Working with tests!
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
(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.common :as common]
|
||||
[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 [currency]
|
||||
(str (-> currency name str/upper-case)
|
||||
"-USD"))
|
||||
|
||||
(defn- product-currency [product]
|
||||
(if-let [currency (some-> (re-matches #"^([A-Z]{2,5})-USD$" 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
|
||||
([currency] (currency-orders-request currency {}))
|
||||
([currency query] (-> (req/base-request)
|
||||
(req/as-get);
|
||||
(req/with-path (build-path :orders))
|
||||
(req/with-query-params
|
||||
(merge query { :product_id (currency-product 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 [currency]
|
||||
(-> (req/base-request)
|
||||
(req/as-get)
|
||||
(req/with-path (build-path :products
|
||||
(currency-product 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}]
|
||||
(let [request! (fn [req] (http/execute-request! client (req/with-host req hostname)))]
|
||||
(reify client/ExchangeClient
|
||||
(get-ticker! [_ currency]
|
||||
(map-success (request! (ticker-request 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} 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-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 currency))
|
||||
reify-orders))
|
||||
|
||||
(get-incomplete-orders! [_ currency]
|
||||
(map-success (request! (currency-orders-request currency { ::order/status [:open :pending] }))
|
||||
reify-orders))
|
||||
|
||||
(get-completed-orders! [_ currency]
|
||||
(map-success (request! (currency-orders-request 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 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 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 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 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]
|
||||
:or { logger (log/print-logger) }}]
|
||||
(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})))
|
||||
Reference in New Issue
Block a user