Compare commits

...
11 Commits
Author SHA1 Message Date
magnusandClaude Opus 4.6 94c56b7cc3 Fall back to DB for price when Binance websocket not connected
The /api/price/latest endpoint now reads the latest row from
binance_price table when the in-memory atom is empty on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:11:33 +01:00
magnusandClaude Opus 4.6 0caa8db0b3 Add buys query table from exchange_to_wallet events
Denormalized read table with id, event_id, occurred_at, wallet, sats,
fee_sats, and amount_eur. Projected on event creation and rebuildable
via POST /api/buys/rebuild. Fiat amounts converted to EUR using
Frankfurter rates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:44:44 +01:00
magnusandClaude Opus 4.6 93073820a2 Change Kraken hourly poller to 60s interval, fetch last 5 candles
Keeps the in-progress hour candle fresh instead of only updating at
the top of each hour.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:06:27 +01:00
magnusandClaude Opus 4.6 bba9652ced Add datetime field to kraken-hour and kraken-minute API responses
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:41:52 +01:00
magnusandClaude Opus 4.6 b6c8745a86 Add /api/kraken-minute endpoint and limit kraken-hour to 24 rows
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:37:00 +01:00
magnusandClaude Opus 4.6 42454646bb Add /api/kraken-hour endpoint returning last 60 hourly candles
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:33:08 +01:00
magnusandClaude Opus 4.6 44bb9a68d4 Add OHLC values to Kraken minute poller log output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:18:05 +01:00
magnusandClaude Opus 4.6 ae65ea3722 Show human-readable timestamps in Kraken minute poller logs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:14:35 +01:00
magnusandClaude Opus 4.6 f27c198634 Limit minute poller to last 5 candles after initial fetch
Reduces unnecessary upserts on each 15-second poll cycle by only
processing the 5 most recent candles from Kraken's response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:09:24 +01:00
magnusandClaude Opus 4.6 b49e8afd59 Add kraken_minute table with 15-second polling
1-minute OHLC candles from Kraken, polled every 15 seconds to keep the
in-progress candle fresh. Unlike hourly/daily pollers, this one keeps
the last candle and upserts it as it updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:06:36 +01:00
magnusandClaude Opus 4.6 747c834e6a Add KRAKEN_ENABLED flag to disable Kraken pollers in local dev
Same pattern as BINANCE_ENABLED and STRIKE_ENABLED. Prevents local
dev from fetching external pricing data when pointed at btcprod.
Docker explicitly sets KRAKEN_ENABLED=true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:45:10 +01:00
15 changed files with 351 additions and 63 deletions
+1
View File
@@ -19,6 +19,7 @@ services:
CORS_ORIGIN: "${DOCKER_CORS_ORIGIN}"
FRANKFURTER_URL: "${DOCKER_FRANKFURTER_URL}"
STRIKE_API_KEY: "${STRIKE_API_KEY}"
KRAKEN_ENABLED: "true"
extra_hosts:
- "postgres:host-gateway"
@@ -0,0 +1 @@
DROP TABLE IF EXISTS kraken_minute;
@@ -0,0 +1,11 @@
CREATE TABLE kraken_minute (
ts BIGINT NOT NULL PRIMARY KEY,
open NUMERIC(18,8) NOT NULL,
high NUMERIC(18,8) NOT NULL,
low NUMERIC(18,8) NOT NULL,
close NUMERIC(18,8) NOT NULL,
vwap NUMERIC(18,8) NOT NULL,
volume NUMERIC(24,8) NOT NULL,
trade_count INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS buys;
@@ -0,0 +1,7 @@
CREATE TABLE buys (
id BIGSERIAL PRIMARY KEY,
event_id BIGINT NOT NULL REFERENCES events(id) UNIQUE,
occurred_at DATE NOT NULL,
wallet TEXT NOT NULL,
amount_eur NUMERIC(18,2) NOT NULL
);
@@ -0,0 +1,3 @@
ALTER TABLE buys DROP COLUMN fee_sats;
--;;
ALTER TABLE buys DROP COLUMN sats;
@@ -0,0 +1,3 @@
ALTER TABLE buys ADD COLUMN sats BIGINT NOT NULL DEFAULT 0;
--;;
ALTER TABLE buys ADD COLUMN fee_sats BIGINT;
+51
View File
@@ -155,6 +155,57 @@ SELECT id, event_id, occurred_at, exchange, fiat_amount, fiat_currency,
FROM deposits
ORDER BY occurred_at DESC, id DESC
-- :name truncate-buys! :! :n
-- :doc Delete all rows from the buys read table
TRUNCATE buys
-- :name get-buy-events :? :*
-- :doc Get exchange_to_wallet events with wallet name, oldest first
SELECT e.id, e.occurred_at, e.sats, e.fee_sats, e.fiat_amount, e.fiat_currency,
e.to_wallet_id, tw.name AS wallet
FROM events e
LEFT JOIN wallets tw ON tw.id = e.to_wallet_id
WHERE e.event_type = 'exchange_to_wallet'
ORDER BY e.occurred_at ASC, e.id ASC
-- :name insert-buy! :! :n
-- :doc Insert a projected buy row
INSERT INTO buys (event_id, occurred_at, wallet, sats, fee_sats, amount_eur)
VALUES (:event-id, :occurred-at, :wallet, :sats, :fee-sats, :amount-eur)
-- :name get-all-buys :? :*
-- :doc Get all projected buys ordered by date descending
SELECT id, event_id, occurred_at, wallet, sats, fee_sats, amount_eur
FROM buys
ORDER BY occurred_at DESC, id DESC
-- :name get-kraken-hour-latest-24 :? :*
-- :doc Get the 24 most recent Kraken hourly candles
SELECT ts, open, high, low, close, vwap, volume, trade_count, created_at
FROM kraken_hour ORDER BY ts DESC LIMIT 24
-- :name get-kraken-minute-latest-60 :? :*
-- :doc Get the 60 most recent Kraken minute candles
SELECT ts, open, high, low, close, vwap, volume, trade_count, created_at
FROM kraken_minute ORDER BY ts DESC LIMIT 60
-- :name upsert-kraken-minute! :! :n
-- :doc Upsert a Kraken minute OHLC candle
INSERT INTO kraken_minute (ts, open, high, low, close, vwap, volume, trade_count)
VALUES (:ts, :open, :high, :low, :close, :vwap, :volume, :trade-count)
ON CONFLICT (ts) DO UPDATE
SET open = EXCLUDED.open,
high = EXCLUDED.high,
low = EXCLUDED.low,
close = EXCLUDED.close,
vwap = EXCLUDED.vwap,
volume = EXCLUDED.volume,
trade_count = EXCLUDED.trade_count
-- :name get-latest-kraken-minute :? :1
-- :doc Get the most recent Kraken minute candle timestamp
SELECT ts FROM kraken_minute ORDER BY ts DESC LIMIT 1
-- :name get-wallet-balances :? :*
-- :doc Compute sats balance per wallet from events
SELECT w.id, w.name,
+8 -2
View File
@@ -56,10 +56,16 @@
:enabled? #or [#env BINANCE_ENABLED "true"]}
:kraken/ohlc
{:query-fn #ig/ref :db.sql/query-fn}
{:query-fn #ig/ref :db.sql/query-fn
:enabled? #or [#env KRAKEN_ENABLED "true"]}
:kraken/ohlc-day
{:query-fn #ig/ref :db.sql/query-fn}
{:query-fn #ig/ref :db.sql/query-fn
:enabled? #or [#env KRAKEN_ENABLED "true"]}
:kraken/ohlc-minute
{:query-fn #ig/ref :db.sql/query-fn
:enabled? #or [#env KRAKEN_ENABLED "true"]}
:frankfurter/rates
{:url #or [#env FRANKFURTER_URL "http://localhost:8080"]
+1
View File
@@ -19,6 +19,7 @@
;; Pollers
[pmagnus.btcdata.kraken.ohlc]
[pmagnus.btcdata.kraken.ohlc-daily]
[pmagnus.btcdata.kraken.ohlc-minute]
[pmagnus.btcdata.frankfurter.rates]
[pmagnus.btcdata.strike.ticker])
(:gen-class)
+30 -45
View File
@@ -54,68 +54,53 @@
:ts))
(defn- poll!
"Fetch OHLC data, drop the last (in-progress) candle, save completed ones.
"Fetch OHLC data, upsert candles. On initial fetch saves all completed candles
(drops last in-progress). On subsequent fetches saves last 5 including in-progress.
Returns the count of saved candles."
[client query-fn since-atom]
[client query-fn since-atom initial?]
(let [result (fetch-ohlc client @since-atom)
;; Kraken returns a map with the pair key and a "last" key
last-ts (:last result)
pair-key (first (remove #{:last} (keys result)))
raw (get result pair-key)
candles (map parse-candle (butlast raw))]
candles (map parse-candle (if initial? (butlast raw) (take-last 5 raw)))]
(when (seq candles)
(save-candles! query-fn candles)
(when last-ts
(reset! since-atom last-ts))
(log/info "Fetched" (count candles) "completed Kraken hourly candles"))
(log/info "Fetched" (count candles) "Kraken hourly candles"))
(count candles)))
(defn- ms-until-next-poll
"Milliseconds from now until next HH:01:00 UTC."
[]
(let [now (java.time.ZonedDateTime/now java.time.ZoneOffset/UTC)
next (-> now
(.truncatedTo java.time.temporal.ChronoUnit/HOURS)
(.plusMinutes 1))
target (if (.isAfter now next)
(.plusHours next 1)
next)]
(.toMillis (java.time.Duration/between now target))))
(defn- start-poll-loop!
"Start a background future that polls Kraken aligned to HH:01:00 UTC.
When `has-data?` is false, fetches immediately to backfill."
[client query-fn since-atom running? has-data?]
"Start a background future that polls Kraken every 60 seconds."
[client query-fn since-atom running?]
(future
(when-not has-data?
(try
(poll! client query-fn since-atom)
(catch Exception e
(log/error e "Kraken OHLC initial poll failed"))))
(try
(poll! client query-fn since-atom true)
(catch Exception e
(log/error e "Kraken OHLC initial poll failed")))
(while @running?
(let [wait (ms-until-next-poll)]
(log/info "Next Kraken OHLC poll in" (quot wait 60000) "minutes")
(Thread/sleep wait)
(when @running?
(try
(poll! client query-fn since-atom)
(catch Exception e
(log/error e "Kraken OHLC poll failed"))))))))
(Thread/sleep 60000)
(when @running?
(try
(poll! client query-fn since-atom false)
(catch Exception e
(log/error e "Kraken OHLC poll failed")))))))
(defmethod ig/init-key :kraken/ohlc
[_ {:keys [query-fn]}]
(let [client (HttpClient/newHttpClient)
db-since (seed-since-from-db query-fn)
since (atom db-since)
running? (atom true)
has-data? (some? db-since)
fut (start-poll-loop! client query-fn since running? has-data?)]
(if has-data?
(log/info "Starting Kraken OHLC poller, next fetch in" (quot (ms-until-next-poll) 60000) "minutes")
(log/info "Starting Kraken OHLC poller, fetching immediately (no data)"))
{:running? running?
:future fut
:since since}))
[_ {:keys [query-fn enabled?]}]
(if (= enabled? "false")
(do (log/info "Kraken OHLC poller disabled")
{:running? (atom false)})
(let [client (HttpClient/newHttpClient)
db-since (seed-since-from-db query-fn)
since (atom db-since)
running? (atom true)
fut (start-poll-loop! client query-fn since running?)]
(log/info "Starting Kraken OHLC poller (60s interval)")
{:running? running?
:future fut
:since since})))
(defmethod ig/halt-key! :kraken/ohlc
[_ {:keys [running? future]}]
+16 -13
View File
@@ -102,19 +102,22 @@
(log/error e "Kraken daily OHLC poll failed"))))))))
(defmethod ig/init-key :kraken/ohlc-day
[_ {:keys [query-fn]}]
(let [client (HttpClient/newHttpClient)
db-since (seed-since-from-db query-fn)
since (atom db-since)
running? (atom true)
has-data? (some? db-since)
fut (start-poll-loop! client query-fn since running? has-data?)]
(if has-data?
(log/info "Starting Kraken daily OHLC poller, next fetch in" (quot (ms-until-next-daily-poll) 60000) "minutes")
(log/info "Starting Kraken daily OHLC poller, fetching immediately (no data)"))
{:running? running?
:future fut
:since since}))
[_ {:keys [query-fn enabled?]}]
(if (= enabled? "false")
(do (log/info "Kraken daily OHLC poller disabled")
{:running? (atom false)})
(let [client (HttpClient/newHttpClient)
db-since (seed-since-from-db query-fn)
since (atom db-since)
running? (atom true)
has-data? (some? db-since)
fut (start-poll-loop! client query-fn since running? has-data?)]
(if has-data?
(log/info "Starting Kraken daily OHLC poller, next fetch in" (quot (ms-until-next-daily-poll) 60000) "minutes")
(log/info "Starting Kraken daily OHLC poller, fetching immediately (no data)"))
{:running? running?
:future fut
:since since})))
(defmethod ig/halt-key! :kraken/ohlc-day
[_ {:keys [running? future]}]
@@ -0,0 +1,114 @@
(ns pmagnus.btcdata.kraken.ohlc-minute
(:require
[clojure.data.json :as json]
[clojure.tools.logging :as log]
[integrant.core :as ig])
(:import
[java.net URI]
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers]
[java.time Instant ZoneOffset]
[java.time.format DateTimeFormatter]))
(def ^:private kraken-ohlc-url
"https://api.kraken.com/0/public/OHLC?pair=XBTUSD&interval=1")
(defn- fetch-ohlc
"HTTP GET to Kraken OHLC endpoint for 1-minute candles.
When `since` is provided, appends &since= to fetch only newer candles."
[^HttpClient client since]
(let [url (if since
(str kraken-ohlc-url "&since=" since)
kraken-ohlc-url)
request (-> (HttpRequest/newBuilder)
(.uri (URI. url))
(.header "Accept" "application/json")
(.GET)
(.build))
resp (.send client request (HttpResponse$BodyHandlers/ofString))
body (json/read-str (.body resp) :key-fn keyword)]
(when-let [errors (seq (:error body))]
(throw (ex-info "Kraken API error" {:errors errors})))
(:result body)))
(defn- parse-candle
"Convert a Kraken OHLC array [ts, open, high, low, close, vwap, volume, count]
to a map with bigdec values."
[[ts open high low close vwap volume count]]
{:ts (long ts)
:open (bigdec open)
:high (bigdec high)
:low (bigdec low)
:close (bigdec close)
:vwap (bigdec vwap)
:volume (bigdec volume)
:trade-count (int count)})
(defn- save-candles!
"Upsert each candle into the kraken_minute table."
[query-fn candles]
(doseq [candle candles]
(query-fn :upsert-kraken-minute! candle)))
(defn- seed-since-from-db
"Query DB for the latest candle timestamp. Returns it or nil."
[query-fn]
(some-> (query-fn :get-latest-kraken-minute {})
:ts))
(defn- poll!
"Fetch OHLC data including the last in-progress candle, upsert all.
On initial fetch saves everything; subsequent fetches only take last 5.
Returns the count of saved candles."
[client query-fn since-atom initial?]
(let [result (fetch-ohlc client @since-atom)
last-ts (:last result)
pair-key (first (remove #{:last} (keys result)))
raw (get result pair-key)
candles (map parse-candle (if initial? raw (take-last 5 raw)))]
(when (seq candles)
(save-candles! query-fn candles)
(when last-ts
(reset! since-atom last-ts))
(let [fmt (DateTimeFormatter/ofPattern "HH:mm")
c (last candles)
ts-str (.format (.atOffset (Instant/ofEpochSecond (:ts c)) ZoneOffset/UTC) fmt)]
(log/info "Kraken minute:" (count candles) "candles, latest" ts-str "UTC"
"O" (str (:open c)) "H" (str (:high c)) "L" (str (:low c)) "C" (str (:close c)))))
(count candles)))
(defn- start-poll-loop!
"Start a background future that polls Kraken every 15 seconds."
[client query-fn since-atom running?]
(future
(try
(poll! client query-fn since-atom true)
(catch Exception e
(log/error e "Kraken minute initial poll failed")))
(while @running?
(Thread/sleep 15000)
(when @running?
(try
(poll! client query-fn since-atom false)
(catch Exception e
(log/error e "Kraken minute poll failed")))))))
(defmethod ig/init-key :kraken/ohlc-minute
[_ {:keys [query-fn enabled?]}]
(if (= enabled? "false")
(do (log/info "Kraken minute poller disabled")
{:running? (atom false)})
(let [client (HttpClient/newHttpClient)
db-since (seed-since-from-db query-fn)
since (atom db-since)
running? (atom true)
fut (start-poll-loop! client query-fn since running?)]
(log/info "Starting Kraken minute poller (15s interval)")
{:running? running?
:future fut
:since since})))
(defmethod ig/halt-key! :kraken/ohlc-minute
[_ {:keys [running? future]}]
(log/info "Stopping Kraken minute poller")
(reset! running? false)
(future-cancel future))
@@ -69,6 +69,29 @@
(catch Exception e
(log/error e "Failed to project deposit for event" event-id))))
(defn- project-buy!
"Project an exchange_to_wallet event into the buys read table."
[{:keys [query-fn frankfurter]} event-id occurred-at to-wallet-id sats fee-sats fiat-amount fiat-currency]
(try
(let [wallet-name (or (:name (query-fn :get-wallet-by-id {:id to-wallet-id})) "Unknown")
amount-eur (if (= fiat-currency "EUR")
(bigdec fiat-amount)
(let [rates (or (query-fn :get-currency-rates-by-date {:rate-date occurred-at})
(rates/fetch-and-persist-for-date!
(:client frankfurter) (:url frankfurter)
query-fn occurred-at))]
(:amount-eur (convert-amount fiat-amount fiat-currency rates))))]
(when amount-eur
(query-fn :insert-buy!
{:event-id event-id
:occurred-at occurred-at
:wallet wallet-name
:sats (or sats 0)
:fee-sats fee-sats
:amount-eur amount-eur})))
(catch Exception e
(log/error e "Failed to project buy for event" event-id))))
(defn create-event! [{:keys [query-fn] :as opts} req]
(let [params (:body-params req)
event-type (:event_type params)]
@@ -93,6 +116,13 @@
(:fiat_amount params)
(:fiat_currency params)
(:note params)))
(when (and (= event-type "exchange_to_wallet") (:fiat_amount params))
(project-buy! opts (:id result) occurred-at
(some-> (:to_wallet_id params) UUID/fromString)
(:sats params)
(:fee_sats params)
(:fiat_amount params)
(:fiat_currency params)))
(response/created "/api/events" {:status "ok"})))))
(defn list-deposits [{:keys [query-fn]} _req]
@@ -128,6 +158,40 @@
0 events)]
(response/ok {:rebuilt n})))
(defn list-buys [{:keys [query-fn]} _req]
(response/ok (query-fn :get-all-buys {})))
(defn rebuild-buys! [{:keys [query-fn frankfurter]} _req]
(query-fn :truncate-buys! {})
(let [events (query-fn :get-buy-events {})
n (reduce
(fn [cnt {:keys [id occurred_at sats fee_sats fiat_amount fiat_currency wallet]}]
(if-not fiat_amount
cnt
(try
(let [amount-eur
(if (= fiat_currency "EUR")
(bigdec fiat_amount)
(let [rates (or (query-fn :get-currency-rates-by-date {:rate-date occurred_at})
(rates/fetch-and-persist-for-date!
(:client frankfurter) (:url frankfurter)
query-fn occurred_at))]
(:amount-eur (convert-amount fiat_amount fiat_currency rates))))]
(when amount-eur
(query-fn :insert-buy!
{:event-id id
:occurred-at occurred_at
:wallet (or wallet "Unknown")
:sats (or sats 0)
:fee-sats fee_sats
:amount-eur amount-eur}))
(inc cnt))
(catch Exception e
(log/error e "Failed to rebuild buy for event" id)
cnt))))
0 events)]
(response/ok {:rebuilt n})))
(defn list-events [{:keys [query-fn]} req]
(let [event-type (get-in req [:query-params "type"])]
(if event-type
+40 -3
View File
@@ -87,10 +87,14 @@
{:on-open (fn [{:keys [^WebSocketChannel channel]}]
(start-strike-ws-loop! channel strike-atom))}}))
(defn- latest-price-handler [{:keys [binance frankfurter]} _req]
(defn- latest-price-handler [{:keys [binance frankfurter query-fn]} _req]
(let [price-atom (:latest-price binance)
rates-atom (:rates frankfurter)
v @price-atom]
v (or @price-atom
(when-let [row (query-fn :get-latest-binance-price {})]
{:price (:price row)
:prev-price (:price row)
:recorded-at (:recorded_at row)}))]
(if v
{:status 200
:headers {"Content-Type" "application/json"}
@@ -144,6 +148,31 @@
:headers {"Content-Type" "application/json"}
:body (json/write-str {:error "Invalid date format, use YYYY-MM-DD"})}))))
(def ^:private ohlc-fmt
(java.time.format.DateTimeFormatter/ofPattern "yyyy-MM-dd HH:mm:ss"))
(defn- ohlc-row->map [r]
{:ts (:ts r)
:datetime (.format (.atOffset (java.time.Instant/ofEpochSecond (:ts r))
java.time.ZoneOffset/UTC) ohlc-fmt)
:open (str (:open r))
:high (str (:high r))
:low (str (:low r))
:close (str (:close r))
:vwap (str (:vwap r))
:volume (str (:volume r))
:trade_count (:trade_count r)})
(defn- kraken-hour-handler [{:keys [query-fn]} _req]
{:status 200
:headers {"Content-Type" "application/json"}
:body (json/write-str (mapv ohlc-row->map (query-fn :get-kraken-hour-latest-24 {})))})
(defn- kraken-minute-handler [{:keys [query-fn]} _req]
{:status 200
:headers {"Content-Type" "application/json"}
:body (json/write-str (mapv ohlc-row->map (query-fn :get-kraken-minute-latest-60 {})))})
(defn- api-routes [opts]
[["/swagger.json"
{:get {:no-doc true
@@ -176,7 +205,15 @@
["/deposits"
{:get (fn [req] (tx/list-deposits opts req))}]
["/deposits/rebuild"
{:post (fn [req] (tx/rebuild-deposits! opts req))}]])
{:post (fn [req] (tx/rebuild-deposits! opts req))}]
["/buys"
{:get (fn [req] (tx/list-buys opts req))}]
["/buys/rebuild"
{:post (fn [req] (tx/rebuild-buys! opts req))}]
["/kraken-hour"
{:get (fn [req] (kraken-hour-handler opts req))}]
["/kraken-minute"
{:get (fn [req] (kraken-minute-handler opts req))}]])
(defn route-data [opts]
(merge