Add Strike quote endpoint for real sats price with fee-adjusted amount

Use POST /v1/currency-exchange-quotes to get actual buy price including
spread, matching the Strike app. Fee is cached daily to calculate the
pre-fee amount from the configured total (default 55 EUR). Store
quote_sats in the strike_price DB table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 11:42:35 +01:00
co-authored by Claude Opus 4.6
parent 3e5fd490b4
commit 146c3a4788
5 changed files with 82 additions and 13 deletions
@@ -0,0 +1 @@
ALTER TABLE strike_price DROP COLUMN quote_sats;
@@ -0,0 +1 @@
ALTER TABLE strike_price ADD COLUMN quote_sats BIGINT;
+1 -1
View File
@@ -45,7 +45,7 @@ SELECT ts FROM kraken_day ORDER BY ts DESC LIMIT 1
-- :name insert-strike-price! :! :n
-- :doc Insert a Strike ticker snapshot
INSERT INTO strike_price (rates) VALUES (:rates)
INSERT INTO strike_price (rates, quote_sats) VALUES (:rates, :quote-sats)
-- :name get-latest-strike-price :? :1
-- :doc Get the most recent Strike ticker snapshot
+2 -1
View File
@@ -65,4 +65,5 @@
:strike/ticker
{:query-fn #ig/ref :db.sql/query-fn
:api-key #env STRIKE_API_KEY}}
:api-key #env STRIKE_API_KEY
:sats-eur-amount #or [#env SATS_EUR_AMOUNT "55"]}}
+75 -9
View File
@@ -7,6 +7,7 @@
(:import
[java.net URI]
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers]
[java.time Duration Instant]
[org.postgresql.util PGobject]))
(defn- ->jsonb
@@ -29,6 +30,58 @@
body (.body resp)]
body))
(defn- post-quote
"POST /v1/currency-exchange-quotes. Returns parsed response map or nil."
[^HttpClient client ^String api-key ^String eur-amount]
(let [body-str (json/write-str {:sell "EUR" :buy "BTC"
:amount {:amount eur-amount :currency "EUR"}})
request (-> (HttpRequest/newBuilder)
(.uri (URI. "https://api.strike.me/v1/currency-exchange-quotes"))
(.header "Accept" "application/json")
(.header "Content-Type" "application/json")
(.header "Authorization" (str "Bearer " api-key))
(.POST (java.net.http.HttpRequest$BodyPublishers/ofString body-str))
(.build))
resp (.send client request (HttpResponse$BodyHandlers/ofString))
status (.statusCode resp)
body (.body resp)]
(if (= 200 status)
(json/read-str body :key-fn keyword)
(do (log/warn "Strike quote HTTP" status body)
nil))))
(def ^:private fee-max-age (Duration/ofHours 24))
(defn- refresh-fee!
"Fetch fee for sats-eur-amount and cache it. Returns the fee BigDecimal or nil."
[client api-key sats-eur-amount fee-cache]
(when-let [parsed (post-quote client api-key sats-eur-amount)]
(let [fee (bigdec (get-in parsed [:fee :amount]))]
(log/info "Strike fee for" sats-eur-amount "EUR:" fee "EUR")
(reset! fee-cache {:fee fee :fetched-at (Instant/now)})
fee)))
(defn- cached-fee
"Return cached fee if fresh, otherwise refresh. Returns BigDecimal or nil."
[client api-key sats-eur-amount fee-cache]
(let [{:keys [fee fetched-at]} @fee-cache]
(if (and fee fetched-at
(.isBefore (Instant/now) (.plus ^Instant fetched-at fee-max-age)))
fee
(refresh-fee! client api-key sats-eur-amount fee-cache))))
(defn- fetch-quote-sats
"Get sats for a total EUR amount (including fee). Uses cached fee to calculate
pre-fee amount, then quotes that. Returns sats as long or nil."
[client api-key sats-eur-amount fee-cache]
(let [total (bigdec sats-eur-amount)
fee (cached-fee client api-key sats-eur-amount fee-cache)
pre-fee (if fee (.toPlainString (.subtract total fee)) sats-eur-amount)]
(when-let [parsed (post-quote client api-key pre-fee)]
(let [btc-amt (get-in parsed [:target :amount])]
(when btc-amt
(long (* (bigdec btc-amt) 100000000)))))))
(defn- format-rates
"Build a compact log string from the parsed rates array."
[rates]
@@ -47,36 +100,49 @@
(defn- poll!
"Fetch rates and insert into the database. Updates latest-atom."
[client api-key query-fn latest-atom]
[client api-key query-fn latest-atom sats-eur-amount fee-cache]
(let [body (fetch-rates client api-key)
rates (json/read-str body :key-fn keyword)]
(query-fn :insert-strike-price! {:rates (->jsonb body)})
(reset! latest-atom (rates->map rates))
rates (json/read-str body :key-fn keyword)
rmap (rates->map rates)
quote-sats (when sats-eur-amount
(try
(let [sats (fetch-quote-sats client api-key sats-eur-amount fee-cache)]
(when sats
(log/info "Strike quote:" sats-eur-amount "EUR ->" sats "sats"))
sats)
(catch Exception e
(log/error e "Strike quote fetch failed")
nil)))
rmap (if quote-sats (assoc rmap "quote-sats" quote-sats) rmap)]
(query-fn :insert-strike-price! {:rates (->jsonb body)
:quote-sats quote-sats})
(reset! latest-atom rmap)
(log/info "Strike:" (format-rates rates))))
(defn- start-poll-loop!
"Fetch immediately, then poll every 10 seconds."
[client api-key query-fn running? latest-atom]
[client api-key query-fn running? latest-atom sats-eur-amount fee-cache]
(future
(try
(poll! client api-key query-fn latest-atom)
(poll! client api-key query-fn latest-atom sats-eur-amount fee-cache)
(catch Exception e
(log/error e "Strike initial fetch failed")))
(while @running?
(Thread/sleep 10000)
(when @running?
(try
(poll! client api-key query-fn latest-atom)
(poll! client api-key query-fn latest-atom sats-eur-amount fee-cache)
(catch Exception e
(log/error e "Strike poll failed")))))))
(defmethod ig/init-key :strike/ticker
[_ {:keys [query-fn api-key]}]
[_ {:keys [query-fn api-key sats-eur-amount]}]
(log/info "Starting Strike ticker poller")
(let [client (HttpClient/newHttpClient)
running? (atom true)
latest-rates (atom nil)
fut (start-poll-loop! client api-key query-fn running? latest-rates)]
fee-cache (atom nil)
fut (start-poll-loop! client api-key query-fn running? latest-rates sats-eur-amount fee-cache)]
{:running? running?
:future fut
:latest-rates latest-rates}))