309 lines
13 KiB
Clojure
309 lines
13 KiB
Clojure
(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})))
|