(ns coinbase-pro.client-test (:require [clojure.test :as t :refer [deftest is testing]] [clojure.data.json :as json] [clojure.string :as str] [fudo-clojure.http.client :as http] [fudo-clojure.http.request :as req] [fudo-clojure.common :as common] [fudo-clojure.result :refer [unwrap map-success]] [exchange.account :as acct] [exchange.client :as client] [exchange.order :as order] [coinbase-pro.client :as cb] [fudo-clojure.logging :as log]) (:use coinbase-pro.client)) (def cb-passphrase "6%CRf$zL!b@toz") (def cb-secret "J/KtQJgu/TRsWigrqnc2KFBxdOTC5L8woaubzmj7G9hfxv9N5tcgsZ+yd9DvqmroJt1nzcy5Hsrk3IipcdFucA==") (def cb-key "57c45fb2a300dd00f7a02af8353af81b") (def test-time-str "2022-02-06T10:34:47.123456Z") (def test-time (common/parse-timestamp test-time-str)) (defmacro is-true [& body] `(clojure.test/is (= true ~@body))) (defmacro is-false [& body] `(clojure.test/is (= false ~@body))) (defmacro is-success? [& body] `(is-true (fudo-clojure.result/success? ~@body))) (defmacro is-failure? [& body] `(is-true (fudo-clojure.result/failure? ~@body))) (defn gen-uuid [] (java.util.UUID/randomUUID)) (defn base-request [] (-> (req/base-request) (req/with-timestamp test-time) (req/as-get) (req/with-host "some.dummy.host"))) (defn- http-client-returning [response] (-> (reify http/HTTPClient (get! [_ _] response) (post! [_ _] response) (delete! [_ _] response)) (http/client:wrap-results) (http/client:jsonify))) (defn- http-client-throwing [e] (-> (reify http/HTTPClient (get! [_ _] (throw e)) (post! [_ _] (throw e)) (delete! [_ _] (throw e))) (http/client:wrap-results) (http/client:jsonify))) (defn sample [coll] (nth coll (rand-int (count coll)))) (defn date-to-instant [date] (-> (java.time.LocalDateTime/parse (str date "T00:00")) (.atZone (java.time.ZoneId/systemDefault)) (.toInstant))) (defn instant-to-epoch [i] (.getEpochSecond i)) (defn random-instant [& {:keys [start end] :or {start (date-to-instant "2010-01-01") end (date-to-instant "2022-05-01")}}] (let [s (instant-to-epoch start) e (instant-to-epoch end)] (java.time.Instant/ofEpochSecond (+ s (rand-int (- e s)))))) (defn gen-order "A response from Coinbase" ([] (gen-order {})) ([overrides] (let [created-at (random-instant)] (merge { :id (gen-uuid) :product_id (sample ["BTC-USD" "ADA-USD" "ETH-USD"]) :type (sample [:limit :market]) :side (sample [:buy :sell]) :done_reason (sample [nil :done :cancelled]) :price (bigdec (rand 100000)) :stop-price (sample [nil (rand 100000)]) :size (bigdec (rand 1000)) :settled (sample [true false]) :fill_fees (bigdec (rand 100)) :completed (sample [true false]) :created_at (str created-at) :done_at (str (sample [nil (random-instant :end created-at)])) } overrides)))) (defn make-public-client [http-client] (#'coinbase-pro.client/reify-exchange-client {::http/client http-client})) (defn public-client-returning [resp] (make-public-client (http-client-returning resp))) (defn public-client-throwing [e] (make-public-client (http-client-throwing e))) (defn resp [& {:keys [code body]}] {:status (.toString (or code 200)) :body (json/write-str (or body {}))}) (deftest test-auth-headers (let [creds {::cb/key cb-key ::cb/secret cb-secret ::cb/passphrase cb-passphrase} make-request-authenticator #'coinbase-pro.client/make-request-authenticator authenticator (make-request-authenticator creds)] (testing "timestamp-header" (is (= (str (common/instant-to-epoch-timestamp test-time)) (-> (authenticator (-> (base-request) (req/as-get) (req/with-path "/one/two") (req/with-body "hello"))) ::req/headers ::cb/cb-access-timestamp)))) (testing "access-key-header" (is (= cb-key (-> (authenticator (-> (base-request) (req/as-get) (req/with-path "/one/two") (req/with-body "hello"))) ::req/headers ::cb/cb-access-key)))) (testing "access-passphrase" (is (= cb-passphrase (-> (authenticator (-> (base-request) (req/as-get) (req/with-path "/one/two") (req/with-body "hello"))) ::req/headers ::cb/cb-access-passphrase)))) (testing "signature" (let [signer (#'coinbase-pro.client/make-signature-generator (common/base64-decode cb-secret)) ts (common/instant-to-epoch-timestamp test-time)] (let [cat-str (str ts "GET/one/two?") sig (signer cat-str)] (is (= sig (-> (authenticator (-> (base-request) (req/as-get) (req/with-path "/one/two"))) ::req/headers ::cb/cb-access-sign)))) (let [cat-str (str ts "GET/one/two?hello") sig (signer cat-str)] (is (= sig (-> (authenticator (-> (base-request) (req/as-get) (req/with-path "/one/two") (req/with-body "hello"))) ::req/headers ::cb/cb-access-sign)))) (let [cat-str (str ts "GET/one/two?") sig (signer cat-str)] (is (not (= sig (-> (authenticator (-> (base-request) (req/as-get) (req/with-path "/one/two") (req/with-body "oops"))) ::req/headers ::cb/cb-access-sign)))) (is (not (= sig (-> (authenticator (-> (base-request) (req/as-get) (req/with-path "/one/two") (req/with-timestamp (java.time.Instant/now)))) ::req/headers ::cb/cb-access-sign))))))))) (defn make-account-client [http-client] (#'coinbase-pro.client/reify-exchange-account-client {::http/client http-client})) (defn account-client-returning [resp] (make-account-client (http-client-returning resp))) (defn account-client-throwing [e] (make-account-client (http-client-throwing e))) (deftest test-accounts (let [balance (bigdec (rand 10000000)) available (bigdec (rand balance)) hold (bigdec (- balance available)) id (gen-uuid) profile-id (gen-uuid) currency "BTC" result [{:id (str id) :profile_id (str profile-id) :currency currency :balance (str balance) :available (str available) :hold (str hold) :trading_enabled true} {:id (str (gen-uuid)) :profile_id (str (gen-uuid)) :currency "ETH" :balance (str balance) :available (str available) :hold (str hold) :trading_enabled true}] response (resp :body result)] (testing "get-accounts!" (is-success? (client/get-accounts! (account-client-returning response))) (is-failure? (client/get-accounts! (account-client-throwing (RuntimeException. "oops!")))) (is-true (every? acct/account? (vals (unwrap (client/get-accounts! (account-client-returning response)))))) (is (= balance (-> (account-client-returning response) (client/get-accounts!) (unwrap) :btc (acct/balance)))) (is (= available (-> (account-client-returning response) (client/get-accounts!) (unwrap) :btc (acct/available)))) (is (= hold (-> (account-client-returning response) (client/get-accounts!) (unwrap) :btc (acct/hold)))) (is (= (-> currency str/lower-case keyword) (-> (account-client-returning response) (client/get-accounts!) (unwrap) :btc (acct/currency))))) (testing "get-account!" (is-success? (client/get-account! (account-client-returning response) :btc)) (is-success? (client/get-account! (account-client-returning response) :eth)) (is-failure? (client/get-account! (account-client-returning response) :ada)) (is-true (-> (client/get-account! (account-client-returning response) :btc) (unwrap) (acct/account?)))))) (deftest test-cancel-order (let [order-id (gen-uuid)] (is-success? (client/cancel-order! (account-client-returning (resp :body order-id )) order-id)) (is-failure? (client/cancel-order! (account-client-throwing (RuntimeException. "oops!")) order-id)) (is-failure? (client/cancel-order! (account-client-throwing (ex-info "not found" { :status 404 })) order-id)) (is (= 404 (http/status (client/cancel-order! (account-client-throwing (ex-info "Not found!" { :status 404 })) order-id)))))) (defmacro test-order-property [order-fn val params] `(is (= ~val (-> (client/get-order! (account-client-returning (resp :body (gen-order ~params))) (gen-uuid)) (unwrap) ~order-fn)))) (deftest test-get-orders (testing "get-order!" (let [order-id (gen-uuid) respond-with-order (fn [m] (account-client-returning (resp :body (gen-order (merge { :id order-id } m)))))] (is-success? (client/get-order! (respond-with-order {}) order-id)) (is-failure? (client/get-order! (account-client-throwing (RuntimeException. "oops!")) order-id)) (is-failure? (client/get-order! (account-client-throwing (ex-info "not found" { :status 404 })) order-id)) (is (= 404 (http/status (client/get-order! (account-client-throwing (ex-info "not found" { :status 404 })) order-id)))) (test-order-property order/id order-id { :id order-id }) (let [price (bigdec (rand 100000)) size (bigdec (rand 100000)) fees (bigdec (rand 1000)) stop-price (bigdec (rand 100000))] (test-order-property order/price price { :price price }) (test-order-property order/size size { :size size }) (test-order-property order/fees fees { :fill_fees fees }) (test-order-property order/stop-price stop-price { :stop_price stop-price }) (test-order-property order/stop-price nil { :stop_price nil })) (test-order-property order/currency :btc { :product_id "BTC-USD" }) (test-order-property order/currency :eth { :product_id "ETH-USD" }) (is-failure? (-> (client/get-order! (respond-with-order { :product_id "BTC-USD?" }) order-id) (map-success order/currency))) (test-order-property order/limit? true { :type :limit }) (test-order-property order/market? false { :type :limit }) (test-order-property order/limit? false { :type :market }) (test-order-property order/market? true { :type :market }) (test-order-property order/stop? true { :stop :stop }) (test-order-property order/stop? true { :stop :entry }) (test-order-property order/stop? false { :stop nil }) (test-order-property order/stop-loss? true { :stop :loss }) (test-order-property order/stop-gain? false { :stop :loss }) (test-order-property order/stop-loss? false { :stop :entry }) (test-order-property order/stop-gain? true { :stop :entry }) (test-order-property order/stop-loss? false { :stop nil }) (test-order-property order/stop-gain? false { :stop nil }) (test-order-property order/sell? true { :side :sell }) (test-order-property order/buy? false { :side :sell }) (test-order-property order/sell? false { :side :buy }) (test-order-property order/buy? true { :side :buy }) (test-order-property order/filled? true { :done_reason :filled }) (test-order-property order/filled? false { :done_reason nil }) (test-order-property order/created test-time { :created_at test-time-str}) (test-order-property order/completed test-time { :done_at test-time-str}) (test-order-property order/completed nil { :done_at nil }))) (testing "get-orders!" (let [orders (map (fn [_] (gen-order)) (range 10))] (is-success? (client/get-orders! (account-client-returning (resp :body orders)) :btc)) (is (= (count orders) (count (unwrap (client/get-orders! (account-client-returning (resp :body orders)) :btc))))) (is-true (->> (client/get-orders! (account-client-returning (resp :body orders)) :btc) (unwrap) (every? order/order?))))))