159 lines
5.3 KiB
Python
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)
|