Replace the Clojure Kit application with a Python FastAPI app that produces identical HTML output. WebSocket proxy, Danish number formatting, and HTMX integration all preserved. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
247 lines
7.4 KiB
Python
247 lines
7.4 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
from datetime import datetime
|
|
|
|
import websockets
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import FileResponse, HTMLResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger("btcprice")
|
|
|
|
BTCDATA_URL = os.environ.get("BTCDATA_URL", "http://localhost:4100")
|
|
|
|
app = FastAPI()
|
|
|
|
app.mount("/css", StaticFiles(directory="resources/public/css"), name="css")
|
|
|
|
|
|
@app.get("/favicon.ico", include_in_schema=False)
|
|
async def favicon():
|
|
return FileResponse("resources/public/favicon.svg", media_type="image/svg+xml")
|
|
|
|
|
|
# --- HTML page ---
|
|
|
|
HOME_HTML = """\
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<link rel="icon" href="/favicon.ico" type="image/svg+xml">
|
|
<title>BTC Price</title>
|
|
<link rel="stylesheet" href="/css/output.css">
|
|
<script src="https://unpkg.com/htmx.org@2/dist/htmx.min.js"></script>
|
|
<script src="https://unpkg.com/htmx-ext-ws@2/ws.js"></script>
|
|
</head>
|
|
<body>
|
|
<div class="mx-auto max-w-lg px-4 py-6">
|
|
<header class="text-center mb-8">
|
|
<h1 class="text-3xl font-bold text-gray-900">BTC Price</h1>
|
|
</header>
|
|
<div id="price-panel" hx-ext="ws" ws-connect="/ws/price">
|
|
<div id="price-display">
|
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
|
|
<p class="text-center text-gray-400 text-sm">Connecting...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4" hx-ext="ws" ws-connect="/ws/strike">
|
|
<div id="strike-display">
|
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
|
|
<p class="text-center text-gray-400 text-sm">Loading Strike rates...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="hidden text-4xl text-2xl font-bold text-green-600 text-red-600 text-gray-900 text-xs text-gray-400 mt-2 flex justify-center justify-between items-center gap-4 mt-1 mt-3 text-sm text-gray-600 text-gray-500 mb-3 py-1 grid grid-cols-2 gap-x-6 gap-y-1" aria-hidden="true"></div>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def home():
|
|
return HOME_HTML
|
|
|
|
|
|
# --- Health endpoint ---
|
|
|
|
|
|
@app.get("/api/health")
|
|
async def health():
|
|
return {"status": "up"}
|
|
|
|
|
|
# --- Formatting helpers (Danish locale) ---
|
|
|
|
|
|
def da_fmt(fmt: str, val: float) -> str:
|
|
"""Format a number using Danish locale conventions."""
|
|
if fmt == ",.2f":
|
|
# e.g. 12.345,67
|
|
int_part = int(val)
|
|
frac = abs(val - int_part)
|
|
int_str = f"{int_part:,}".replace(",", ".")
|
|
return f"{int_str},{frac:.2f}"[:-1].split(",")[0] + "," + f"{frac:.2f}"[2:]
|
|
if fmt == ",.0f":
|
|
return f"{int(round(val)):,}".replace(",", ".")
|
|
if fmt == ".4f":
|
|
return f"{val:.4f}".replace(".", ",")
|
|
if fmt == ",d":
|
|
return f"{int(val):,}".replace(",", ".")
|
|
return str(val)
|
|
|
|
|
|
# --- Price transform ---
|
|
|
|
|
|
def price_json_to_html(text: str) -> str:
|
|
data = json.loads(text)
|
|
p = float(data["price"])
|
|
pp = float(data["prev_price"]) if data.get("prev_price") is not None else None
|
|
|
|
if pp is None:
|
|
color = "text-gray-900"
|
|
elif p > pp:
|
|
color = "text-green-600"
|
|
elif p < pp:
|
|
color = "text-red-600"
|
|
else:
|
|
color = "text-gray-900"
|
|
|
|
recorded_at = data.get("recorded_at", "")
|
|
if recorded_at:
|
|
dt = datetime.fromisoformat(recorded_at.replace("Z", "+00:00"))
|
|
local_dt = dt.astimezone()
|
|
ts = local_dt.strftime("%d-%m %H:%M:%S")
|
|
else:
|
|
ts = ""
|
|
|
|
html = (
|
|
'<div id="price-display">'
|
|
'<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">'
|
|
'<div class="text-center">'
|
|
f'<p class="text-4xl font-bold {color}">${da_fmt(",.2f", p)}</p>'
|
|
)
|
|
|
|
if data.get("price_eur") is not None:
|
|
p_eur = float(data["price_eur"])
|
|
p_dkk = float(data["price_dkk"])
|
|
eur_rate = p_eur / p
|
|
dkk_rate = p_dkk / p
|
|
|
|
html += (
|
|
'<div class="flex justify-center gap-4 mt-3">'
|
|
f'<span class="text-2xl font-bold text-gray-600">\u20ac{da_fmt(",.0f", p_eur)}</span>'
|
|
f'<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">'
|
|
f'<span class="text-xs text-gray-400">EUR/USD {da_fmt(".4f", eur_rate)}</span>'
|
|
f'<span class="text-xs text-gray-400">DKK/USD {da_fmt(".4f", dkk_rate)}</span>'
|
|
"</div>"
|
|
)
|
|
|
|
html += f'<p class="text-xs text-gray-400 mt-2">Updated {ts}</p>'
|
|
html += "</div></div></div>"
|
|
return html
|
|
|
|
|
|
# --- Strike transform ---
|
|
|
|
STRIKE_PAIRS = [
|
|
("BTC/USDT", "$"),
|
|
("BTC/EUR", "\u20ac"),
|
|
("USDT/EUR", ""),
|
|
("EUR/BTC", ""),
|
|
]
|
|
|
|
|
|
def format_strike_amount(prefix: str, amount) -> str:
|
|
if amount is None:
|
|
return "\u2014"
|
|
v = float(amount)
|
|
if prefix in ("$", "\u20ac"):
|
|
return f'{prefix}{da_fmt(",.2f", v)}'
|
|
return str(amount)
|
|
|
|
|
|
def strike_json_to_html(text: str) -> str:
|
|
rates = json.loads(text)
|
|
sats = rates.get("quote-sats")
|
|
|
|
html = (
|
|
'<div id="strike-display">'
|
|
'<div class="rounded-2xl shadow-sm border border-gray-200 p-6" style="background:#f3f4f6;">'
|
|
)
|
|
|
|
if sats is not None:
|
|
html += (
|
|
'<p class="text-center text-2xl font-bold mb-3" style="color:#1e3a5f;">'
|
|
f'{da_fmt(",d", sats)} <span style="color:#1e3a5f;">sats</span></p>'
|
|
)
|
|
|
|
html += '<div style="display:grid;grid-template-columns:1fr;row-gap:0.25rem;">'
|
|
|
|
for pair, prefix in STRIKE_PAIRS:
|
|
amount = rates.get(pair)
|
|
html += (
|
|
'<div style="display:flex;justify-content:space-between;align-items:center;padding:0.25rem 0;">'
|
|
f'<span class="text-sm text-gray-500">{pair}</span>'
|
|
f'<span class="text-sm font-bold text-gray-900">{format_strike_amount(prefix, amount)}</span>'
|
|
"</div>"
|
|
)
|
|
|
|
html += "</div></div></div>"
|
|
return html
|
|
|
|
|
|
# --- WebSocket proxy ---
|
|
|
|
|
|
def _btcdata_ws_url(path: str) -> str:
|
|
return BTCDATA_URL.replace("http", "ws", 1) + path
|
|
|
|
|
|
async def _ws_proxy(browser_ws: WebSocket, upstream_path: str, transform):
|
|
await browser_ws.accept()
|
|
upstream_url = _btcdata_ws_url(upstream_path)
|
|
logger.info("Browser connected, proxying to %s", upstream_url)
|
|
|
|
try:
|
|
async with websockets.connect(upstream_url, ping_interval=None) as upstream:
|
|
async for message in upstream:
|
|
if not message:
|
|
continue
|
|
try:
|
|
html = transform(message)
|
|
await browser_ws.send_text(html)
|
|
except WebSocketDisconnect:
|
|
break
|
|
except Exception:
|
|
logger.debug("Error forwarding to browser", exc_info=True)
|
|
break
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception:
|
|
logger.debug("Upstream connection error", exc_info=True)
|
|
finally:
|
|
try:
|
|
await browser_ws.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@app.websocket("/ws/price")
|
|
async def ws_price(websocket: WebSocket):
|
|
await _ws_proxy(websocket, "/api/price/ws", price_json_to_html)
|
|
|
|
|
|
@app.websocket("/ws/strike")
|
|
async def ws_strike(websocket: WebSocket):
|
|
await _ws_proxy(websocket, "/api/strike/ws", strike_json_to_html)
|