Files
btcprice/app/main.py
T
magnus 0c86cf3b59
pull-request / validate (pull_request) Successful in 12s
Show midnight change below heading
2026-09-17 14:53:05 +02:00

412 lines
13 KiB
Python

import json
import logging
import os
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 (1h)</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-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-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 ---
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)