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 -- :name insert-strike-price! :! :n
-- :doc Insert a Strike ticker snapshot -- :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 -- :name get-latest-strike-price :? :1
-- :doc Get the most recent Strike ticker snapshot -- :doc Get the most recent Strike ticker snapshot
+3 -2
View File
@@ -64,5 +64,6 @@
{:url #or [#env FRANKFURTER_URL "http://localhost:8080"]} {:url #or [#env FRANKFURTER_URL "http://localhost:8080"]}
:strike/ticker :strike/ticker
{:query-fn #ig/ref :db.sql/query-fn {: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"]}}
+76 -10
View File
@@ -7,6 +7,7 @@
(:import (:import
[java.net URI] [java.net URI]
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers] [java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers]
[java.time Duration Instant]
[org.postgresql.util PGobject])) [org.postgresql.util PGobject]))
(defn- ->jsonb (defn- ->jsonb
@@ -29,6 +30,58 @@
body (.body resp)] body (.body resp)]
body)) 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 (defn- format-rates
"Build a compact log string from the parsed rates array." "Build a compact log string from the parsed rates array."
[rates] [rates]
@@ -47,36 +100,49 @@
(defn- poll! (defn- poll!
"Fetch rates and insert into the database. Updates latest-atom." "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) (let [body (fetch-rates client api-key)
rates (json/read-str body :key-fn keyword)] rates (json/read-str body :key-fn keyword)
(query-fn :insert-strike-price! {:rates (->jsonb body)}) rmap (rates->map rates)
(reset! latest-atom (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)))) (log/info "Strike:" (format-rates rates))))
(defn- start-poll-loop! (defn- start-poll-loop!
"Fetch immediately, then poll every 10 seconds." "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 (future
(try (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 (catch Exception e
(log/error e "Strike initial fetch failed"))) (log/error e "Strike initial fetch failed")))
(while @running? (while @running?
(Thread/sleep 10000) (Thread/sleep 10000)
(when @running? (when @running?
(try (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 (catch Exception e
(log/error e "Strike poll failed"))))))) (log/error e "Strike poll failed")))))))
(defmethod ig/init-key :strike/ticker (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") (log/info "Starting Strike ticker poller")
(let [client (HttpClient/newHttpClient) (let [client (HttpClient/newHttpClient)
running? (atom true) running? (atom true)
latest-rates (atom nil) 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? {:running? running?
:future fut :future fut
:latest-rates latest-rates})) :latest-rates latest-rates}))