Move HTML rendering to btcprice, send raw JSON over both WebSockets

Price and Strike WebSockets now emit JSON instead of HTML fragments.
Added Strike WebSocket endpoint and wired strike/ticker into API routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 10:18:48 +01:00
co-authored by Claude Opus 4.6
parent c786b01679
commit 3e5fd490b4
3 changed files with 57 additions and 51 deletions
+2 -1
View File
@@ -47,7 +47,8 @@
{:base-path "/api"
:query-fn #ig/ref :db.sql/query-fn
:binance #ig/ref :ws/binance
:frankfurter #ig/ref :frankfurter/rates}
:frankfurter #ig/ref :frankfurter/rates
:strike #ig/ref :strike/ticker}
:ws/binance
{:query-fn #ig/ref :db.sql/query-fn
+18 -7
View File
@@ -37,27 +37,36 @@
(str sourceCurrency "/" targetCurrency " " amount)))
(str/join " ")))
(defn- rates->map
"Index the rates array by sourceCurrency/targetCurrency pair."
[rates]
(into {}
(map (fn [{:keys [sourceCurrency targetCurrency amount]}]
[(str sourceCurrency "/" targetCurrency) amount])
rates)))
(defn- poll!
"Fetch rates and insert into the database."
[client api-key query-fn]
"Fetch rates and insert into the database. Updates latest-atom."
[client api-key query-fn latest-atom]
(let [body (fetch-rates client api-key)
rates (json/read-str body :key-fn keyword)]
(query-fn :insert-strike-price! {:rates (->jsonb body)})
(reset! latest-atom (rates->map rates))
(log/info "Strike:" (format-rates rates))))
(defn- start-poll-loop!
"Fetch immediately, then poll every 10 seconds."
[client api-key query-fn running?]
[client api-key query-fn running? latest-atom]
(future
(try
(poll! client api-key query-fn)
(poll! client api-key query-fn latest-atom)
(catch Exception e
(log/error e "Strike initial fetch failed")))
(while @running?
(Thread/sleep 10000)
(when @running?
(try
(poll! client api-key query-fn)
(poll! client api-key query-fn latest-atom)
(catch Exception e
(log/error e "Strike poll failed")))))))
@@ -66,9 +75,11 @@
(log/info "Starting Strike ticker poller")
(let [client (HttpClient/newHttpClient)
running? (atom true)
fut (start-poll-loop! client api-key query-fn running?)]
latest-rates (atom nil)
fut (start-poll-loop! client api-key query-fn running? latest-rates)]
{:running? running?
:future fut}))
:future fut
:latest-rates latest-rates}))
(defmethod ig/halt-key! :strike/ticker
[_ {:keys [running? future]}]
+34 -40
View File
@@ -12,9 +12,6 @@
[reitit.swagger :as swagger])
(:import
[io.undertow.websockets.core WebSockets WebSocketChannel]
[java.time Instant ZoneId]
[java.time.format DateTimeFormatter]
[java.util Locale]
[java.util.concurrent LinkedBlockingQueue TimeUnit]))
(defn- price->json [{:keys [price prev-price recorded-at]} rates]
@@ -25,41 +22,6 @@
rates (assoc :price_eur (str (.multiply (bigdec price) (:eur rates)))
:price_dkk (str (.multiply (bigdec price) (:dkk rates)))))))
(def ^:private ts-fmt (DateTimeFormatter/ofPattern "dd-MM HH:mm:ss"))
(def ^:private da-locale (Locale. "da" "DK"))
(defn- da-fmt [fmt val]
(String/format da-locale fmt (into-array Object [val])))
(defn- price->html [{:keys [price prev-price recorded-at]} rates]
(let [p (double (bigdec price))
pp (when prev-price (double (bigdec prev-price)))
color (cond
(nil? pp) "text-gray-900"
(> p pp) "text-green-600"
(< p pp) "text-red-600"
:else "text-gray-900")
ts (.format (.atZone (Instant/from recorded-at) (ZoneId/systemDefault)) ts-fmt)]
(str "<div id=\"price-display\">"
"<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">"
"<div class=\"text-center\">"
"<p class=\"text-4xl font-bold " color "\">$" (da-fmt "%,.2f" p) "</p>"
(when rates
(let [eur (double (:eur rates))
dkk (double (:dkk rates))
p-eur (double (.multiply (bigdec price) (:eur rates)))
p-dkk (double (.multiply (bigdec price) (:dkk rates)))]
(str "<div class=\"flex justify-center gap-4 mt-3\">"
"<span class=\"text-2xl font-bold text-gray-600\">\u20AC" (da-fmt "%,.0f" p-eur) "</span>"
"<span class=\"text-2xl font-bold text-gray-600\">" (da-fmt "%,.0f" p-dkk) " kr</span>"
"</div>"
"<div class=\"flex justify-center gap-4 mt-1\">"
"<span class=\"text-xs text-gray-400\">EUR/USD " (da-fmt "%.4f" eur) "</span>"
"<span class=\"text-xs text-gray-400\">DKK/USD " (da-fmt "%.4f" dkk) "</span>"
"</div>")))
"<p class=\"text-xs text-gray-400 mt-2\">Updated " ts "</p>"
"</div></div></div>")))
(defn- start-ws-send-loop! [^WebSocketChannel channel price-atom rates-atom]
(let [queue (LinkedBlockingQueue.)
wkey (keyword (gensym "ws-"))
@@ -76,7 +38,7 @@
(when (.isOpen channel)
(if (.poll queue 15 TimeUnit/SECONDS)
(when-let [v @price-atom]
(WebSockets/sendTextBlocking (price->html v @rates-atom) channel))
(WebSockets/sendTextBlocking (price->json v @rates-atom) channel))
(WebSockets/sendTextBlocking "" channel))
(recur)))
(catch Exception _)
@@ -93,6 +55,34 @@
{:on-open (fn [{:keys [^WebSocketChannel channel]}]
(start-ws-send-loop! channel price-atom rates-atom))}}))
(defn- start-strike-ws-loop! [^WebSocketChannel channel strike-atom]
(let [queue (LinkedBlockingQueue.)
wkey (keyword (gensym "ws-s-"))]
(add-watch strike-atom wkey
(fn [_ _ _ _] (.offer queue :update)))
(when @strike-atom
(.offer queue :update))
(future
(try
(loop []
(when (.isOpen channel)
(if (.poll queue 15 TimeUnit/SECONDS)
(when-let [rates @strike-atom]
(WebSockets/sendTextBlocking (json/write-str rates) channel))
(WebSockets/sendTextBlocking "" channel))
(recur)))
(catch Exception _)
(finally
(remove-watch strike-atom wkey)
(when (.isOpen channel)
(try (.close channel) (catch Exception _))))))))
(defn- ws-strike-handler [{:keys [strike]} _req]
(let [strike-atom (:latest-rates strike)]
{:undertow/websocket
{:on-open (fn [{:keys [^WebSocketChannel channel]}]
(start-strike-ws-loop! channel strike-atom))}}))
(defn- latest-price-handler [{:keys [binance frankfurter]} _req]
(let [price-atom (:latest-price binance)
rates-atom (:rates frankfurter)
@@ -117,7 +107,11 @@
:no-doc true
:middleware []}]
["/price/latest"
{:get (fn [req] (latest-price-handler opts req))}]])
{:get (fn [req] (latest-price-handler opts req))}]
["/strike/ws"
{:get (fn [req] (ws-strike-handler opts req))
:no-doc true
:middleware []}]])
(defn route-data [opts]
(merge