Rewrite btcprice from Clojure to Python FastAPI
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>
This commit is contained in:
+246
@@ -0,0 +1,246 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user