diff --git a/resources/migrations/20260306100000-add-strike-quote-sats.down.sql b/resources/migrations/20260306100000-add-strike-quote-sats.down.sql new file mode 100644 index 0000000..087d37b --- /dev/null +++ b/resources/migrations/20260306100000-add-strike-quote-sats.down.sql @@ -0,0 +1 @@ +ALTER TABLE strike_price DROP COLUMN quote_sats; diff --git a/resources/migrations/20260306100000-add-strike-quote-sats.up.sql b/resources/migrations/20260306100000-add-strike-quote-sats.up.sql new file mode 100644 index 0000000..3a64635 --- /dev/null +++ b/resources/migrations/20260306100000-add-strike-quote-sats.up.sql @@ -0,0 +1 @@ +ALTER TABLE strike_price ADD COLUMN quote_sats BIGINT; diff --git a/resources/queries.sql b/resources/queries.sql index f88487b..08e1b96 100644 --- a/resources/queries.sql +++ b/resources/queries.sql @@ -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 diff --git a/resources/system.edn b/resources/system.edn index 856b673..7ab51cc 100644 --- a/resources/system.edn +++ b/resources/system.edn @@ -64,5 +64,6 @@ {:url #or [#env FRANKFURTER_URL "http://localhost:8080"]} :strike/ticker - {:query-fn #ig/ref :db.sql/query-fn - :api-key #env STRIKE_API_KEY}} + {:query-fn #ig/ref :db.sql/query-fn + :api-key #env STRIKE_API_KEY + :sats-eur-amount #or [#env SATS_EUR_AMOUNT "55"]}} diff --git a/src/clj/pmagnus/btcdata/strike/ticker.clj b/src/clj/pmagnus/btcdata/strike/ticker.clj index ad44c93..167fc16 100644 --- a/src/clj/pmagnus/btcdata/strike/ticker.clj +++ b/src/clj/pmagnus/btcdata/strike/ticker.clj @@ -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] - (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)) + [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) + 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}))