406 lines
13 KiB
Python
406 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>
|
|
</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;
|
|
} 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)
|