2024-03-23 11:31:08 -07:00

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})))