From 0caa8db0b3c7bb36ca64f1275d409d1904200402 Mon Sep 17 00:00:00 2001 From: Per Magnus Petersen Date: Mon, 16 Mar 2026 12:44:44 +0100 Subject: [PATCH] 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 --- .../20260316000000-create-buys.down.sql | 1 + .../20260316000000-create-buys.up.sql | 7 ++ .../20260316100000-buys-add-sats.down.sql | 3 + .../20260316100000-buys-add-sats.up.sql | 3 + resources/queries.sql | 24 +++++++ .../btcdata/web/controllers/transactions.clj | 64 +++++++++++++++++++ src/clj/pmagnus/btcdata/web/routes/api.clj | 4 ++ 7 files changed, 106 insertions(+) create mode 100644 resources/migrations/20260316000000-create-buys.down.sql create mode 100644 resources/migrations/20260316000000-create-buys.up.sql create mode 100644 resources/migrations/20260316100000-buys-add-sats.down.sql create mode 100644 resources/migrations/20260316100000-buys-add-sats.up.sql diff --git a/resources/migrations/20260316000000-create-buys.down.sql b/resources/migrations/20260316000000-create-buys.down.sql new file mode 100644 index 0000000..7bf7dd0 --- /dev/null +++ b/resources/migrations/20260316000000-create-buys.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS buys; diff --git a/resources/migrations/20260316000000-create-buys.up.sql b/resources/migrations/20260316000000-create-buys.up.sql new file mode 100644 index 0000000..90c15b1 --- /dev/null +++ b/resources/migrations/20260316000000-create-buys.up.sql @@ -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 +); diff --git a/resources/migrations/20260316100000-buys-add-sats.down.sql b/resources/migrations/20260316100000-buys-add-sats.down.sql new file mode 100644 index 0000000..526eae6 --- /dev/null +++ b/resources/migrations/20260316100000-buys-add-sats.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE buys DROP COLUMN fee_sats; +--;; +ALTER TABLE buys DROP COLUMN sats; diff --git a/resources/migrations/20260316100000-buys-add-sats.up.sql b/resources/migrations/20260316100000-buys-add-sats.up.sql new file mode 100644 index 0000000..ee0cb13 --- /dev/null +++ b/resources/migrations/20260316100000-buys-add-sats.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE buys ADD COLUMN sats BIGINT NOT NULL DEFAULT 0; +--;; +ALTER TABLE buys ADD COLUMN fee_sats BIGINT; diff --git a/resources/queries.sql b/resources/queries.sql index e63b612..f9aaa50 100644 --- a/resources/queries.sql +++ b/resources/queries.sql @@ -155,6 +155,30 @@ 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 diff --git a/src/clj/pmagnus/btcdata/web/controllers/transactions.clj b/src/clj/pmagnus/btcdata/web/controllers/transactions.clj index 31a0642..c103781 100644 --- a/src/clj/pmagnus/btcdata/web/controllers/transactions.clj +++ b/src/clj/pmagnus/btcdata/web/controllers/transactions.clj @@ -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 diff --git a/src/clj/pmagnus/btcdata/web/routes/api.clj b/src/clj/pmagnus/btcdata/web/routes/api.clj index c18f149..0b98b7e 100644 --- a/src/clj/pmagnus/btcdata/web/routes/api.clj +++ b/src/clj/pmagnus/btcdata/web/routes/api.clj @@ -202,6 +202,10 @@ {:get (fn [req] (tx/list-deposits opts req))}] ["/deposits/rebuild" {: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"