From 11a1fc6b8f0eb65249af4b07ab7580fe0c51200f Mon Sep 17 00:00:00 2001 From: Per Magnus Petersen Date: Fri, 6 Mar 2026 12:16:53 +0100 Subject: [PATCH] Persist Frankfurter EUR/DKK rates in currencies table Store daily exchange rates in PostgreSQL with EUR/DKK cross rate. Seed rates atom from DB on startup so WebSocket works immediately even if Frankfurter is unreachable. Co-Authored-By: Claude Opus 4.6 --- .../20260306200000-create-currencies.down.sql | 1 + .../20260306200000-create-currencies.up.sql | 7 +++ resources/queries.sql | 11 ++++ resources/system.edn | 3 +- src/clj/pmagnus/btcdata/frankfurter/rates.clj | 56 +++++++++++++------ 5 files changed, 60 insertions(+), 18 deletions(-) create mode 100644 resources/migrations/20260306200000-create-currencies.down.sql create mode 100644 resources/migrations/20260306200000-create-currencies.up.sql diff --git a/resources/migrations/20260306200000-create-currencies.down.sql b/resources/migrations/20260306200000-create-currencies.down.sql new file mode 100644 index 0000000..e46ec6a --- /dev/null +++ b/resources/migrations/20260306200000-create-currencies.down.sql @@ -0,0 +1 @@ +DROP TABLE currencies; diff --git a/resources/migrations/20260306200000-create-currencies.up.sql b/resources/migrations/20260306200000-create-currencies.up.sql new file mode 100644 index 0000000..cbe7ddc --- /dev/null +++ b/resources/migrations/20260306200000-create-currencies.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE currencies ( + rate_date DATE PRIMARY KEY, + eur NUMERIC(12,6) NOT NULL, + dkk NUMERIC(12,6) NOT NULL, + eur_dkk NUMERIC(12,6) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/resources/queries.sql b/resources/queries.sql index 08e1b96..504c477 100644 --- a/resources/queries.sql +++ b/resources/queries.sql @@ -50,3 +50,14 @@ 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 SELECT rates, recorded_at FROM strike_price ORDER BY id DESC LIMIT 1 + +-- :name upsert-currency-rates! :! :n +-- :doc Upsert daily currency rates from Frankfurter +INSERT INTO currencies (rate_date, eur, dkk, eur_dkk, updated_at) +VALUES (:rate-date, :eur, :dkk, :eur-dkk, NOW()) +ON CONFLICT (rate_date) DO UPDATE +SET eur = EXCLUDED.eur, dkk = EXCLUDED.dkk, eur_dkk = EXCLUDED.eur_dkk, updated_at = NOW() + +-- :name get-latest-currency-rates :? :1 +-- :doc Get the most recent currency rates +SELECT eur, dkk, eur_dkk, updated_at FROM currencies ORDER BY rate_date DESC LIMIT 1 diff --git a/resources/system.edn b/resources/system.edn index 7ab51cc..df5e4f3 100644 --- a/resources/system.edn +++ b/resources/system.edn @@ -61,7 +61,8 @@ {:query-fn #ig/ref :db.sql/query-fn} :frankfurter/rates - {:url #or [#env FRANKFURTER_URL "http://localhost:8080"]} + {:url #or [#env FRANKFURTER_URL "http://localhost:8080"] + :query-fn #ig/ref :db.sql/query-fn} :strike/ticker {:query-fn #ig/ref :db.sql/query-fn diff --git a/src/clj/pmagnus/btcdata/frankfurter/rates.clj b/src/clj/pmagnus/btcdata/frankfurter/rates.clj index eec97b4..fed5f03 100644 --- a/src/clj/pmagnus/btcdata/frankfurter/rates.clj +++ b/src/clj/pmagnus/btcdata/frankfurter/rates.clj @@ -6,7 +6,7 @@ (:import [java.net URI] [java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers] - [java.time Instant])) + [java.time Instant LocalDate])) (defn- fetch-rates "GET /latest?from=USD&to=EUR,DKK from Frankfurter. Returns {:EUR x :DKK y}." @@ -22,23 +22,31 @@ (:rates body))) (defn- poll! - "Fetch rates and update the atom." - [client base-url rates-atom] + "Fetch rates, update the atom, and persist to DB." + [client base-url rates-atom query-fn] (let [rates (fetch-rates client base-url)] (when (and (:EUR rates) (:DKK rates)) - (reset! rates-atom - {:eur (bigdec (str (:EUR rates))) - :dkk (bigdec (str (:DKK rates))) - :updated-at (Instant/now)}) - (log/info "Frankfurter rates — EUR:" (:EUR rates) "DKK:" (:DKK rates))))) + (let [eur (bigdec (str (:EUR rates))) + dkk (bigdec (str (:DKK rates))) + eur-dkk (.divide dkk eur 6 java.math.RoundingMode/HALF_UP)] + (reset! rates-atom + {:eur eur + :dkk dkk + :updated-at (Instant/now)}) + (query-fn :upsert-currency-rates! + {:rate-date (LocalDate/now) + :eur eur + :dkk dkk + :eur-dkk eur-dkk}) + (log/info "Frankfurter rates — EUR:" eur "DKK:" dkk "EUR/DKK:" eur-dkk))))) (defn- start-poll-loop! "Fetch immediately, then poll every 60 minutes." - [client base-url rates-atom running?] + [client base-url rates-atom running? query-fn] (future (loop [retries 12] (let [ok? (try - (poll! client base-url rates-atom) + (poll! client base-url rates-atom query-fn) true (catch Exception e (log/error e (str "Frankfurter fetch failed, retrying in 10s (" retries " left)")) @@ -50,20 +58,34 @@ (Thread/sleep 3600000) (when @running? (try - (poll! client base-url rates-atom) + (poll! client base-url rates-atom query-fn) (catch Exception e (log/error e "Frankfurter poll failed"))))))) +(defn- seed-from-db! + "Load latest rates from DB into the atom so we have data immediately." + [rates-atom query-fn] + (try + (when-let [row (query-fn :get-latest-currency-rates {})] + (reset! rates-atom + {:eur (:eur row) + :dkk (:dkk row) + :updated-at (:updated_at row)}) + (log/info "Seeded Frankfurter rates from DB — EUR:" (:eur row) "DKK:" (:dkk row))) + (catch Exception e + (log/warn e "Could not seed rates from DB")))) + (defmethod ig/init-key :frankfurter/rates - [_ {:keys [url]}] + [_ {:keys [url query-fn]}] (log/info "Starting Frankfurter rates poller:" url) (let [client (HttpClient/newHttpClient) rates (atom nil) - running? (atom true) - fut (start-poll-loop! client url rates running?)] - {:rates rates - :running? running? - :future fut})) + running? (atom true)] + (seed-from-db! rates query-fn) + (let [fut (start-poll-loop! client url rates running? query-fn)] + {:rates rates + :running? running? + :future fut}))) (defmethod ig/halt-key! :frankfurter/rates [_ {:keys [running? future]}]