callernode commited on
Commit
ec75ae4
·
verified ·
1 Parent(s): b3cfeb8

Create main.py

Browse files
Files changed (1) hide show
  1. main.py +110 -0
main.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """XAUUSD — live gold spot dashboard. FastAPI + Docker Space."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+ from fastapi import FastAPI
11
+ from fastapi.responses import JSONResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+
14
+ CACHE_TTL = 30.0 # upstream asks clients to cache >= 30s
15
+ REQUEST_TIMEOUT = 10.0
16
+ MAX_HISTORY = 480 # ~4h of 30s samples (in-memory, resets on restart)
17
+
18
+ UPSTREAM = [
19
+ "https://xaus.com/api/v1/spot",
20
+ "https://api.gold-api.com/price/XAU",
21
+ ]
22
+
23
+ app = FastAPI(title="XAUUSD", docs_url=None, redoc_url=None)
24
+
25
+ _cache: dict[str, Any] = {"ts": 0.0, "payload": None}
26
+ _history: list[dict[str, Any]] = []
27
+ _lock = asyncio.Lock()
28
+
29
+
30
+ def _num(value: Any) -> Optional[float]:
31
+ try:
32
+ return float(value)
33
+ except (TypeError, ValueError):
34
+ return None
35
+
36
+
37
+ def _parse(url: str, data: dict[str, Any]) -> Optional[dict[str, Any]]:
38
+ """Normalise the different upstream shapes into one payload."""
39
+ price = _num(data.get("spot_usd_oz")) if "spot_usd_oz" in data else _num(data.get("price"))
40
+ if price is None:
41
+ return None
42
+
43
+ per_gram = _num(data.get("per_gram_usd")) or price / 31.1034768
44
+ return {
45
+ "price": price,
46
+ "per_gram": per_gram,
47
+ "per_kg": _num(data.get("per_kg_usd")) or per_gram * 1000.0,
48
+ "silver": _num(data.get("silver_usd_oz")),
49
+ "gold_silver_ratio": _num(data.get("gold_silver_ratio")),
50
+ "xaut_ratio": _num(data.get("xaut_gold_ratio")),
51
+ "paxg_ratio": _num(data.get("paxg_gold_ratio")),
52
+ "btc_usd": _num(data.get("btc_usd")),
53
+ "btc_gold_oz": _num(data.get("btc_gold_oz")),
54
+ "source": str(data.get("source") or url.split("/")[2]),
55
+ "upstream_updated_at": data.get("updated_at") or data.get("updatedAt"),
56
+ }
57
+
58
+
59
+ async def _fetch_upstream() -> Optional[dict[str, Any]]:
60
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, follow_redirects=True) as client:
61
+ for url in UPSTREAM:
62
+ try:
63
+ r = await client.get(url, headers={"Accept": "application/json"})
64
+ r.raise_for_status()
65
+ payload = _parse(url, r.json())
66
+ except Exception:
67
+ continue
68
+ if payload:
69
+ return payload
70
+ return None
71
+
72
+
73
+ async def _get_price() -> tuple[Optional[dict[str, Any]], Optional[str]]:
74
+ async with _lock:
75
+ now = time.time()
76
+ if _cache["payload"] and now - _cache["ts"] < CACHE_TTL:
77
+ return _cache["payload"], None
78
+
79
+ payload = await _fetch_upstream()
80
+ if payload is None:
81
+ if _cache["payload"]: # serve stale rather than nothing
82
+ return _cache["payload"], "upstream unavailable — showing last known price"
83
+ return None, "upstream unavailable"
84
+
85
+ payload["fetched_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
86
+ _cache.update(ts=now, payload=payload)
87
+ _history.append({"t": now, "p": payload["price"]})
88
+ del _history[:-MAX_HISTORY]
89
+ return payload, None
90
+
91
+
92
+ @app.get("/api/price")
93
+ async def api_price() -> JSONResponse:
94
+ payload, warning = await _get_price()
95
+ if payload is None:
96
+ return JSONResponse({"ok": False, "error": warning}, status_code=503)
97
+ body = dict(payload)
98
+ body["ok"] = True
99
+ body["warning"] = warning
100
+ body["history"] = [round(point["p"], 2) for point in _history]
101
+ return JSONResponse(body, headers={"Cache-Control": "no-store"})
102
+
103
+
104
+ @app.get("/health")
105
+ async def health() -> dict[str, Any]:
106
+ return {"status": "ok", "cached": bool(_cache["payload"])}
107
+
108
+
109
+ # mount last so it doesn't shadow the API routes
110
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")