(ns coinbase-pro.client (:require [clojure.set :as set] [clojure.spec.alpha :as s] [clojure.string :as str] [exchange.account :as acct] [exchange.client :as client] [exchange.order :as order] [exchange.ticker :as ticker] [coinbase-pro.order :as order-req] [fudo-clojure.common :refer [base64-decode base64-encode-string ensure-conform instant-to-epoch-timestamp to-uuid parse-timestamp pthru]] [fudo-clojure.http.client :as http] [fudo-clojure.http.request :as req] [fudo-clojure.logging :as log] [fudo-clojure.result :refer [map-success bind success exception-failure]])) (def signature-algo "HmacSHA256") (defn- to-path-elem [el] (cond (keyword? el) (name el) (uuid? el) (.toString el) (string? el) el :else (throw (ex-info (str "Bad path element: " el) {})))) (defn- build-path [& path-elems] (str "/" (str/join "/" (map to-path-elem path-elems)))) (defn- make-signature-generator [key] (let [hmac (doto (javax.crypto.Mac/getInstance signature-algo) (.init (javax.crypto.spec.SecretKeySpec. key signature-algo)))] (fn [msg] (-> (.doFinal hmac (.getBytes msg)) (base64-encode-string))))) (s/def ::secret string?) (s/def ::passphrase string?) (s/def ::key string?) (s/def ::hostname string?) (s/def ::profile-id uuid?) (s/def ::trade-id uuid?) (s/def ::order-id uuid?) (s/def ::credentials (s/keys :req [::authenticator ::key ::passphrase])) (s/def ::hostname string?) (s/def ::connection (s/keys :req [::hostname ::http/client] :opt [::log/logger])) (s/def ::authenticated-connection (s/and ::connection (s/keys :req [::credentials]))) (defn- make-request-authenticator [{key ::key secret ::secret passphrase ::passphrase}] (let [sign (make-signature-generator (base64-decode secret))] (fn [req] (let [epoch-timestamp (-> req req/timestamp instant-to-epoch-timestamp str) req-str (str epoch-timestamp (-> req req/method name) (-> req req/request-path) (-> req req/body (or ""))) signature (sign req-str)] (req/with-headers req {::cb-access-timestamp epoch-timestamp ::cb-access-key key ::cb-access-passphrase passphrase ::cb-access-sign signature}))))) (def lower-case-keyword (comp keyword str/lower-case)) (defn- currency-product [base currency] (str (-> currency name str/upper-case) "-" base)) (defn- product-currency [base product] (let [product-rx (re-pattern (format "^([A-Z]{2,5})-%s$" base))] (if-let [currency (some-> (re-matches product-rx product) (get 1) (lower-case-keyword))] currency (throw (ex-info (str "not a valid product_id: " product) {:product product}))))) (defn- accounts-request [] (-> (req/base-request) (req/as-get) (req/with-path (build-path :accounts)))) (defn- reify-account [acct] (reify acct/CurrencyAccount (currency [_] (-> acct :currency lower-case-keyword)) (balance [_] (-> acct :balance bigdec)) (hold [_] (-> acct :hold bigdec)) (available [_] (-> acct :available bigdec)))) (defn- order-request [order-id] (-> (req/base-request) (req/as-get) (req/with-path (build-path :orders order-id)))) (defn- cancel-order-request [order-id] (-> (req/base-request) (req/as-delete) (req/with-path (build-path :orders order-id)))) (defn- currency-orders-request ([base currency] (currency-orders-request currency {})) ([base currency query] (-> (req/base-request) (req/as-get); (req/with-path (build-path :orders)) (req/with-query-params (merge query { :product_id (currency-product base currency) }))))) (defn- create-order-request [order] (-> (req/base-request) (req/as-post) (req/with-path (build-path :orders)) (req/with-body-params (ensure-conform ::order-req/order order)))) (s/fdef create-order-request :args (s/cat :params ::order-req/order) :ret ::req/request) (defn- ticker-request [base currency] (-> (req/base-request) (req/as-get) (req/with-path (build-path :products (currency-product base currency) :ticker)))) (defn- ensure-keys [ks m] (let [diff (set/difference ks (set (keys m)))] (when (seq diff) (throw (ex-info (str "missing keys: " (str/join "," diff)) {:missing-keys diff :map m}))))) (defn- reify-order [order] (let [required-keys #{:id :product_id :type :side :price :size :settled :created_at}] (ensure-keys required-keys order) (reify order/Order (id [_] (-> order :id to-uuid)) (currency [_] (-> order :product_id product-currency)) (limit? [_] (-> order :type keyword (= :limit))) (market? [_] (-> order :type keyword (= :market))) (stop? [_] (-> order :stop nil? not)) (sell? [_] (-> order :side keyword (= :sell))) (buy? [_] (-> order :side keyword (= :buy))) (stop-loss? [_] (-> order :stop keyword (= :loss))) (stop-gain? [_] (-> order :stop keyword (= :entry))) (filled? [_] (-> order :done_reason keyword (= :filled))) (price [_] (-> order :price bigdec)) (stop-price [_] (some-> order :stop_price bigdec)) (size [_] (-> order :size bigdec)) (settled? [_] (-> order :settled)) (done? [_] (-> order :status keyword (= :done))) (cancelled? [_] (-> order :done_reason keyword (= :canceled))) (fees [_] (some-> order :fill_fees bigdec)) (created [_] (-> order :created_at parse-timestamp)) (completed [_] (some-> order :done_at parse-timestamp)) (get-raw [_] order)))) (defn- reify-ticker [currency ticker] (reify ticker/Ticker (currency [_] currency) (price [_] (-> ticker :price bigdec)) (tick-time [_] (-> ticker :time parse-timestamp)) (bid [_] (-> ticker :bid bigdec)) (ask [_] (-> ticker :ask bigdec)) (volume [_] (-> ticker :volume bigdec)))) (defn- reify-exchange-client [{client ::http/client hostname ::hostname logger ::log/logger base ::base-currency}] (let [request! (fn [req] (http/execute-request! client (req/with-host req hostname)))] (reify client/ExchangeClient (get-ticker! [_ currency] (map-success (request! (ticker-request base currency)) (partial reify-ticker currency))) (get-market-price! [self currency] (map-success (client/get-ticker! self currency) ticker/price))))) (defn- reify-exchange-account-client [opts] (let [{client ::http/client hostname ::hostname logger ::log/logger base ::base-currency} opts public-client (reify-exchange-client opts) request! (fn [req] (http/execute-request! client (req/with-host req hostname))) before (fn [a b] (.isBefore a b)) reify-order (partial reify-order base) reify-orders (comp (partial sort-by order/created before) (partial map reify-order)) accounts-map (fn [accts] (into {} (map (juxt acct/currency identity) accts)))] (reify client/ExchangeClient (get-ticker! [_ currency] (client/get-ticker! public-client currency)) (get-market-price! [_ currency] (client/get-market-price! public-client currency)) client/ExchangeAccountClient (get-accounts! [_] (map-success (request! (accounts-request)) (comp accounts-map (partial map reify-account)))) (get-account! [this currency] (bind (client/get-accounts! this) (fn [accts] (if-let [acct (get accts currency)] (success acct) (exception-failure (ex-info (str "no account for currency: " currency) {:currency currency :existing-accounts accts})))))) (get-order! [_ order-id] (map-success (request! (order-request order-id)) reify-order)) (get-orders! [_ currency] (map-success (request! (currency-orders-request base currency)) reify-orders)) (get-incomplete-orders! [_ currency] (map-success (request! (currency-orders-request base currency { ::order/status [:open :pending] })) reify-orders)) (get-completed-orders! [_ currency] (map-success (request! (currency-orders-request base currency { ::order/status [:done] })) reify-orders)) (get-completed-limit-orders! [self currency] (map-success (client/get-completed-orders! self currency) (comp reify-orders (partial filter order/limit?)))) (get-completed-limit-buy-orders! [self currency] (map-success (client/get-completed-limit-orders! self currency) (comp reify-orders (partial filter order/buy?)))) (get-completed-limit-sell-orders! [self currency] (map-success (client/get-completed-limit-orders! self currency) (comp reify-orders (partial filter order/sell?)))) (cancel-order! [_ order-id] (map-success (request! (cancel-order-request order-id)) to-uuid)) (create-stop-loss-order! [_ currency stop-price sell-price size] (map-success (request! (create-order-request (-> (order-req/base-order (currency-product base currency)) (order-req/as-stop-loss (bigdec stop-price)) (order-req/with-price (bigdec sell-price)) (order-req/with-size (bigdec size))))) (comp to-uuid :id))) (create-stop-gain-order! [_ currency stop-price buy-price size] (map-success (request! (create-order-request (-> (order-req/base-order (currency-product base currency)) (order-req/as-stop-gain (bigdec stop-price)) (order-req/with-price (bigdec buy-price)) (order-req/with-size (bigdec size))))) (comp to-uuid :id))) (create-limit-sell-order! [_ currency sell-price size] (map-success (request! (create-order-request (-> (order-req/base-order (currency-product base currency)) (order-req/as-limit) (order-req/as-sell) (order-req/with-price (bigdec sell-price)) (order-req/with-size (bigdec size))))) (comp to-uuid :id))) (create-limit-buy-order! [_ currency buy-price size] (map-success (request! (create-order-request (-> (order-req/base-order (currency-product base currency)) (order-req/as-limit) (order-req/as-buy) (order-req/with-price (bigdec buy-price)) (order-req/with-size (bigdec size))))) (comp to-uuid :id)))))) (defn connect [& {:keys [hostname logger credentials base-currency] :or { logger (log/print-logger :error :notify) base-currency "USD"}}] (if credentials (let [authenticator (make-request-authenticator credentials)] (reify-exchange-account-client {::http/client (http/json-client :authenticator authenticator :logger logger) ::log/logger logger ::hostname hostname})) (reify-exchange-client {::http/client (http/json-client :logger logger) ::log/logger logger ::hostname hostname})))