Initial commit: Gold Trading Simulator with AI-powered analysis

This commit is contained in:
Krikorios
2025-11-16 00:50:04 +02:00
commit 72c1d3adb7
128 changed files with 16232 additions and 0 deletions
View File
+215
View File
@@ -0,0 +1,215 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime
from typing import Dict, Set, Tuple, Any
import httpx
import asyncio
import time
import random
from app.config import settings
from app.streaming.live_store import live_store
ALPHA_BASE = "https://www.alphavantage.co/query"
@dataclass(frozen=True)
class AVKey:
symbol: str # e.g., XAUUSD
timeframe: str # '1m' only for hub
class AlphaVantageHub:
"""
Polls Alpha Vantage FX_INTRADAY for the latest bar per (symbol, 1m),
fans out to subscribers via asyncio.Queue, and ingests into live_store.
Ensures a single poller per (symbol,timeframe).
"""
def __init__(self) -> None:
self._subs: Dict[AVKey, Set[asyncio.Queue]] = {}
self._tasks: Dict[AVKey, asyncio.Task] = {}
self._lock = asyncio.Lock()
# Conditional request caches
self._etags: Dict[AVKey, str] = {}
self._last_mod: Dict[AVKey, str] = {}
# Global token-bucket (Alpha free tier ~5 req/min)
self._rate_lock = asyncio.Lock()
self._tokens: float = 5.0
self._max_tokens: float = 5.0
self._refill_rate_per_sec: float = 5.0 / 60.0
self._last_refill_ts: float = time.time()
async def _acquire_token(self) -> None:
# Simple async token bucket
while True:
async with self._rate_lock:
now = time.time()
elapsed = now - self._last_refill_ts
if elapsed > 0:
self._tokens = min(self._max_tokens, self._tokens + elapsed * self._refill_rate_per_sec)
self._last_refill_ts = now
if self._tokens >= 1.0:
self._tokens -= 1.0
return
# Not enough tokens, compute wait time for next token
need = 1.0 - self._tokens
wait = max(0.1, need / self._refill_rate_per_sec)
await asyncio.sleep(min(wait, 5.0))
def get_status(self) -> list[dict]:
out: list[dict] = []
for key, subs in self._subs.items():
hist = live_store.get_history(key.symbol, key.timeframe)
last_ts = hist[-1]["time"] if hist else None
last_iso = None
if isinstance(last_ts, (int, float)):
try:
last_iso = datetime.utcfromtimestamp(int(last_ts)).isoformat() + "Z"
except Exception:
last_iso = None
out.append({
"symbol": key.symbol,
"timeframe": key.timeframe,
"subscribers": len(subs),
"last_event_time": last_iso,
})
return out
async def subscribe(self, symbol: str, timeframe: str = "1m") -> Tuple[asyncio.Queue, Any]:
if timeframe != "1m":
raise ValueError("AlphaVantageHub currently supports timeframe '1m' only")
key = AVKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
q: asyncio.Queue = asyncio.Queue(maxsize=100)
async with self._lock:
subs = self._subs.get(key)
if not subs:
subs = set()
self._subs[key] = subs
subs.add(q)
if key not in self._tasks:
self._tasks[key] = asyncio.create_task(self._run_poller(key))
async def _unsubscribe() -> None:
async with self._lock:
s = self._subs.get(key)
if s and q in s:
s.remove(q)
try:
q.put_nowait(None)
except Exception:
pass
if s is not None and len(s) == 0:
t = self._tasks.pop(key, None)
if t:
t.cancel()
self._subs.pop(key, None)
return q, _unsubscribe
async def _run_poller(self, key: AVKey) -> None:
symbol = key.symbol
from_symbol = symbol[:3]
to_symbol = symbol[3:]
apikey = settings.ALPHA_VANTAGE_API_KEY or "demo"
last_ts: int | None = None
poll_interval = 60 # seconds
backoff_cap = 300 # max 5 min
async with httpx.AsyncClient(timeout=30) as client:
while True:
try:
await self._acquire_token()
params = {
"function": "FX_INTRADAY",
"from_symbol": from_symbol,
"to_symbol": to_symbol,
"interval": "1min",
"outputsize": "compact",
"apikey": apikey,
}
headers = {}
et = self._etags.get(key)
lm = self._last_mod.get(key)
if et:
headers["If-None-Match"] = et
if lm:
headers["If-Modified-Since"] = lm
r = await client.get(ALPHA_BASE, params=params, headers=headers)
if r.status_code == 304:
# Not modified, keep interval
delay = poll_interval + random.uniform(0, 2)
await asyncio.sleep(delay)
continue
# Raise for other non-2xx
r.raise_for_status()
# Store caching headers for next time
etag = r.headers.get("ETag")
if etag:
self._etags[key] = etag
last_mod = r.headers.get("Last-Modified")
if last_mod:
self._last_mod[key] = last_mod
js = r.json()
series = js.get("Time Series FX (1min)") or {}
if series:
latest_ts_str = max(series.keys())
dt = datetime.fromisoformat(latest_ts_str)
tsec = int(dt.timestamp())
if last_ts is None or tsec > last_ts:
row = series[latest_ts_str]
evt = {
"symbol": symbol,
"timeframe": key.timeframe,
"open_time": dt.isoformat(),
"close_time": dt.isoformat(),
"open": float(row["1. open"]),
"high": float(row["2. high"]),
"low": float(row["3. low"]),
"close": float(row["4. close"]),
"volume": float(row.get("5. volume", 0.0)),
"is_closed": True,
"source": "alpha_vantage",
}
try:
live_store.ingest_bar(symbol=symbol, timeframe="1m", bar={
"time": tsec,
"open": evt["open"],
"high": evt["high"],
"low": evt["low"],
"close": evt["close"],
"volume": evt["volume"],
})
except Exception:
pass
subs = self._subs.get(key) or set()
for q in list(subs):
try:
if q.full():
q.get_nowait()
q.put_nowait(evt)
except Exception:
try:
subs.remove(q)
except Exception:
pass
last_ts = tsec
# success -> reset interval
poll_interval = 60
except httpx.HTTPStatusError as e:
status = e.response.status_code if e.response else None
# 429 or 5xx -> exponential backoff
if status == 429 or (status and 500 <= status < 600):
poll_interval = min(backoff_cap, max(60, int(poll_interval * 2)))
# else, keep interval
except Exception:
# network or parse error
poll_interval = min(backoff_cap, max(60, int(poll_interval * 2)))
# sleep with small jitter
delay = poll_interval + random.uniform(0, 2)
await asyncio.sleep(delay)
# Singleton hub
alpha_hub = AlphaVantageHub()
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
import asyncio
import json
import os
from dataclasses import dataclass
from datetime import datetime
from typing import Dict, Set, Tuple, Any
import websockets
from app.streaming.live_store import live_store
@dataclass(frozen=True)
class StreamKey:
symbol: str
timeframe: str # only '1m' supported in hub
class BinanceStreamHub:
"""
Maintains a single upstream websocket per (symbol,timeframe) and fans out
kline events to multiple subscribers via asyncio.Queues.
"""
def __init__(self, base_ws: str | None = None) -> None:
self.base_ws = (base_ws or os.getenv("BINANCE_WS_URL", "wss://stream.binance.com:9443/ws")).rstrip("/")
self._subs: Dict[StreamKey, Set[asyncio.Queue]] = {}
self._tasks: Dict[StreamKey, asyncio.Task] = {}
self._lock = asyncio.Lock()
def get_status(self) -> list[dict]:
"""Return status snapshot of active streams."""
out: list[dict] = []
for key, subs in self._subs.items():
hist = live_store.get_history(key.symbol, key.timeframe)
last_ts = hist[-1]["time"] if hist else None
last_iso = None
if isinstance(last_ts, (int, float)):
try:
last_iso = datetime.utcfromtimestamp(int(last_ts)).isoformat() + "Z"
except Exception:
last_iso = None
out.append({
"symbol": key.symbol,
"timeframe": key.timeframe,
"subscribers": len(subs),
"last_event_time": last_iso,
})
return out
async def subscribe(self, symbol: str, timeframe: str = "1m") -> Tuple[asyncio.Queue, Any]:
"""Subscribe to a stream. Returns (queue, unsubscribe_cb)."""
if timeframe != "1m":
raise ValueError("BinanceStreamHub currently supports timeframe '1m' only")
key = StreamKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
q: asyncio.Queue = asyncio.Queue(maxsize=1000)
async with self._lock:
subs = self._subs.get(key)
if not subs:
subs = set()
self._subs[key] = subs
subs.add(q)
if key not in self._tasks:
self._tasks[key] = asyncio.create_task(self._run_stream(key))
async def _unsubscribe() -> None:
async with self._lock:
s = self._subs.get(key)
if s and q in s:
s.remove(q)
# Close queue to unblock listeners
try:
q.put_nowait(None)
except Exception:
pass
if s is not None and len(s) == 0:
# cancel task and cleanup
t = self._tasks.pop(key, None)
if t:
t.cancel()
self._subs.pop(key, None)
return q, _unsubscribe
async def _run_stream(self, key: StreamKey) -> None:
symbol = key.symbol
stream = f"{symbol.lower()}@kline_{key.timeframe}"
url = self.base_ws.replace("/ws", "/stream") + f"?streams={stream}"
# Reconnect loop
while True:
try:
async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws:
async for message in ws:
try:
data = json.loads(message)
k = (data.get("data") or {}).get("k") or {}
if not k:
continue
# Normalize event
evt = {
"symbol": symbol,
"timeframe": key.timeframe,
"open_time": datetime.fromtimestamp(k["t"] / 1000.0).isoformat(),
"close_time": datetime.fromtimestamp(k["T"] / 1000.0).isoformat(),
"open": float(k["o"]),
"high": float(k["h"]),
"low": float(k["l"]),
"close": float(k["c"]),
"volume": float(k.get("v", 0.0)),
"is_closed": bool(k.get("x", False)),
"source": "binance",
}
# Update live store (1m bar)
try:
tsec = int(k["T"] // 1000)
live_store.ingest_bar(symbol=symbol, timeframe=key.timeframe, bar={
"time": tsec, "open": evt["open"], "high": evt["high"], "low": evt["low"], "close": evt["close"], "volume": evt["volume"],
})
except Exception:
pass
# Fan-out to subscribers
subs = self._subs.get(key) or set()
for q in list(subs):
try:
if q.full():
q.get_nowait()
q.put_nowait(evt)
except Exception:
# Drop failed subscriber
try:
subs.remove(q)
except Exception:
pass
except Exception:
continue
except Exception:
await asyncio.sleep(1.5)
# Singleton hub instance
hub = BinanceStreamHub()
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import asyncio
import os
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Dict, List, Tuple, Any
import glob
import shutil
import pyarrow as pa
import pyarrow.parquet as pq
@dataclass
class _Series:
bars: List[Dict[str, Any]]
last_flushed_ts: int
class LiveStore:
def __init__(self, max_bars: int = 5000, root: str = "data/parquet/live") -> None:
self._series: Dict[Tuple[str, str], _Series] = {}
self._max_bars = max_bars
self._root = root
self._lock = asyncio.Lock()
@property
def root(self) -> str:
return self._root
def _get_series(self, symbol: str, timeframe: str) -> _Series:
key = (symbol, timeframe)
s = self._series.get(key)
if not s:
s = _Series(bars=[], last_flushed_ts=0)
self._series[key] = s
return s
def get_history(self, symbol: str, timeframe: str) -> List[Dict[str, Any]]:
s = self._get_series(symbol, timeframe)
return list(s.bars)
def ingest_bar(self, symbol: str, timeframe: str, bar: Dict[str, Any]) -> None:
s = self._get_series(symbol, timeframe)
if s.bars and s.bars[-1]["time"] == bar["time"]:
# update last
last = s.bars[-1]
last["high"] = max(last["high"], bar["high"])
last["low"] = min(last["low"], bar["low"])
last["close"] = bar["close"]
last["volume"] = last.get("volume", 0.0) + bar.get("volume", 0.0)
else:
s.bars.append(bar)
if len(s.bars) > self._max_bars:
s.bars.pop(0)
async def flush_parquet(self) -> None:
# Write new bars since last flush, partitioned by date
async with self._lock:
for (symbol, timeframe), s in self._series.items():
new_rows = [b for b in s.bars if b["time"] > s.last_flushed_ts]
if not new_rows:
continue
# Partition by date
rows_by_date: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for r in new_rows:
dt = datetime.utcfromtimestamp(int(r["time"]))
rows_by_date[dt.strftime("%Y-%m-%d")].append(r)
for date_str, rows in rows_by_date.items():
table = pa.Table.from_pylist(rows)
base = os.path.join(self._root, symbol, timeframe)
out = os.path.join(base, f"date={date_str}")
os.makedirs(out, exist_ok=True)
# write one file per flush to this partition
pq.write_table(table, os.path.join(out, f"part-{int(datetime.utcnow().timestamp())}.parquet"))
s.last_flushed_ts = max(b["time"] for b in new_rows)
# Singleton store
live_store = LiveStore()
async def periodic_flush(interval_sec: int = 60):
while True:
try:
await live_store.flush_parquet()
except Exception:
pass
await asyncio.sleep(interval_sec)
def _iter_partitions(root: str):
"""Yield (symbol, timeframe, partition_path, date_str) for existing partitions."""
# root/symbol/timeframe/date=YYYY-MM-DD
for sym_dir in glob.glob(f"{root}/*"):
if not os.path.isdir(sym_dir):
continue
symbol = os.path.basename(sym_dir)
for tf_dir in glob.glob(f"{sym_dir}/*"):
if not os.path.isdir(tf_dir):
continue
timeframe = os.path.basename(tf_dir)
for part_dir in glob.glob(f"{tf_dir}/date=*" ):
if not os.path.isdir(part_dir):
continue
date_str = os.path.basename(part_dir).split("=", 1)[-1]
yield (symbol, timeframe, part_dir, date_str)
def prune_old_partitions(root: str, retention_days: int = 7) -> int:
"""Delete partition directories older than retention_days. Returns count deleted."""
now = datetime.utcnow()
deleted = 0
for symbol, timeframe, part_dir, date_str in list(_iter_partitions(root)):
try:
y, m, d = map(int, date_str.split("-"))
dt = datetime(y, m, d)
if now - dt > timedelta(days=retention_days):
shutil.rmtree(part_dir, ignore_errors=True)
deleted += 1
except Exception:
# Skip unparsable date partitions
continue
return deleted
def compact_partition(part_dir: str, max_files_threshold: int = 20) -> bool:
"""If too many small part files exist, compact them into a single file.
Returns True if compaction performed.
"""
part_files = sorted(glob.glob(os.path.join(part_dir, "part-*.parquet")))
if len(part_files) < max_files_threshold:
return False
try:
tables: List[pa.Table] = []
for p in part_files:
tables.append(pq.read_table(p))
if not tables:
return False
combined = pa.concat_tables(tables, promote=True)
out_file = os.path.join(part_dir, f"compact-{int(datetime.utcnow().timestamp())}.parquet")
pq.write_table(combined, out_file)
# remove old parts
for p in part_files:
try:
os.remove(p)
except Exception:
pass
return True
except Exception:
return False
def compact_all(root: str, max_files_threshold: int = 20) -> int:
"""Run compaction across all partitions. Returns number of partitions compacted."""
compacted = 0
for _, _, part_dir, _ in list(_iter_partitions(root)):
if compact_partition(part_dir, max_files_threshold=max_files_threshold):
compacted += 1
return compacted
async def periodic_maintenance(retention_days: int = 7, compact_threshold_files: int = 20, interval_sec: int = 900):
"""Periodically prune old partitions and compact small files."""
while True:
try:
prune_old_partitions(live_store.root, retention_days=retention_days)
compact_all(live_store.root, max_files_threshold=compact_threshold_files)
except Exception:
pass
await asyncio.sleep(interval_sec)