StatistaAmeri/backend/live_stream.py

159 lines
5.3 KiB
Python

import math, random
def _generate_sparkline(current_price: float, pct_change: float = 0.0, n_points: int = 40) -> list:
if not current_price or current_price <= 0:
return [0.0] * n_points
pct = (pct_change or 0.0) / 100.0
start_price = current_price / (1.0 + pct) if (1.0 + pct) > 0 else current_price
points = []
trend_step = (current_price - start_price) / max(1, n_points - 1)
volatility = max(abs(pct) * 0.4, 0.007) * current_price
rand_seed = int(abs(current_price) * 1000) % 100000
r = random.Random(rand_seed)
for i in range(n_points - 1):
progress = i / float(n_points - 1)
base = start_price + trend_step * i
wave = math.sin(progress * math.pi * 3.5) * (volatility * 0.6) + math.cos(progress * math.pi * 5.2) * (volatility * 0.4)
noise = (r.random() - 0.5) * volatility * 0.45
val = base + wave + noise
points.append(round(max(val, current_price * 0.2), 2 if current_price > 100 else 4))
points.append(round(current_price, 2 if current_price > 100 else 4))
return points
"""
Polls Yahoo Finance every 3 seconds for live commodity prices.
Broadcasts to all connected WebSocket clients.
"""
import asyncio
import json
import yfinance as yf
from fastapi import WebSocket
SYMBOLS = {
"CL=F": "Crude Oil WTI",
"BZ=F": "Brent Crude",
"NG=F": "Natural Gas",
"RB=F": "Gasoline",
"HO=F": "Heating Oil",
"GC=F": "Gold",
"SI=F": "Silver",
"HG=F": "Copper",
"PL=F": "Platinum",
"PA=F": "Palladium",
"ALI=F": "Aluminum",
"NI=F": "Nickel",
"ZC=F": "Corn",
"ZW=F": "Wheat",
"ZS=F": "Soybeans",
"KC=F": "Coffee",
"CT=F": "Cotton",
"CC=F": "Cocoa",
"SB=F": "Sugar",
"OJ=F": "Orange Juice",
}
_clients: set[WebSocket] = set()
_latest: list[dict] = []
_MAX_CLIENTS = 500 # ponytail: global cap — the board is identical for everyone, so no per-IP fairness needed
_SYM_LIST = " ".join(SYMBOLS.keys())
def _fetch_sync() -> list[dict]:
try:
# Single batch HTTP request — much faster than one-by-one
raw = yf.download(
_SYM_LIST,
period="2d",
progress=False,
group_by="ticker",
auto_adjust=True,
threads=True,
)
out = []
for sym, name in SYMBOLS.items():
try:
if sym in raw.columns.get_level_values(0):
df = raw[sym].dropna()
else:
df = raw.dropna()
if df.empty:
continue
price = float(df["Close"].iloc[-1])
prev = float(df["Close"].iloc[-2]) if len(df) > 1 else price
change = price - prev
pct = (change / prev * 100) if prev else 0
out.append({
"symbol": sym,
"name": name,
"price": round(price, 4),
"change": round(change, 4),
"pct": round(pct, 3),
"currency": "USD",
"up": change >= 0,
"sparkline": _generate_sparkline(price, pct),
})
except Exception:
pass
return out
except Exception as e:
print(f"[live] batch fetch error: {e}")
# fallback: individual fetch
out = []
tickers = yf.Tickers(_SYM_LIST)
for sym, name in SYMBOLS.items():
try:
fi = tickers.tickers[sym].fast_info
price = fi.last_price or 0
prev = fi.previous_close or price
change = price - prev
pct = (change / prev * 100) if prev else 0
out.append({"symbol": sym, "name": name, "price": round(price,4),
"change": round(change,4), "pct": round(pct,3),
"currency": "USD", "up": change >= 0,
"sparkline": _generate_sparkline(price, pct)})
except Exception:
pass
return out
async def fetch_prices() -> list[dict]:
return await asyncio.to_thread(_fetch_sync)
def get_latest() -> list[dict]:
"""Latest commodity snapshot — served over plain HTTP (tunnel/proxy friendly)."""
return _latest
async def broadcast_loop():
global _latest, _clients # _clients is reassigned below (-= dead), so declare it global
while True:
try:
data = await fetch_prices()
if data:
_latest = data
msg = json.dumps(data)
dead = set()
for ws in _clients.copy():
try:
await ws.send_text(msg)
except Exception:
dead.add(ws)
_clients -= dead
except Exception as e:
print(f"[live] error: {e}")
await asyncio.sleep(3)
async def connect(ws: WebSocket):
if len(_clients) >= _MAX_CLIENTS:
await ws.close(code=1013) # 1013 = "try again later"
return
await ws.accept()
_clients.add(ws)
if _latest:
await ws.send_text(json.dumps(_latest))
try:
while True:
await ws.receive_text()
except Exception:
pass
finally:
_clients.discard(ws)