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 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 12:16:53 +01:00
co-authored by Claude Opus 4.6
parent 146c3a4788
commit 11a1fc6b8f
5 changed files with 60 additions and 18 deletions
@@ -0,0 +1 @@
DROP TABLE currencies;
@@ -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()
);
+11
View File
@@ -50,3 +50,14 @@ 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
SELECT rates, recorded_at FROM strike_price ORDER BY id DESC LIMIT 1 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
+2 -1
View File
@@ -61,7 +61,8 @@
{:query-fn #ig/ref :db.sql/query-fn} {:query-fn #ig/ref :db.sql/query-fn}
:frankfurter/rates :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 :strike/ticker
{:query-fn #ig/ref :db.sql/query-fn {:query-fn #ig/ref :db.sql/query-fn
+35 -13
View File
@@ -6,7 +6,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 Instant])) [java.time Instant LocalDate]))
(defn- fetch-rates (defn- fetch-rates
"GET /latest?from=USD&to=EUR,DKK from Frankfurter. Returns {:EUR x :DKK y}." "GET /latest?from=USD&to=EUR,DKK from Frankfurter. Returns {:EUR x :DKK y}."
@@ -22,23 +22,31 @@
(:rates body))) (:rates body)))
(defn- poll! (defn- poll!
"Fetch rates and update the atom." "Fetch rates, update the atom, and persist to DB."
[client base-url rates-atom] [client base-url rates-atom query-fn]
(let [rates (fetch-rates client base-url)] (let [rates (fetch-rates client base-url)]
(when (and (:EUR rates) (:DKK rates)) (when (and (:EUR rates) (: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 (reset! rates-atom
{:eur (bigdec (str (:EUR rates))) {:eur eur
:dkk (bigdec (str (:DKK rates))) :dkk dkk
:updated-at (Instant/now)}) :updated-at (Instant/now)})
(log/info "Frankfurter rates — EUR:" (:EUR rates) "DKK:" (:DKK rates))))) (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! (defn- start-poll-loop!
"Fetch immediately, then poll every 60 minutes." "Fetch immediately, then poll every 60 minutes."
[client base-url rates-atom running?] [client base-url rates-atom running? query-fn]
(future (future
(loop [retries 12] (loop [retries 12]
(let [ok? (try (let [ok? (try
(poll! client base-url rates-atom) (poll! client base-url rates-atom query-fn)
true true
(catch Exception e (catch Exception e
(log/error e (str "Frankfurter fetch failed, retrying in 10s (" retries " left)")) (log/error e (str "Frankfurter fetch failed, retrying in 10s (" retries " left)"))
@@ -50,20 +58,34 @@
(Thread/sleep 3600000) (Thread/sleep 3600000)
(when @running? (when @running?
(try (try
(poll! client base-url rates-atom) (poll! client base-url rates-atom query-fn)
(catch Exception e (catch Exception e
(log/error e "Frankfurter poll failed"))))))) (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 (defmethod ig/init-key :frankfurter/rates
[_ {:keys [url]}] [_ {:keys [url query-fn]}]
(log/info "Starting Frankfurter rates poller:" url) (log/info "Starting Frankfurter rates poller:" url)
(let [client (HttpClient/newHttpClient) (let [client (HttpClient/newHttpClient)
rates (atom nil) rates (atom nil)
running? (atom true) running? (atom true)]
fut (start-poll-loop! client url rates running?)] (seed-from-db! rates query-fn)
(let [fut (start-poll-loop! client url rates running? query-fn)]
{:rates rates {:rates rates
:running? running? :running? running?
:future fut})) :future fut})))
(defmethod ig/halt-key! :frankfurter/rates (defmethod ig/halt-key! :frankfurter/rates
[_ {:keys [running? future]}] [_ {:keys [running? future]}]