Files
btcprice/app/main.py
magnus 0a27d01f12
pull-request / validate (pull_request) Successful in 2m34s
Show 30-minute candles on the last-24-hours chart
2026-11-03 10:32:23 +01:00

495 lines
17 KiB
Python

import asyncio
import json
import logging
import os
import time
from datetime import datetime
import httpx
import websockets
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("btcprice")
BTCDATA_URL = os.environ.get("BTCDATA_URL", "http://localhost:4100").rstrip("/")
APP_VERSION = os.environ.get("APP_VERSION", "development")
READINESS_TIMEOUT_SECONDS = 3.0
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>
<script src="https://unpkg.com/lightweight-charts@4/dist/lightweight-charts.standalone.production.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>
<p id="midnight-change" class="mt-1 text-sm text-gray-400">&nbsp;</p>
</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="mt-4">
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<p class="text-sm text-gray-500 mb-3">1h Candles (1m)</p>
<div id="chart-minute" style="height:300px"></div>
</div>
</div>
<div class="mt-4">
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<p class="text-sm text-gray-500 mb-3">24h Candles (30m)</p>
<div id="chart-hour" style="height:300px"></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>
<script>
(function() {
let currentPrice = null;
let midnightPrice = null;
function formatPriceChange(change) {
const sign = change > 0 ? '+' : change < 0 ? '-' : '±';
const amount = Math.abs(change).toLocaleString('da-DK', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return sign + '$' + amount;
}
function updateTitleFromPrice() {
const priceDisplay = document.getElementById('price-display');
const price = priceDisplay && priceDisplay.dataset.titlePrice;
const priceValue = priceDisplay && Number(priceDisplay.dataset.priceValue);
if (price && Number.isFinite(priceValue)) {
currentPrice = priceValue;
}
if (price && currentPrice !== null && midnightPrice !== null) {
const change = currentPrice - midnightPrice;
const formattedChange = formatPriceChange(change);
document.title = price + ' | ' + formattedChange;
const midnightChange = document.getElementById('midnight-change');
midnightChange.textContent = formattedChange;
midnightChange.className = 'mt-1 text-sm font-bold ' + (
change > 0 ? 'text-green-600' : change < 0 ? 'text-red-600' : 'text-gray-500'
);
} else if (price) {
document.title = price + ' | BTC Price';
}
}
function updateMidnightPrice(candles) {
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
const midnightSeconds = Math.floor(midnight.getTime() / 1000);
const firstToday = candles.find(candle => candle.time >= midnightSeconds);
if (firstToday) {
midnightPrice = firstToday.open;
updateTitleFromPrice();
}
}
const pricePanel = document.getElementById('price-panel');
new MutationObserver(updateTitleFromPrice).observe(pricePanel, {
childList: true,
subtree: true,
});
updateTitleFromPrice();
function initChart(containerId, endpoint, intervalMs, onData) {
const container = document.getElementById(containerId);
const chart = LightweightCharts.createChart(container, {
layout: { background: { type: 'solid', color: '#ffffff' }, textColor: '#6b7280' },
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
rightPriceScale: { borderColor: '#e5e7eb' },
timeScale: { borderColor: '#e5e7eb', timeVisible: true, secondsVisible: false },
});
const series = chart.addCandlestickSeries({
upColor: '#16a34a', downColor: '#dc2626',
borderUpColor: '#16a34a', borderDownColor: '#dc2626',
wickUpColor: '#16a34a', wickDownColor: '#dc2626',
});
function loadData() {
fetch(endpoint)
.then(r => r.json())
.then(candles => {
const data = candles.map(c => ({
time: c.ts,
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close),
})).sort((a, b) => a.time - b.time);
series.setData(data);
if (onData) onData(data);
chart.timeScale().fitContent();
})
.catch(e => console.error('Chart fetch error:', e));
}
loadData();
setInterval(loadData, intervalMs);
new ResizeObserver(() => {
chart.applyOptions({ width: container.clientWidth });
}).observe(container);
}
initChart('chart-minute', '/api/kraken-minute', 60 * 1000);
initChart('chart-hour', '/api/kraken-half-hour', 5 * 60 * 1000, updateMidnightPrice);
})();
</script>
</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"}
@app.get("/api/ready")
async def ready():
"""Report readiness only while the btcdata dependency is healthy."""
url = f"{BTCDATA_URL}/api/health"
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=READINESS_TIMEOUT_SECONDS)
response.raise_for_status()
except httpx.HTTPError:
logger.warning("btcdata readiness check failed for %s", url)
return JSONResponse(content={"status": "not ready"}, status_code=503)
return {"status": "ready"}
@app.get("/api/version")
async def version():
return {"version": APP_VERSION}
async def _proxy_btcdata_json(path: str) -> JSONResponse:
url = f"{BTCDATA_URL}{path}"
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
return JSONResponse(content=resp.json(), status_code=resp.status_code)
except httpx.RequestError as exc:
logger.warning("Unable to connect to btcdata at %s: %s", url, exc)
return JSONResponse(
content={
"error": "btcdata unavailable",
"detail": f"Could not connect to {BTCDATA_URL}. Start btcdata first or set BTCDATA_URL.",
},
status_code=503,
)
except json.JSONDecodeError:
logger.warning("btcdata returned non-JSON response from %s", url)
return JSONResponse(
content={"error": "invalid btcdata response"},
status_code=502,
)
@app.get("/api/kraken-half-hour")
async def kraken_half_hour():
return await _proxy_btcdata_json("/api/kraken-half-hour")
@app.get("/api/kraken-hour")
async def kraken_hour():
return await _proxy_btcdata_json("/api/kraken-hour")
@app.get("/api/kraken-minute")
async def kraken_minute():
return await _proxy_btcdata_json("/api/kraken-minute")
# --- 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 = ""
formatted_price = f'${da_fmt(",.2f", p)}'
html = (
f'<div id="price-display" data-title-price="{formatted_price}" data-price-value="{p}">'
'<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}">{formatted_price}</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 ---
# htmx's WebSocket extension reconnects a browser socket only after an abnormal
# close (1006/1011/1012/1013). A normal 1000 close means "the server is done",
# so the extension never reconnects and the panel stays frozen until the visitor
# reloads. A btcdata restart must therefore never reach the browser as a normal
# close: the proxy reconnects upstream behind the still-open browser socket, and
# only if that keeps failing does it close with 1012 (Service Restart) and hand
# recovery to the page's own reconnect.
BROWSER_RETRY_CLOSE_CODE = 1012
UPSTREAM_OPEN_TIMEOUT_SECONDS = 5.0
UPSTREAM_RETRY_DELAYS_SECONDS = (0.5, 1.0, 2.0, 4.0, 8.0, 8.0)
UPSTREAM_STABLE_SECONDS = 30.0
def _btcdata_ws_url(path: str) -> str:
return BTCDATA_URL.replace("http", "ws", 1) + path
async def _await_browser_close(browser_ws: WebSocket) -> None:
"""Return as soon as the browser side goes away.
The browser sends nothing, but reading is the only way to notice that it
left while the proxy is waiting on a dead upstream rather than sending.
"""
try:
while True:
if (await browser_ws.receive())["type"] == "websocket.disconnect":
return
except (WebSocketDisconnect, RuntimeError):
return
async def _forward_upstream(browser_ws: WebSocket, upstream_url: str, transform) -> None:
"""Forward one upstream connection to the browser until that connection ends."""
async with websockets.connect(
upstream_url, ping_interval=None, open_timeout=UPSTREAM_OPEN_TIMEOUT_SECONDS
) as upstream:
unrenderable = 0
async for message in upstream:
if not message:
continue
try:
html = transform(message)
except Exception:
# One unexpected payload must not take down a working page. Log
# the first per connection, then stay quiet about the rest.
unrenderable += 1
logger.log(
logging.WARNING if unrenderable == 1 else logging.DEBUG,
"Discarding an upstream message that could not be rendered",
exc_info=True,
)
continue
await browser_ws.send_text(html)
async def _ws_proxy(browser_ws: WebSocket, upstream_path: str, transform) -> None:
await browser_ws.accept()
upstream_url = _btcdata_ws_url(upstream_path)
logger.info("Browser connected, proxying to %s", upstream_url)
browser_closed = asyncio.create_task(_await_browser_close(browser_ws))
attempt = 0
try:
while True:
started = time.monotonic()
forwarding = asyncio.create_task(
_forward_upstream(browser_ws, upstream_url, transform)
)
done, _ = await asyncio.wait(
{forwarding, browser_closed}, return_when=asyncio.FIRST_COMPLETED
)
if browser_closed in done:
# The browser left; a healthy upstream would otherwise keep this
# task forwarding into a socket nobody is reading.
forwarding.cancel()
await asyncio.gather(forwarding, return_exceptions=True)
logger.info("Browser disconnected from %s", upstream_url)
return
try:
forwarding.result()
logger.info("Upstream %s closed; reconnecting", upstream_url)
except Exception:
logger.info("Upstream %s unavailable; reconnecting", upstream_url)
logger.debug("Upstream connection error", exc_info=True)
if time.monotonic() - started >= UPSTREAM_STABLE_SECONDS:
# A connection that worked for a while means a restart, not a
# persistently broken upstream: start the schedule over.
attempt = 0
if attempt >= len(UPSTREAM_RETRY_DELAYS_SECONDS):
logger.warning(
"Upstream %s still unavailable; asking the browser to reconnect",
upstream_url,
)
return
await asyncio.wait({browser_closed}, timeout=UPSTREAM_RETRY_DELAYS_SECONDS[attempt])
attempt += 1
if browser_closed.done():
logger.info("Browser disconnected from %s", upstream_url)
return
finally:
browser_closed.cancel()
await asyncio.gather(browser_closed, return_exceptions=True)
try:
# Every server-side exit, expected or not, closes with a code the
# page retries; a browser that already left ignores it.
await browser_ws.close(code=BROWSER_RETRY_CLOSE_CODE)
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)