Add Frankfurter EUR/DKK rate polling and include rates in WebSocket stream

Polls self-hosted Frankfurter API hourly for USD-based EUR and DKK exchange
rates. Both the WebSocket HTML fragments and the /api/price/latest JSON
endpoint now include the currency rates alongside BTC price.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:22:24 +01:00
co-authored by Claude Opus 4.6
parent 39ec47b5e1
commit 7d6ca3911f
5 changed files with 113 additions and 22 deletions
+4 -2
View File
@@ -44,8 +44,8 @@ Docker runs alongside local dev on different ports and databases:
## API Endpoints ## API Endpoints
- `GET /api/health` — Health check - `GET /api/health` — Health check
- `GET /api/price/ws` — WebSocket endpoint for live HTML price fragments (HTMX-compatible) - `GET /api/price/ws` — WebSocket endpoint for live HTML price fragments with EUR/DKK rates (HTMX-compatible)
- `GET /api/price/latest` — Latest price as JSON - `GET /api/price/latest` — Latest price + EUR/DKK rates as JSON
## Source Layout ## Source Layout
@@ -55,6 +55,8 @@ src/clj/pmagnus/btcdata/
├── config.clj # System config loader ├── config.clj # System config loader
├── ws/ ├── ws/
│ └── binance.clj # Binance WebSocket client │ └── binance.clj # Binance WebSocket client
├── frankfurter/
│ └── rates.clj # EUR/DKK exchange rate poller
├── kraken/ ├── kraken/
│ ├── ohlc.clj # Hourly OHLC poller │ ├── ohlc.clj # Hourly OHLC poller
│ └── ohlc_daily.clj # Daily OHLC poller │ └── ohlc_daily.clj # Daily OHLC poller
+3
View File
@@ -4,10 +4,13 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "4101:4101" - "4101:4101"
depends_on:
- frankfurter
environment: environment:
BTCDATA_PORT: "4101" BTCDATA_PORT: "4101"
JDBC_URL: "jdbc:postgresql://postgres:5432/btcprod?user=postgres&password=ratata,123" JDBC_URL: "jdbc:postgresql://postgres:5432/btcprod?user=postgres&password=ratata,123"
CORS_ORIGIN: "http://localhost:4041" CORS_ORIGIN: "http://localhost:4041"
FRANKFURTER_URL: "http://frankfurter:8080"
extra_hosts: extra_hosts:
- "postgres:host-gateway" - "postgres:host-gateway"
+6 -2
View File
@@ -46,7 +46,8 @@
:reitit.routes/api :reitit.routes/api
{:base-path "/api" {:base-path "/api"
:query-fn #ig/ref :db.sql/query-fn :query-fn #ig/ref :db.sql/query-fn
:binance #ig/ref :ws/binance} :binance #ig/ref :ws/binance
:frankfurter #ig/ref :frankfurter/rates}
:ws/binance :ws/binance
{:query-fn #ig/ref :db.sql/query-fn {:query-fn #ig/ref :db.sql/query-fn
@@ -56,4 +57,7 @@
{:query-fn #ig/ref :db.sql/query-fn} {:query-fn #ig/ref :db.sql/query-fn}
:kraken/ohlc-day :kraken/ohlc-day
{:query-fn #ig/ref :db.sql/query-fn}} {:query-fn #ig/ref :db.sql/query-fn}
:frankfurter/rates
{:url #or [#env FRANKFURTER_URL "http://localhost:8080"]}}
@@ -0,0 +1,66 @@
(ns pmagnus.btcdata.frankfurter.rates
(: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]))
(defn- fetch-rates
"GET /latest?from=USD&to=EUR,DKK from Frankfurter. Returns {:EUR x :DKK y}."
[^HttpClient client base-url]
(let [url (str base-url "/latest?from=USD&to=EUR,DKK")
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)]
(:rates body)))
(defn- poll!
"Fetch rates and update the atom."
[client base-url rates-atom]
(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)))))
(defn- start-poll-loop!
"Fetch immediately, then poll every 60 minutes."
[client base-url rates-atom running?]
(future
(try
(poll! client base-url rates-atom)
(catch Exception e
(log/error e "Frankfurter initial fetch failed")))
(while @running?
(Thread/sleep 3600000)
(when @running?
(try
(poll! client base-url rates-atom)
(catch Exception e
(log/error e "Frankfurter poll failed")))))))
(defmethod ig/init-key :frankfurter/rates
[_ {:keys [url]}]
(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}))
(defmethod ig/halt-key! :frankfurter/rates
[_ {:keys [running? future]}]
(log/info "Stopping Frankfurter rates poller")
(reset! running? false)
(future-cancel future))
+31 -15
View File
@@ -18,15 +18,17 @@
[java.time.format DateTimeFormatter] [java.time.format DateTimeFormatter]
[java.util.concurrent LinkedBlockingQueue TimeUnit])) [java.util.concurrent LinkedBlockingQueue TimeUnit]))
(defn- price->json [{:keys [price prev-price recorded-at]}] (defn- price->json [{:keys [price prev-price recorded-at]} rates]
(json/write-str (json/write-str
{:price (str price) (cond-> {:price (str price)
:prev_price (when prev-price (str prev-price)) :prev_price (when prev-price (str prev-price))
:recorded_at (str recorded-at)})) :recorded_at (str recorded-at)}
rates (assoc :eur (str (:eur rates))
:dkk (str (:dkk rates))))))
(def ^:private ts-fmt (DateTimeFormatter/ofPattern "dd-MM HH:mm:ss")) (def ^:private ts-fmt (DateTimeFormatter/ofPattern "dd-MM HH:mm:ss"))
(defn- price->html [{:keys [price prev-price recorded-at]}] (defn- price->html [{:keys [price prev-price recorded-at]} rates]
(let [p (double (bigdec price)) (let [p (double (bigdec price))
pp (when prev-price (double (bigdec prev-price))) pp (when prev-price (double (bigdec prev-price)))
color (cond color (cond
@@ -39,50 +41,64 @@
"<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">" "<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">"
"<div class=\"text-center\">" "<div class=\"text-center\">"
"<p class=\"text-4xl font-bold " color "\">$" (format "%,.2f" p) "</p>" "<p class=\"text-4xl font-bold " color "\">$" (format "%,.2f" p) "</p>"
(when rates
(let [eur (double (:eur rates))
dkk (double (:dkk rates))]
(str "<div class=\"flex justify-center gap-4 mt-3\">"
"<span class=\"text-sm text-gray-600\">EUR " (format "%.4f" eur) "</span>"
"<span class=\"text-sm text-gray-600\">DKK " (format "%.4f" dkk) "</span>"
"</div>")))
"<p class=\"text-xs text-gray-400 mt-2\">Updated " ts "</p>" "<p class=\"text-xs text-gray-400 mt-2\">Updated " ts "</p>"
"</div></div></div>"))) "</div></div></div>")))
(defn- start-ws-send-loop! [^WebSocketChannel channel price-atom] (defn- start-ws-send-loop! [^WebSocketChannel channel price-atom rates-atom]
(let [queue (LinkedBlockingQueue.) (let [queue (LinkedBlockingQueue.)
wkey (keyword (gensym "ws-"))] wkey (keyword (gensym "ws-"))
rkey (keyword (gensym "ws-r-"))]
(.set (.getReceiveSetter channel) (.set (.getReceiveSetter channel)
(proxy [AbstractReceiveListener] [])) (proxy [AbstractReceiveListener] []))
(.resumeReceives channel) (.resumeReceives channel)
(add-watch price-atom wkey (add-watch price-atom wkey
(fn [_ _ _ v] (.offer queue v))) (fn [_ _ _ _] (.offer queue :update)))
(when-let [v @price-atom] (add-watch rates-atom rkey
(.offer queue v)) (fn [_ _ _ _] (.offer queue :update)))
(when @price-atom
(.offer queue :update))
(future (future
(try (try
(loop [] (loop []
(when (.isOpen channel) (when (.isOpen channel)
(if-let [v (.poll queue 15 TimeUnit/SECONDS)] (if (.poll queue 15 TimeUnit/SECONDS)
(WebSockets/sendTextBlocking (price->html v) channel) (when-let [v @price-atom]
(WebSockets/sendTextBlocking (price->html v @rates-atom) channel))
(WebSockets/sendTextBlocking "" channel)) (WebSockets/sendTextBlocking "" channel))
(recur))) (recur)))
(catch Exception _) (catch Exception _)
(finally (finally
(remove-watch price-atom wkey) (remove-watch price-atom wkey)
(remove-watch rates-atom rkey)
(when (.isOpen channel) (when (.isOpen channel)
(try (.close channel) (catch Exception _)))))))) (try (.close channel) (catch Exception _))))))))
(defn- ws-price-handler [{:keys [binance]} req] (defn- ws-price-handler [{:keys [binance frankfurter]} req]
(let [^HttpServerExchange exchange (:server-exchange req) (let [^HttpServerExchange exchange (:server-exchange req)
price-atom (:latest-price binance) price-atom (:latest-price binance)
rates-atom (:rates frankfurter)
callback (proxy [WebSocketConnectionCallback] [] callback (proxy [WebSocketConnectionCallback] []
(onConnect [_ws-exchange channel] (onConnect [_ws-exchange channel]
(start-ws-send-loop! channel price-atom))) (start-ws-send-loop! channel price-atom rates-atom)))
handler (WebSocketProtocolHandshakeHandler. callback)] handler (WebSocketProtocolHandshakeHandler. callback)]
(.handleRequest handler exchange) (.handleRequest handler exchange)
nil)) nil))
(defn- latest-price-handler [{:keys [binance]} _req] (defn- latest-price-handler [{:keys [binance frankfurter]} _req]
(let [price-atom (:latest-price binance) (let [price-atom (:latest-price binance)
rates-atom (:rates frankfurter)
v @price-atom] v @price-atom]
(if v (if v
{:status 200 {:status 200
:headers {"Content-Type" "application/json"} :headers {"Content-Type" "application/json"}
:body (price->json v)} :body (price->json v @rates-atom)}
{:status 503 {:status 503
:headers {"Content-Type" "application/json"} :headers {"Content-Type" "application/json"}
:body (json/write-str {:error "No price data yet"})}))) :body (json/write-str {:error "No price data yet"})})))