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
+19
View File
@@ -0,0 +1,19 @@
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/gold_trading_db
# API Keys (Required)
ALPHA_VANTAGE_API_KEY=your_alpha_vantage_key_here
OPENROUTER_API_KEY=your_openrouter_key_here
# Optional News API Keys (for enhanced news coverage)
FINNHUB_API_KEY=
NEWS_API_KEY=
# Application
APP_ENV=development
DEBUG=True
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Server
HOST=0.0.0.0
PORT=8000
+2
View File
@@ -0,0 +1,2 @@
# Gold Trading Simulator Backend
__version__ = "1.0.0"
+1
View File
@@ -0,0 +1 @@
# API routes package
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from fastapi import APIRouter
from typing import Any, Dict, List
from datetime import datetime, timezone
from app.api.trading import simulation_state
from app.streaming.live_store import live_store
router = APIRouter(prefix="/account", tags=["Account"])
router_positions = APIRouter(tags=["Positions"])
def _latest_close(symbol: str, timeframe: str = "1m") -> float | None:
try:
history = live_store.get_history(symbol, timeframe)
if history:
return float(history[-1]["close"])
except Exception:
pass
return None
@router.get("")
async def get_account() -> Dict[str, Any]:
cash = float(simulation_state.get("cash", 0.0))
initial = float(simulation_state.get("initial_capital", 0.0))
pos = simulation_state.get("position")
position_value = 0.0
exposure: Dict[str, float] = {}
if pos:
symbol = pos.get("symbol", "XAU/USD")
last = _latest_close(symbol) or float(pos["avg_price"])
position_value = float(pos["quantity"]) * last
exposure[symbol] = position_value
equity = cash + position_value
return {
"time": datetime.now(timezone.utc).isoformat(),
"cash": cash,
"equity": equity,
"initial_capital": initial,
"margin_used": 0.0,
"exposure": exposure,
}
@router.get("/positions")
@router_positions.get("/positions")
async def get_positions() -> List[Dict[str, Any]]:
pos = simulation_state.get("position")
if not pos:
return []
symbol = pos.get("symbol", "XAU/USD")
last = _latest_close(symbol)
return [
{
"symbol": symbol,
"quantity": float(pos["quantity"]),
"avg_price": float(pos["avg_price"]),
"last_price": float(last) if last is not None else None,
"market_value": float(pos["quantity"]) * (float(last) if last is not None else float(pos["avg_price"]))
}
]
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from fastapi import APIRouter
from app.streaming.binance_hub import hub as binance_hub
from app.streaming.alpha_hub import alpha_hub
router = APIRouter(prefix="/admin", tags=["Admin"])
@router.get("/streams")
async def streams_status():
"""Return current streaming hubs status (Binance and Alpha Vantage)."""
return {
"binance": binance_hub.get_status(),
"alpha_vantage": alpha_hub.get_status(),
}
+43
View File
@@ -0,0 +1,43 @@
from fastapi import APIRouter, HTTPException
from app.services.openrouter import openrouter_service
from app.schemas.schemas import AIAnalysisRequest, AIAnalysisResponse
from app.services.decisions import log_decision
router = APIRouter(prefix="/ai", tags=["AI Analysis"])
@router.post("/analyze", response_model=AIAnalysisResponse)
async def analyze_scenario(request: AIAnalysisRequest):
"""
Analyze trading scenario using AI (Claude 3.5 Sonnet via OpenRouter)
Provides:
- Trading recommendation (BUY/SELL/HOLD)
- Confidence level
- Detailed reasoning
- Support and resistance levels
- Risk assessment
"""
try:
analysis = await openrouter_service.analyze_scenario(request)
# Log decision (best-effort) with minimal metadata
try:
log_decision(
symbol="XAU/USD",
timeframe="unknown",
style="unknown",
recommendation=analysis.recommendation.value if hasattr(analysis, 'recommendation') else str(analysis.recommendation),
confidence=float(analysis.confidence),
risk_level=analysis.risk_level.value if hasattr(analysis, 'risk_level') else str(analysis.risk_level),
rationale=analysis.reasoning,
inputs_hash=None,
cost={},
)
except Exception:
pass
return analysis
except Exception as e:
raise HTTPException(
status_code=500, detail=f"AI analysis failed: {str(e)}"
)
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from fastapi import APIRouter, Query
from typing import List, Dict, Any
from app.services.decisions import store
router = APIRouter(prefix="/decisions", tags=["Decisions"])
@router.get("/latest")
async def latest_decisions(limit: int = Query(20, ge=1, le=100)) -> List[Dict[str, Any]]:
return store.latest(limit=limit)
+76
View File
@@ -0,0 +1,76 @@
from fastapi import APIRouter, HTTPException, Query
from typing import List
from app.services.price_simulator import gold_simulator
from app.schemas.schemas import PriceData, MarketDataResponse
router = APIRouter(prefix="/market", tags=["Market Data"])
@router.get("/gold/current", response_model=MarketDataResponse)
async def get_current_gold_price():
"""Get current gold (XAU/USD) market data - simulated live feed"""
try:
# Get current simulated price
current_price = gold_simulator.get_current_price()
# Generate recent data for 24h high/low calculation
recent_data = gold_simulator.generate_historical_data(interval="60min", points=24)
if len(recent_data) > 0:
# Calculate 24h stats
high_24h = max(candle.high for candle in recent_data)
low_24h = min(candle.low for candle in recent_data)
latest = recent_data[-1]
previous = recent_data[-2] if len(recent_data) > 1 else latest
change = latest.close - previous.close
change_percent = (change / previous.close) * 100
return MarketDataResponse(
symbol="XAU/USD",
price=current_price,
change=change,
change_percent=change_percent,
high_24h=high_24h,
low_24h=low_24h,
volume=0.0,
)
else:
raise HTTPException(status_code=500, detail="Unable to generate market data")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/gold/history", response_model=List[PriceData])
async def get_gold_historical_data(
interval: str = Query("daily", description="Time interval: daily, 1min, 5min, 15min, 30min, 60min"),
output_size: str = Query("compact", description="compact (100 points) or full (500 points)"),
):
"""Get historical gold (XAU/USD) price data - simulated"""
try:
# Determine number of points based on output_size
points = 500 if output_size == "full" else 100
# Generate historical data using simulator
data = gold_simulator.generate_historical_data(interval=interval, points=points)
return data
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/gold/live", response_model=PriceData)
async def get_live_gold_price(
interval: str = Query("1min", description="Time interval for rounding: 1min, 5min, 15min, 30min, 60min")
):
"""Get latest live gold price tick - simulated real-time feed (no external API calls)"""
try:
# Use the simulator to generate a live candle
live_candle = gold_simulator.get_live_candle(interval=interval)
return live_candle
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+147
View File
@@ -0,0 +1,147 @@
from fastapi import APIRouter, HTTPException, Query
from typing import List
from app.services.news_service import news_service
from app.services.alert_service import alert_service
from app.schemas.schemas import (
NewsFeedResponse,
EconomicCalendarResponse,
AlertsResponse,
CorrelationAnalysisResponse,
)
router = APIRouter(prefix="/news", tags=["News & Sentiment"])
@router.get("/feed", response_model=NewsFeedResponse)
async def get_news_feed(
limit: int = Query(50, description="Maximum number of articles to return"),
):
"""
Get aggregated news feed from multiple sources with sentiment analysis
Features:
- Fetches from Alpha Vantage News Sentiment API
- Fetches from Finnhub (if API key provided)
- Filters for gold-relevant news
- Performs sentiment analysis
- Categorizes by impact type
- Calculates relevance scores
- Provides overall market sentiment
"""
try:
news_feed = await news_service.get_aggregated_news_feed()
# Limit articles
news_feed.articles = news_feed.articles[:limit]
# Generate alerts for high-impact news
for article in news_feed.articles:
if article.impact_on_gold == "HIGH":
alert_service.add_news_alert(
news_title=article.title,
impact=article.impact_on_gold,
sentiment=article.sentiment.value,
)
return news_feed
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to fetch news feed: {str(e)}"
)
@router.get("/economic-calendar", response_model=EconomicCalendarResponse)
async def get_economic_calendar():
"""
Get upcoming economic events that may impact gold prices
Includes:
- Federal Reserve meetings
- Employment reports
- Inflation data (CPI, PPI)
- GDP releases
- Central bank decisions
"""
try:
calendar = await news_service.get_economic_calendar()
return calendar
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to fetch economic calendar: {str(e)}"
)
@router.get("/alerts", response_model=AlertsResponse)
async def get_alerts(
limit: int = Query(50, description="Maximum number of alerts to return"),
):
"""
Get recent alerts for price movements and news events
Alert Types:
- PRICE_SPIKE: Significant upward price movement
- PRICE_DROP: Significant downward price movement
- NEWS_BREAKING: High-impact breaking news
- SUPPORT_BREACH: Price broke below support level
- RESISTANCE_BREACH: Price broke above resistance level
- HIGH_VOLATILITY: Unusual price volatility detected
- ECONOMIC_EVENT: Upcoming important economic release
"""
try:
alerts = alert_service.get_alerts(limit=limit)
return alerts
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to fetch alerts: {str(e)}"
)
@router.get("/correlation", response_model=CorrelationAnalysisResponse)
async def get_news_price_correlation():
"""
Analyze correlation between news events and price movements
Shows:
- How price reacted to specific news
- Time delay between news and price change
- Correlation strength (STRONG/MODERATE/WEAK)
- Average price impact from news
"""
try:
# Get recent news and price data
news_feed = await news_service.get_aggregated_news_feed()
# Would need price data here - for MVP return empty
# In full implementation, fetch from market service
correlation = alert_service.analyze_news_price_correlation(
news_articles=news_feed.articles[:20],
price_data=[], # Would pass actual price data
)
return correlation
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to analyze correlation: {str(e)}"
)
@router.post("/alerts/clear")
async def clear_old_alerts():
"""Clear alerts older than 24 hours"""
try:
alert_service.clear_old_alerts(hours=24)
return {"message": "Old alerts cleared successfully"}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to clear alerts: {str(e)}"
)
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
from fastapi import APIRouter, Query, HTTPException
from typing import List, Dict, Any
from app.services.crypto.binance_rest import fetch_klines as binance_klines
from app.services.metals.alpha_fx import fetch_fx_intraday, fetch_fx_daily
from app.utils.cache import TTLCache
from app.streaming.live_store import live_store
router = APIRouter(prefix="/ohlcv", tags=["OHLCV"])
_cache = TTLCache(default_ttl=60, maxsize=128)
def _resample(data: List[Dict[str, Any]], timeframe: str) -> List[Dict[str, Any]]:
# data is ascending, 1m or 5m depending on source
import math
seconds_map = {"1m": 60, "5m": 300, "1h": 3600, "4h": 14400, "1d": 86400}
tf_sec = seconds_map.get(timeframe, 60)
buckets: Dict[int, Dict[str, Any]] = {}
for d in data:
b = (d["time"] // tf_sec) * tf_sec
cur = buckets.get(b)
if cur is None:
buckets[b] = {
"time": b,
"open": d["open"],
"high": d["high"],
"low": d["low"],
"close": d["close"],
"volume": d.get("volume", 0.0),
}
else:
cur["high"] = max(cur["high"], d["high"])
cur["low"] = min(cur["low"], d["low"])
cur["close"] = d["close"]
cur["volume"] = cur.get("volume", 0.0) + d.get("volume", 0.0)
out = list(buckets.values())
out.sort(key=lambda x: x["time"])
return out
def _ttl_for(sym: str, timeframe: str) -> int:
# Tune TTL based on timeframe and provider characteristics
if sym.startswith("XAU"):
# Alpha Vantage free tier ~ 1/min practical cadence
if timeframe in ("1m", "5m"): return 60
if timeframe in ("1h", "4h"): return 300
return 3600
else:
# Binance updates are frequent; cache briefly
if timeframe == "1m": return 10
if timeframe in ("5m",): return 20
if timeframe in ("1h", "4h"): return 120
return 900
@router.get("")
async def get_ohlcv(
symbol: str = Query(..., description="e.g., BTCUSDT, ETHUSDT, XAUUSD"),
timeframe: str = Query("1m", description="1m,5m,1h,4h,1d"),
limit: int = Query(500, ge=10, le=1000),
) -> List[Dict[str, Any]]:
try:
sym = symbol.upper().replace("/", "")
key = (sym, timeframe)
cached = _cache.get(key)
if cached is not None:
return cached[-limit:]
if sym.startswith("XAU"):
# Prefer live store 1m if available (ingested by alpha_hub)
live_1m = live_store.get_history(sym, "1m")
if live_1m:
if timeframe == "1m":
return live_1m[-limit:]
data = _resample(live_1m, timeframe)
return data[-limit:]
# Fallback to Alpha Vantage REST
if timeframe in ("1m", "5m"):
base_tf = timeframe
data = await fetch_fx_intraday(sym, interval="1min" if timeframe == "1m" else "5min")
elif timeframe in ("1h", "4h"):
base_tf = "5m"
data = await fetch_fx_intraday(sym, interval="5min")
else: # daily
base_tf = "1d"
data = await fetch_fx_daily(sym)
if timeframe != base_tf:
data = _resample(data, timeframe)
ttl = _ttl_for(sym, timeframe)
_cache.set(key, data, ttl=ttl)
return data[-limit:]
else:
# Binance
if timeframe not in ("1m", "5m", "1h", "4h", "1d"):
raise HTTPException(status_code=400, detail="Unsupported timeframe")
# Prefer live store for 1m data if available
live_1m = live_store.get_history(sym, "1m")
if live_1m:
if timeframe == "1m":
return live_1m[-limit:]
# Resample from 1m to requested timeframe
data = _resample(live_1m, timeframe)
return data[-limit:]
# Fallback to REST
data = await binance_klines(sym, interval=timeframe, limit=1000)
ttl = _ttl_for(sym, timeframe)
_cache.set(key, data, ttl=ttl)
return data[-limit:]
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
from fastapi import APIRouter, Query
from typing import Any, Dict, List, Optional
import math
from app.api.trading import simulation_state
from app.streaming.live_store import live_store
router = APIRouter(tags=["Performance"]) # paths mounted at /api
def _equity_from_live(symbol: str = "XAU/USD", timeframe: str = "1m", limit: int = 300) -> List[Dict[str, Any]]:
bars = live_store.get_history(symbol, timeframe)
if not bars:
return []
if limit > 0:
bars = bars[-limit:]
cash = float(simulation_state.get("cash", 0.0))
qty = float(simulation_state.get("position", {}).get("quantity", 0.0) if simulation_state.get("position") else 0.0)
out: List[Dict[str, Any]] = []
for b in bars:
out.append({
"time": int(b["time"]),
"equity": cash + qty * float(b["close"]),
})
return out
@router.get("/equity-history")
async def equity_history(symbol: str = Query("XAU/USD"), timeframe: str = Query("1m"), limit: int = Query(300, ge=1, le=5000)) -> List[Dict[str, Any]]:
# Prefer recorded equity history if available
hist = simulation_state.get("equity_history") or []
if hist:
if limit > 0:
hist = hist[-limit:]
return hist
# Fallback: derive from current cash and open qty over historical closes
return _equity_from_live(symbol, timeframe, limit)
def _max_drawdown(eqs: List[float]) -> float:
max_peak = -math.inf
max_dd = 0.0
for v in eqs:
if v > max_peak:
max_peak = v
dd = (max_peak - v) / max_peak if max_peak > 0 else 0.0
if dd > max_dd:
max_dd = dd
return max_dd
@router.get("/performance")
async def performance(symbol: str = Query("XAU/USD"), timeframe: str = Query("1m"), limit: int = Query(300, ge=10, le=5000)) -> Dict[str, Any]:
series = await equity_history(symbol=symbol, timeframe=timeframe, limit=limit)
if not series or len(series) < 2:
return {"available": False}
eq = [float(x["equity"]) for x in series]
rets = []
for i in range(1, len(eq)):
prev = eq[i-1]
curr = eq[i]
if prev > 0:
rets.append(curr/prev - 1.0)
if not rets:
return {"available": False}
avg = sum(rets) / len(rets)
var = sum((r - avg)**2 for r in rets) / (len(rets) - 1) if len(rets) > 1 else 0.0
std = math.sqrt(var)
downside = [r for r in rets if r < 0]
if downside:
d_avg = sum(downside) / len(downside)
d_var = sum((r - d_avg)**2 for r in downside) / (len(downside) - 1) if len(downside) > 1 else 0.0
d_std = math.sqrt(d_var)
else:
d_std = 0.0
periods_per_year = {
"1m": 365*24*60,
"5m": 365*24*12,
"1h": 365*24,
"4h": 365*6,
"1d": 365,
}.get(timeframe, 365)
sharpe = (avg/std*math.sqrt(periods_per_year)) if std > 0 else None
sortino = (avg/d_std*math.sqrt(periods_per_year)) if d_std > 0 else None
total_return = (eq[-1]/eq[0] - 1.0) if eq[0] > 0 else None
mdd = _max_drawdown(eq)
return {
"available": True,
"count": len(eq),
"total_return": total_return,
"sharpe": sharpe,
"sortino": sortino,
"max_drawdown": mdd,
}
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from typing import Any, Dict, List
from app.services.prompts import list_templates, get_template
router = APIRouter(prefix="/prompt-templates", tags=["Prompts"])
@router.get("")
async def list_prompt_templates() -> List[Dict[str, Any]]:
return list_templates()
@router.get("/{name}")
async def get_prompt_template(name: str) -> Dict[str, Any]:
try:
return get_template(name)
except KeyError:
raise HTTPException(status_code=404, detail="Template not found")
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from fastapi import APIRouter
from typing import Any, Dict
from app.services.settings import get_models, update_models, get_exchanges, update_exchanges
router = APIRouter(prefix="/settings", tags=["Settings"])
@router.get("/models")
async def models_get() -> Dict[str, Any]:
return get_models()
@router.put("/models")
async def models_put(patch: Dict[str, Any]) -> Dict[str, Any]:
return update_models(patch)
@router.get("/exchanges")
async def exchanges_get() -> Dict[str, Any]:
return get_exchanges()
@router.put("/exchanges")
async def exchanges_put(patch: Dict[str, Any]) -> Dict[str, Any]:
return update_exchanges(patch)
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from fastapi import APIRouter
from datetime import datetime, timezone
from app.config import settings
from app.api.trading import simulation_state
from app.streaming.binance_hub import hub as binance_hub
from app.streaming.alpha_hub import alpha_hub
router = APIRouter(prefix="/status", tags=["Status"])
@router.get("")
async def get_status():
pos = simulation_state.get("position")
eq = float(simulation_state.get("cash", 0.0)) + (
float(pos["quantity"]) * float(pos["avg_price"]) if pos else 0.0
)
return {
"time": datetime.now(timezone.utc).isoformat(),
"app": {"name": settings.APP_NAME, "version": settings.APP_VERSION},
"simulation": {
"cash": float(simulation_state.get("cash", 0.0)),
"equity_est": eq,
"open_position": bool(pos),
},
"streams": {
"binance": binance_hub.get_status(),
"alpha_vantage": alpha_hub.get_status(),
},
}
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import asyncio
import json
from typing import List
import contextlib
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from app.streaming.binance_hub import hub as binance_hub
from app.streaming.alpha_hub import alpha_hub
router = APIRouter(prefix="/stream", tags=["Stream"])
@router.websocket("/klines")
async def stream_klines_ws(
websocket: WebSocket,
symbols: str = Query("BTCUSDT,XAUUSD"),
timeframe: str = Query("1m"),
):
await websocket.accept()
syms: List[str] = [s.strip().upper().replace("/", "") for s in symbols.split(",") if s.strip()]
async def forward_alpha(sym: str):
queue, unsubscribe = await alpha_hub.subscribe(sym, timeframe="1m")
try:
while True:
evt = await queue.get()
if evt is None:
break
try:
await websocket.send_text(json.dumps(evt))
except WebSocketDisconnect:
break
except Exception:
await asyncio.sleep(0)
finally:
try:
await unsubscribe()
except Exception:
pass
async def forward_binance(sym: str):
queue, unsubscribe = await binance_hub.subscribe(sym, timeframe="1m")
try:
while True:
evt = await queue.get()
if evt is None:
break
# evt already normalized with iso timestamps
try:
await websocket.send_text(json.dumps(evt))
except WebSocketDisconnect:
break
except Exception:
await asyncio.sleep(0)
finally:
try:
await unsubscribe()
except Exception:
pass
tasks: List[asyncio.Task] = []
try:
for s in syms:
if s == "XAUUSD" or s.startswith("XAU"):
tasks.append(asyncio.create_task(forward_alpha("XAUUSD")))
else:
tasks.append(asyncio.create_task(forward_binance(s)))
# Wait for disconnect
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
except WebSocketDisconnect:
pass
finally:
for t in tasks:
t.cancel()
with contextlib.suppress(Exception):
await t
with contextlib.suppress(Exception):
await websocket.close()
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import asyncio
import contextlib
import json
from typing import List, Callable, Awaitable
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from app.streaming.binance_hub import hub as binance_hub
from app.streaming.alpha_hub import alpha_hub
router = APIRouter(prefix="/stream", tags=["Stream"])
@router.get("/sse")
async def stream_sse(symbols: str = "BTCUSDT,XAUUSD", timeframe: str = "1m"):
"""
Server-Sent Events (SSE) multiplexer for multiple symbols over a single connection.
- Supports 1m timeframe (server streams 1m updates; clients can resample locally).
- symbols: comma-separated list (e.g., BTCUSDT,ETHUSDT,XAUUSD)
"""
if not symbols:
raise HTTPException(status_code=400, detail="symbols must not be empty")
if timeframe != "1m":
raise HTTPException(status_code=400, detail="Only timeframe=1m is supported")
syms: List[str] = [s.strip().upper().replace("/", "") for s in symbols.split(",") if s.strip()]
if not syms:
raise HTTPException(status_code=400, detail="No valid symbols provided")
out_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
tasks: List[asyncio.Task] = []
unsubscribers: List[Callable[[], Awaitable[None]]] = []
async def add_subscription(sym: str):
if sym.startswith("XAU"):
q, unsubscribe = await alpha_hub.subscribe(sym, timeframe="1m")
else:
q, unsubscribe = await binance_hub.subscribe(sym, timeframe="1m")
unsubscribers.append(unsubscribe)
async def worker():
try:
while True:
evt = await q.get()
if evt is None:
break
try:
await out_queue.put(evt)
except Exception:
await asyncio.sleep(0)
except asyncio.CancelledError:
pass
tasks.append(asyncio.create_task(worker()))
for s in syms:
await add_subscription(s)
async def event_generator():
try:
while True:
try:
evt = await asyncio.wait_for(out_queue.get(), timeout=15.0)
data = json.dumps(evt, separators=(",", ":"))
yield f"event: kline\n".encode("utf-8")
yield f"data: {data}\n\n".encode("utf-8")
except asyncio.TimeoutError:
# Keep-alive comment
yield b": ping\n\n"
finally:
for t in tasks:
t.cancel()
for t in tasks:
with contextlib.suppress(Exception):
await t
for u in unsubscribers:
with contextlib.suppress(Exception):
await u()
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
+133
View File
@@ -0,0 +1,133 @@
from fastapi import APIRouter, HTTPException
from typing import Dict
from datetime import datetime, timezone
from app.services.risk import validate_order
router = APIRouter(prefix="/trading", tags=["Trading"])
# In-memory simulation state (for MVP - will use DB in future)
simulation_state = {
"cash": 100000.0,
"initial_capital": 100000.0,
"position": None,
"trades": [],
"equity_history": [], # list of {time: epoch_sec, equity: float}
}
def _compute_equity_at_price(price: float) -> float:
pos = simulation_state.get("position")
qty = pos["quantity"] if pos else 0.0
return float(simulation_state.get("cash", 0.0) + qty * price)
@router.post("/execute")
async def execute_trade(trade: Dict):
"""
Execute a trade in the simulation.
- Validates simple risk rules (position cap, anti-stacking)
- Updates cash/position
- Records trade with timestamp
- Appends equity snapshot after execution
"""
try:
action = trade.get("action")
quantity = float(trade.get("quantity")) if trade.get("quantity") is not None else None
price = float(trade.get("price")) if trade.get("price") is not None else None
if not all([action, quantity is not None, price is not None]):
raise HTTPException(status_code=400, detail="Missing required fields")
# Risk validation prior to execution
try:
validate_order(simulation_state, action, quantity, price)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
total = quantity * price
if action == "BUY":
if total > simulation_state["cash"]:
raise HTTPException(status_code=400, detail="Insufficient funds")
simulation_state["cash"] -= total
if simulation_state["position"] is None:
simulation_state["position"] = {
"symbol": "XAU/USD",
"quantity": quantity,
"avg_price": price,
}
else:
# Update average price for additional buy
pos = simulation_state["position"]
new_qty = pos["quantity"] + quantity
new_avg = (
pos["avg_price"] * pos["quantity"] + price * quantity
) / new_qty
pos["quantity"] = new_qty
pos["avg_price"] = new_avg
elif action == "SELL":
if (
simulation_state["position"] is None
or quantity > simulation_state["position"]["quantity"]
):
raise HTTPException(status_code=400, detail="Insufficient position")
simulation_state["cash"] += total
pnl = (price - simulation_state["position"]["avg_price"]) * quantity
simulation_state["position"]["quantity"] -= quantity
if simulation_state["position"]["quantity"] == 0:
simulation_state["position"] = None
trade["pnl"] = pnl
else:
raise HTTPException(status_code=400, detail="Unsupported action")
now_ts = int(datetime.now(timezone.utc).timestamp())
trade["id"] = len(simulation_state["trades"]) + 1
trade["timestamp"] = now_ts
simulation_state["trades"].append(trade)
# Append equity snapshot post trade using trade price
equity = _compute_equity_at_price(price)
simulation_state["equity_history"].append({"time": now_ts, "equity": equity})
return trade
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/portfolio")
async def get_portfolio():
"""Get current portfolio state"""
return simulation_state
@router.post("/reset")
async def reset_simulation():
"""Reset simulation to initial state"""
global simulation_state
simulation_state = {
"cash": 100000.0,
"initial_capital": 100000.0,
"position": None,
"trades": [],
"equity_history": [],
}
return {"message": "Simulation reset successfully"}
@router.get("/history")
async def get_trade_history():
"""Get trade history"""
return simulation_state["trades"]
+50
View File
@@ -0,0 +1,50 @@
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
# Application
APP_NAME: str = "Gold Trading Simulator API"
APP_VERSION: str = "1.0.0"
APP_ENV: str = "development"
DEBUG: bool = True
# Database
DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/gold_trading_db"
# API Keys (all optional now - using simulated data)
ALPHA_VANTAGE_API_KEY: str = "" # Optional - not needed for simulator
OPENROUTER_API_KEY: str = "" # Optional - for AI features only
FINNHUB_API_KEY: str = "" # Optional
NEWS_API_KEY: str = "" # Optional
# CORS
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://127.0.0.1:3000"]
# Server
HOST: str = "0.0.0.0"
PORT: int = 8000
# OpenRouter
OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
OPENROUTER_MODEL: str = "anthropic/claude-3.5-sonnet"
OPENROUTER_SITE_URL: str = "https://gold-trading-simulator.local"
OPENROUTER_SITE_NAME: str = "Gold Trading Simulator"
# Alpha Vantage
ALPHA_VANTAGE_BASE_URL: str = "https://www.alphavantage.co/query"
# News APIs
FINNHUB_BASE_URL: str = "https://finnhub.io/api/v1"
NEWS_API_BASE_URL: str = "https://newsapi.org/v2"
# Alert Settings
PRICE_ALERT_THRESHOLD: float = 1.0 # Percentage change for alerts
NEWS_REFRESH_INTERVAL: int = 300 # Seconds (5 minutes)
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class Settings(BaseSettings):
"""Application settings (dev defaults). Bind to a .env later if needed."""
app_env: str = "development"
debug: bool = True
# CORS - default dev origins; can tighten later
cors_origins: list[str] = Field(
default_factory=lambda: [
"http://localhost:3000",
"http://127.0.0.1:3000",
]
)
# API keys (optional here; use backend/.env in dev)
alpha_vantage_api_key: str | None = None
openrouter_api_key: str | None = None
# Providers
binance_ws_url: str = "wss://stream.binance.com:9443/ws"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = Settings()
+1
View File
@@ -0,0 +1 @@
# Database package
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Dependency for database session"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""Initialize database tables"""
Base.metadata.create_all(bind=engine)
+76
View File
@@ -0,0 +1,76 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.api import market, ai, trading, news, stream, ohlcv
from app.api import admin, stream_sse, decisions
from app.streaming.live_store import periodic_flush, periodic_maintenance
import asyncio
# Newly added routers
from app.api import account, performance, status, settings_api, prompts
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="AI-Powered Gold Trading Scenario Simulator",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(market.router, prefix="/api")
app.include_router(ai.router, prefix="/api")
app.include_router(trading.router, prefix="/api")
app.include_router(news.router, prefix="/api")
app.include_router(stream.router, prefix="/api")
app.include_router(ohlcv.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
app.include_router(stream_sse.router, prefix="/api")
app.include_router(decisions.router, prefix="/api")
# New
app.include_router(account.router, prefix="/api")
app.include_router(account.router_positions, prefix="/api")
app.include_router(performance.router, prefix="/api")
app.include_router(status.router, prefix="/api")
app.include_router(settings_api.router, prefix="/api")
app.include_router(prompts.router, prefix="/api")
@app.on_event("startup")
async def _startup():
# Schedule periodic parquet flush in background
asyncio.create_task(periodic_flush(interval_sec=60))
# Schedule retention+compaction maintenance every 15 minutes
asyncio.create_task(periodic_maintenance(retention_days=7, compact_threshold_files=20, interval_sec=900))
@app.get("/")
async def root():
return {
"name": settings.APP_NAME,
"version": settings.APP_VERSION,
"status": "running",
}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.HOST,
port=settings.PORT,
reload=settings.DEBUG,
)
+4
View File
@@ -0,0 +1,4 @@
# Models package
from .models import Simulation, Trade, Position, AIAnalysisLog
__all__ = ["Simulation", "Trade", "Position", "AIAnalysisLog"]
+72
View File
@@ -0,0 +1,72 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.db.database import Base
class TradeAction(str, enum.Enum):
BUY = "BUY"
SELL = "SELL"
class Simulation(Base):
__tablename__ = "simulations"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(String, nullable=True) # For future multi-user support
symbol = Column(String, default="XAU/USD")
initial_capital = Column(Float, default=100000.0)
current_capital = Column(Float, default=100000.0)
total_pnl = Column(Float, default=0.0)
total_pnl_percent = Column(Float, default=0.0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
trades = relationship("Trade", back_populates="simulation", cascade="all, delete-orphan")
positions = relationship("Position", back_populates="simulation", cascade="all, delete-orphan")
class Trade(Base):
__tablename__ = "trades"
id = Column(Integer, primary_key=True, index=True)
simulation_id = Column(Integer, ForeignKey("simulations.id"))
action = Column(Enum(TradeAction))
quantity = Column(Float)
price = Column(Float)
total = Column(Float)
pnl = Column(Float, nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now())
simulation = relationship("Simulation", back_populates="trades")
class Position(Base):
__tablename__ = "positions"
id = Column(Integer, primary_key=True, index=True)
simulation_id = Column(Integer, ForeignKey("simulations.id"))
symbol = Column(String, default="XAU/USD")
quantity = Column(Float)
avg_price = Column(Float)
current_price = Column(Float)
unrealized_pnl = Column(Float)
unrealized_pnl_percent = Column(Float)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
simulation = relationship("Simulation", back_populates="positions")
class AIAnalysisLog(Base):
__tablename__ = "ai_analysis_logs"
id = Column(Integer, primary_key=True, index=True)
simulation_id = Column(Integer, nullable=True)
recommendation = Column(String)
confidence = Column(Float)
reasoning = Column(String)
risk_level = Column(String)
support_levels = Column(String) # JSON string
resistance_levels = Column(String) # JSON string
created_at = Column(DateTime(timezone=True), server_default=func.now())
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
import abc
from typing import AsyncIterator
from .typing import Kline
class BaseProvider(abc.ABC):
@abc.abstractmethod
async def stream_klines(self, symbol: str, timeframe: str) -> AsyncIterator["Kline"]:
...
@abc.abstractmethod
async def get_historical_ohlcv(self, symbol: str, timeframe: str, start=None, end=None):
...
@@ -0,0 +1,50 @@
from __future__ import annotations
import asyncio
import json
import websockets
from typing import AsyncIterator
from datetime import datetime
from ..typing import Kline
import os
class BinanceWSProvider:
def __init__(self, base_url: str | None = None):
self.base_url = base_url or os.getenv("BINANCE_WS_URL", "wss://stream.binance.com:9443/ws")
async def stream_klines(self, symbol: str, timeframe: str) -> AsyncIterator[Kline]:
# Binance expects lowercase, no slash: BTCUSDT -> btcusdt
stream = f"{symbol.lower()}@kline_{timeframe}"
url = self.base_url.rstrip("/").replace("/ws", "/stream") + f"?streams={stream}"
async for msg in self._ws_loop(url):
try:
data = json.loads(msg)
k = data.get("data", {}).get("k", {})
if not k:
continue
yield Kline(
symbol=symbol,
timeframe=timeframe,
open_time=datetime.fromtimestamp(k["t"] / 1000.0),
close_time=datetime.fromtimestamp(k["T"] / 1000.0),
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",
)
except Exception:
continue
async def _ws_loop(self, url: str):
while True:
try:
async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws:
async for message in ws:
yield message
except Exception:
await asyncio.sleep(2)
@@ -0,0 +1,60 @@
from __future__ import annotations
import asyncio
from datetime import datetime
from typing import AsyncIterator
import httpx
from ..typing import Kline
from app.config import settings
ALPHA_BASE = "https://www.alphavantage.co/query"
class AlphaVantageXAUProvider:
def __init__(self, api_key: str | None = None):
self.api_key = api_key or settings.alpha_vantage_api_key
async def stream_klines(self, symbol: str, timeframe: str) -> AsyncIterator[Kline]:
# Poll once per minute due to AV rate limits
assert symbol.upper() in {"XAUUSD", "XAU/USD"}
from_symbol = "XAU"
to_symbol = "USD"
interval = "1min" if timeframe == "1m" else "5min"
async with httpx.AsyncClient(timeout=30) as client:
while True:
params = {
"function": "FX_INTRADAY",
"from_symbol": from_symbol,
"to_symbol": to_symbol,
"interval": interval,
"outputsize": "compact",
"apikey": self.api_key or "demo",
}
try:
r = await client.get(ALPHA_BASE, params=params)
r.raise_for_status()
js = r.json()
# Pick the latest candle
key = f"Time Series FX ({interval})"
series = js.get(key) or {}
if series:
ts, row = next(iter(series.items()))
dt = datetime.fromisoformat(ts)
yield Kline(
symbol="XAUUSD",
timeframe=timeframe,
open_time=dt,
close_time=dt,
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",
)
except Exception:
pass
await asyncio.sleep(60)
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Kline:
symbol: str
timeframe: str
open_time: datetime
close_time: datetime
open: float
high: float
low: float
close: float
volume: float = 0.0
is_closed: bool = True
source: str = "other"
+1
View File
@@ -0,0 +1 @@
# Schemas package
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from typing import Literal
from datetime import datetime
class KlineEvent(BaseModel):
symbol: str
timeframe: str
open_time: datetime
close_time: datetime
open: float
high: float
low: float
close: float
volume: float = 0.0
is_closed: bool = Field(default=True, description="True when candle closed")
source: Literal["binance", "alpha_vantage", "oanda", "other"] = "other"
class OHLCVRequest(BaseModel):
symbol: str
timeframe: str
start: datetime | None = None
end: datetime | None = None
+211
View File
@@ -0,0 +1,211 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
class TradeAction(str, Enum):
BUY = "BUY"
SELL = "SELL"
class Recommendation(str, Enum):
BUY = "BUY"
SELL = "SELL"
HOLD = "HOLD"
class RiskLevel(str, Enum):
LOW = "LOW"
MEDIUM = "MEDIUM"
HIGH = "HIGH"
class PriceData(BaseModel):
time: int
open: float
high: float
low: float
close: float
volume: Optional[float] = None
class TradeCreate(BaseModel):
action: TradeAction
quantity: float
price: float
class TradeResponse(BaseModel):
id: int
simulation_id: int
action: TradeAction
quantity: float
price: float
total: float
pnl: Optional[float] = None
timestamp: datetime
class Config:
from_attributes = True
class PositionResponse(BaseModel):
symbol: str
quantity: float
avg_price: float
current_price: float
unrealized_pnl: float
unrealized_pnl_percent: float
class Config:
from_attributes = True
class PortfolioResponse(BaseModel):
cash: float
initial_capital: float
total_value: float
total_pnl: float
total_pnl_percent: float
position: Optional[PositionResponse] = None
trades: List[TradeResponse] = []
class MarketDataResponse(BaseModel):
symbol: str = "XAU/USD"
price: float
change: float
change_percent: float
high_24h: float
low_24h: float
volume: float
class SupportResistance(BaseModel):
support: List[float] = []
resistance: List[float] = []
class AIAnalysisRequest(BaseModel):
price_data: List[PriceData]
indicators: List[dict]
current_price: float
class AIAnalysisResponse(BaseModel):
recommendation: Recommendation
confidence: float = Field(..., ge=0, le=100)
reasoning: str
support_resistance: SupportResistance
risk_level: RiskLevel
class IndicatorData(BaseModel):
time: int
value: float
# News and Sentiment Schemas
class Sentiment(str, Enum):
POSITIVE = "POSITIVE"
NEGATIVE = "NEGATIVE"
NEUTRAL = "NEUTRAL"
class NewsArticle(BaseModel):
id: str
source: str
title: str
description: Optional[str] = None
url: str
published_at: datetime
sentiment: Sentiment
sentiment_score: float = Field(..., ge=-1, le=1)
impact_on_gold: str # HIGH, MEDIUM, LOW
relevance_score: float = Field(..., ge=0, le=1)
category: str # MONETARY_POLICY, GEOPOLITICS, ECONOMIC_DATA, etc.
class NewsFeedResponse(BaseModel):
articles: List[NewsArticle]
total_count: int
bullish_count: int
bearish_count: int
neutral_count: int
overall_sentiment: Sentiment
avg_sentiment_score: float
class EconomicEvent(BaseModel):
id: str
title: str
country: str
currency: str
event_date: datetime
importance: str # HIGH, MEDIUM, LOW
forecast: Optional[str] = None
previous: Optional[str] = None
actual: Optional[str] = None
impact_on_gold: str
class EconomicCalendarResponse(BaseModel):
events: List[EconomicEvent]
upcoming_high_impact: int
# Alert Schemas
class AlertType(str, Enum):
PRICE_SPIKE = "PRICE_SPIKE"
PRICE_DROP = "PRICE_DROP"
NEWS_BREAKING = "NEWS_BREAKING"
SUPPORT_BREACH = "SUPPORT_BREACH"
RESISTANCE_BREACH = "RESISTANCE_BREACH"
HIGH_VOLATILITY = "HIGH_VOLATILITY"
ECONOMIC_EVENT = "ECONOMIC_EVENT"
class AlertSeverity(str, Enum):
CRITICAL = "CRITICAL"
HIGH = "HIGH"
MEDIUM = "MEDIUM"
LOW = "LOW"
class Alert(BaseModel):
id: str
type: AlertType
severity: AlertSeverity
title: str
message: str
price: Optional[float] = None
change_percent: Optional[float] = None
timestamp: datetime
related_news: Optional[List[str]] = [] # URLs to related news
action_required: bool = False
class AlertsResponse(BaseModel):
alerts: List[Alert]
critical_count: int
unread_count: int
# News-Price Correlation
class NewsPriceCorrelation(BaseModel):
news_id: str
news_title: str
news_time: datetime
price_before: float
price_after: float
price_change: float
price_change_percent: float
time_delta_minutes: int
correlation_strength: str # STRONG, MODERATE, WEAK
class CorrelationAnalysisResponse(BaseModel):
correlations: List[NewsPriceCorrelation]
significant_events: int
avg_price_impact: float
+1
View File
@@ -0,0 +1 @@
# Services package
+270
View File
@@ -0,0 +1,270 @@
from typing import List, Dict, Optional
from datetime import datetime, timedelta
import uuid
from app.schemas.schemas import (
Alert,
AlertType,
AlertSeverity,
AlertsResponse,
PriceData,
NewsPriceCorrelation,
CorrelationAnalysisResponse,
)
from app.config import settings
class AlertService:
def __init__(self):
self.alerts: List[Alert] = []
self.price_history: List[PriceData] = []
self.last_price: Optional[float] = None
self.support_levels: List[float] = []
self.resistance_levels: List[float] = []
def set_support_resistance(self, support: List[float], resistance: List[float]):
"""Set support and resistance levels for breach detection"""
self.support_levels = support
self.resistance_levels = resistance
def add_price_data(self, price_data: PriceData):
"""Add new price data and check for alerts"""
self.price_history.append(price_data)
# Keep only last 1000 data points
if len(self.price_history) > 1000:
self.price_history = self.price_history[-1000:]
current_price = price_data.close
if self.last_price:
self._check_price_alerts(current_price, self.last_price)
self._check_volatility_alerts(price_data)
self._check_support_resistance_breach(current_price)
self.last_price = current_price
def _check_price_alerts(self, current_price: float, last_price: float):
"""Check for significant price movements"""
change_percent = ((current_price - last_price) / last_price) * 100
threshold = settings.PRICE_ALERT_THRESHOLD
if abs(change_percent) >= threshold:
if change_percent > 0:
alert_type = AlertType.PRICE_SPIKE
title = f"Gold Price Spike: +{change_percent:.2f}%"
severity = AlertSeverity.HIGH if change_percent > 2.0 else AlertSeverity.MEDIUM
else:
alert_type = AlertType.PRICE_DROP
title = f"Gold Price Drop: {change_percent:.2f}%"
severity = AlertSeverity.HIGH if change_percent < -2.0 else AlertSeverity.MEDIUM
alert = Alert(
id=str(uuid.uuid4()),
type=alert_type,
severity=severity,
title=title,
message=f"Gold price moved from ${last_price:.2f} to ${current_price:.2f} ({change_percent:+.2f}%)",
price=current_price,
change_percent=change_percent,
timestamp=datetime.now(),
action_required=severity == AlertSeverity.HIGH,
)
self.alerts.append(alert)
def _check_volatility_alerts(self, price_data: PriceData):
"""Check for high volatility conditions"""
if len(self.price_history) < 20:
return
# Calculate ATR-like volatility
recent_data = self.price_history[-20:]
ranges = [d.high - d.low for d in recent_data]
avg_range = sum(ranges) / len(ranges)
current_range = price_data.high - price_data.low
# Alert if current range is 2x average
if current_range > avg_range * 2:
alert = Alert(
id=str(uuid.uuid4()),
type=AlertType.HIGH_VOLATILITY,
severity=AlertSeverity.MEDIUM,
title="High Volatility Detected",
message=f"Current price range ${current_range:.2f} is significantly higher than average ${avg_range:.2f}",
price=price_data.close,
timestamp=datetime.now(),
)
self.alerts.append(alert)
def _check_support_resistance_breach(self, current_price: float):
"""Check if price breached support or resistance levels"""
if not self.last_price:
return
# Check resistance breach (upward)
for resistance in self.resistance_levels:
if self.last_price < resistance <= current_price:
alert = Alert(
id=str(uuid.uuid4()),
type=AlertType.RESISTANCE_BREACH,
severity=AlertSeverity.HIGH,
title=f"Resistance Breached: ${resistance:.2f}",
message=f"Gold price broke above resistance level of ${resistance:.2f}",
price=current_price,
timestamp=datetime.now(),
action_required=True,
)
self.alerts.append(alert)
# Check support breach (downward)
for support in self.support_levels:
if self.last_price > support >= current_price:
alert = Alert(
id=str(uuid.uuid4()),
type=AlertType.SUPPORT_BREACH,
severity=AlertSeverity.HIGH,
title=f"Support Breached: ${support:.2f}",
message=f"Gold price broke below support level of ${support:.2f}",
price=current_price,
timestamp=datetime.now(),
action_required=True,
)
self.alerts.append(alert)
def add_news_alert(self, news_title: str, impact: str, sentiment: str):
"""Add alert for breaking news"""
severity_map = {
"HIGH": AlertSeverity.CRITICAL,
"MEDIUM": AlertSeverity.HIGH,
"LOW": AlertSeverity.MEDIUM,
}
alert = Alert(
id=str(uuid.uuid4()),
type=AlertType.NEWS_BREAKING,
severity=severity_map.get(impact, AlertSeverity.MEDIUM),
title=f"Breaking: {news_title[:50]}...",
message=f"High-impact news detected: {news_title}",
timestamp=datetime.now(),
action_required=impact == "HIGH",
)
self.alerts.append(alert)
def add_economic_event_alert(self, event_title: str, importance: str):
"""Add alert for upcoming economic event"""
severity_map = {
"HIGH": AlertSeverity.HIGH,
"MEDIUM": AlertSeverity.MEDIUM,
"LOW": AlertSeverity.LOW,
}
alert = Alert(
id=str(uuid.uuid4()),
type=AlertType.ECONOMIC_EVENT,
severity=severity_map.get(importance, AlertSeverity.MEDIUM),
title=f"Upcoming: {event_title}",
message=f"Important economic event scheduled: {event_title}",
timestamp=datetime.now(),
action_required=importance == "HIGH",
)
self.alerts.append(alert)
def get_alerts(self, limit: int = 50) -> AlertsResponse:
"""Get recent alerts"""
# Sort by timestamp (newest first)
sorted_alerts = sorted(self.alerts, key=lambda x: x.timestamp, reverse=True)
# Limit results
recent_alerts = sorted_alerts[:limit]
# Count critical alerts
critical_count = sum(1 for a in recent_alerts if a.severity == AlertSeverity.CRITICAL)
# For MVP, all alerts are unread
unread_count = len(recent_alerts)
return AlertsResponse(
alerts=recent_alerts,
critical_count=critical_count,
unread_count=unread_count,
)
def clear_old_alerts(self, hours: int = 24):
"""Remove alerts older than specified hours"""
cutoff = datetime.now() - timedelta(hours=hours)
self.alerts = [a for a in self.alerts if a.timestamp > cutoff]
def analyze_news_price_correlation(
self,
news_articles: List,
price_data: List[PriceData],
) -> CorrelationAnalysisResponse:
"""Analyze correlation between news and price movements"""
correlations = []
for article in news_articles:
news_time = article.published_at
# Find price before and after news
price_before = None
price_after = None
for i, data in enumerate(price_data):
data_time = datetime.fromtimestamp(data.time)
# Price before news (within 1 hour before)
if data_time < news_time and (news_time - data_time).total_seconds() < 3600:
price_before = data.close
# Price after news (within 1 hour after)
if data_time > news_time and (data_time - news_time).total_seconds() < 3600:
if not price_after: # Take first price after
price_after = data.close
if price_before and price_after:
price_change = price_after - price_before
price_change_percent = (price_change / price_before) * 100
time_delta = 60 # Approximate minutes
# Determine correlation strength
if abs(price_change_percent) > 1.0:
strength = "STRONG"
elif abs(price_change_percent) > 0.5:
strength = "MODERATE"
else:
strength = "WEAK"
correlation = NewsPriceCorrelation(
news_id=article.id,
news_title=article.title,
news_time=news_time,
price_before=price_before,
price_after=price_after,
price_change=price_change,
price_change_percent=price_change_percent,
time_delta_minutes=time_delta,
correlation_strength=strength,
)
correlations.append(correlation)
# Calculate statistics
significant_events = sum(1 for c in correlations if c.correlation_strength in ["STRONG", "MODERATE"])
avg_impact = (
sum(abs(c.price_change_percent) for c in correlations) / len(correlations)
if correlations else 0.0
)
return CorrelationAnalysisResponse(
correlations=correlations[:20], # Limit to 20 most recent
significant_events=significant_events,
avg_price_impact=avg_impact,
)
# Global instance
alert_service = AlertService()
+133
View File
@@ -0,0 +1,133 @@
import httpx
from typing import List, Dict
from datetime import datetime
from app.config import settings
from app.schemas.schemas import PriceData
class AlphaVantageService:
def __init__(self):
self.base_url = settings.ALPHA_VANTAGE_BASE_URL
self.api_key = settings.ALPHA_VANTAGE_API_KEY
async def get_gold_daily_data(
self, output_size: str = "compact"
) -> List[PriceData]:
"""
Fetch daily gold price data from Alpha Vantage using GLD ETF
GLD tracks gold prices closely (1 share ≈ 0.1 oz of gold)
Args:
output_size: 'compact' (100 data points) or 'full' (20+ years)
Returns:
List of PriceData objects
"""
params = {
"function": "TIME_SERIES_DAILY",
"symbol": "GLD",
"outputsize": output_size,
"apikey": self.api_key,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(self.base_url, params=params)
response.raise_for_status()
data = response.json()
if "Time Series (Daily)" not in data:
raise ValueError(f"Invalid API response: {data}")
time_series = data["Time Series (Daily)"]
price_data = []
for date_str, values in time_series.items():
# Convert date to Unix timestamp
dt = datetime.strptime(date_str, "%Y-%m-%d")
timestamp = int(dt.timestamp())
price_data.append(
PriceData(
time=timestamp,
open=float(values["1. open"]),
high=float(values["2. high"]),
low=float(values["3. low"]),
close=float(values["4. close"]),
)
)
# Sort by time (oldest first)
price_data.sort(key=lambda x: x.time)
return price_data
async def get_gold_intraday_data(
self, interval: str = "15min", output_size: str = "compact"
) -> List[PriceData]:
"""
Fetch intraday gold price data using GLD ETF
Args:
interval: '1min', '5min', '15min', '30min', '60min'
output_size: 'compact' or 'full'
Returns:
List of PriceData objects
"""
params = {
"function": "TIME_SERIES_INTRADAY",
"symbol": "GLD",
"interval": interval,
"outputsize": output_size,
"apikey": self.api_key,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(self.base_url, params=params)
response.raise_for_status()
data = response.json()
time_series_key = f"Time Series ({interval})"
if time_series_key not in data:
raise ValueError(f"Invalid API response: {data}")
time_series = data[time_series_key]
price_data = []
for datetime_str, values in time_series.items():
dt = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S")
timestamp = int(dt.timestamp())
price_data.append(
PriceData(
time=timestamp,
open=float(values["1. open"]),
high=float(values["2. high"]),
low=float(values["3. low"]),
close=float(values["4. close"]),
)
)
price_data.sort(key=lambda x: x.time)
return price_data
async def get_current_gold_price(self) -> float:
"""Get current gold price using GLD ETF latest price"""
params = {
"function": "GLOBAL_QUOTE",
"symbol": "GLD",
"apikey": self.api_key,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(self.base_url, params=params)
response.raise_for_status()
data = response.json()
if "Global Quote" not in data:
raise ValueError(f"Invalid API response: {data}")
quote = data["Global Quote"]
return float(quote["05. price"])
alpha_vantage_service = AlphaVantageService()
@@ -0,0 +1,37 @@
from __future__ import annotations
import httpx
from typing import List, Literal, Dict, Any
BINANCE_REST = "https://api.binance.com/api/v3/klines"
Interval = Literal["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d"]
async def fetch_klines(symbol: str, interval: Interval, limit: int = 500) -> List[Dict[str, Any]]:
"""
Fetch OHLCV klines from Binance REST. Returns list of dicts with fields:
time, open, high, low, close, volume
"""
params = {"symbol": symbol.upper().replace("/", ""), "interval": interval, "limit": min(max(limit, 1), 1000)}
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(BINANCE_REST, params=params)
r.raise_for_status()
data = r.json()
out: List[Dict[str, Any]] = []
for row in data:
# Binance format
# [ openTime, open, high, low, close, volume, closeTime, ... ]
out.append(
{
"time": int(row[0] // 1000),
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
"volume": float(row[5]),
}
)
# Ensure ascending by time
out.sort(key=lambda x: x["time"])
return out
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
from typing import Any, Dict, List
from datetime import datetime, timezone
import threading
class DecisionStore:
def __init__(self) -> None:
self._lock = threading.Lock()
self._items: List[Dict[str, Any]] = []
def add(self, item: Dict[str, Any]) -> None:
with self._lock:
self._items.append(item)
if len(self._items) > 1000:
# keep last 1000
self._items = self._items[-1000:]
def latest(self, limit: int = 50) -> List[Dict[str, Any]]:
with self._lock:
return list(reversed(self._items[-limit:]))
# singleton store
store = DecisionStore()
def log_decision(
*,
symbol: str,
timeframe: str,
style: str,
recommendation: str,
confidence: float,
risk_level: str,
rationale: str,
inputs_hash: str | None = None,
cost: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
now = datetime.now(timezone.utc).isoformat()
item = {
"id": f"dec_{int(datetime.now(timezone.utc).timestamp()*1000)}",
"time": now,
"symbol": symbol,
"timeframe": timeframe,
"style": style,
"recommendation": recommendation,
"confidence": confidence,
"risk_level": risk_level,
"rationale": rationale,
"inputs_hash": inputs_hash,
"cost": cost or {},
}
store.add(item)
return item
+142
View File
@@ -0,0 +1,142 @@
import httpx
from typing import List, Dict
from datetime import datetime, timedelta
from app.schemas.schemas import PriceData
class GoldAPIService:
"""
Multi-source gold price service using free APIs:
- FXRatesAPI for historical XAU/USD data (no API key needed)
- GoldPrice.org for real-time spot prices
"""
def __init__(self):
self.fxrates_base_url = "https://api.fxratesapi.com"
self.goldprice_url = "https://data-asg.goldprice.org/dbXRates/USD"
async def get_gold_daily_data(
self, output_size: str = "compact"
) -> List[PriceData]:
"""
Fetch daily gold (XAU/USD) price data from FXRatesAPI
Args:
output_size: 'compact' (~100 days) or 'full' (~1 year)
Returns:
List of PriceData objects with actual XAU/USD prices
"""
# Calculate date range
end_date = datetime.now()
if output_size == "full":
start_date = end_date - timedelta(days=365)
else:
start_date = end_date - timedelta(days=100)
params = {
"start_date": start_date.strftime("%Y-%m-%d"),
"end_date": end_date.strftime("%Y-%m-%d"),
"base": "XAU",
"currencies": "USD",
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{self.fxrates_base_url}/timeseries", params=params
)
response.raise_for_status()
data = response.json()
if not data.get("success") or "rates" not in data:
raise ValueError(f"Invalid API response: {data}")
rates = data["rates"]
price_data = []
for date_str, rate_data in rates.items():
# Parse the ISO timestamp
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
timestamp = int(dt.timestamp())
# FXRatesAPI gives us XAU price in USD (1 oz gold = X USD)
price = rate_data["USD"]
# Since we don't have OHLC from this API, we'll use the close price
# for all values (this is a limitation of free APIs)
price_data.append(
PriceData(
time=timestamp,
open=price,
high=price * 1.002, # Add small variance for visual effect
low=price * 0.998,
close=price,
)
)
# Sort by time (oldest first)
price_data.sort(key=lambda x: x.time)
return price_data
async def get_gold_intraday_data(
self, interval: str = "15min", output_size: str = "compact"
) -> List[PriceData]:
"""
Fallback to daily data for intraday (free APIs don't provide intraday)
Or fetch current price and simulate recent data points
"""
# For free tier, we'll return simulated intraday data based on current price
current_price = await self.get_current_gold_price()
price_data = []
now = datetime.now()
# Generate last 24 hours of data points
intervals = {
"1min": 60,
"5min": 5 * 60,
"15min": 15 * 60,
"30min": 30 * 60,
"60min": 60 * 60,
}
interval_seconds = intervals.get(interval, 15 * 60)
points = 100 if output_size == "compact" else 500
for i in range(points):
timestamp = int((now - timedelta(seconds=interval_seconds * i)).timestamp())
# Add small random variance (±0.5%)
variance = 1.0 + ((i % 10 - 5) * 0.001)
price = current_price * variance
price_data.append(
PriceData(
time=timestamp,
open=price,
high=price * 1.001,
low=price * 0.999,
close=price,
)
)
price_data.sort(key=lambda x: x.time)
return price_data
async def get_current_gold_price(self) -> float:
"""Get current spot gold price from FXRatesAPI (free, no API key)"""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{self.fxrates_base_url}/latest",
params={"base": "XAU", "currencies": "USD"}
)
response.raise_for_status()
data = response.json()
if not data.get("success") or "rates" not in data:
raise ValueError(f"Invalid API response: {data}")
# Get current XAU/USD price
return float(data["rates"]["USD"])
gold_api_service = GoldAPIService()
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from typing import List, Dict, Any
import httpx
from app.config import settings
ALPHA_BASE = "https://www.alphavantage.co/query"
async def fetch_fx_intraday(symbol: str = "XAUUSD", interval: str = "1min") -> List[Dict[str, Any]]:
from_symbol = symbol[:3].upper()
to_symbol = symbol[3:].upper()
params = {
"function": "FX_INTRADAY",
"from_symbol": from_symbol,
"to_symbol": to_symbol,
"interval": interval,
"outputsize": "compact",
"apikey": settings.ALPHA_VANTAGE_API_KEY or "demo",
}
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(ALPHA_BASE, params=params)
r.raise_for_status()
js = r.json()
key = f"Time Series FX ({interval})"
series = js.get(key) or {}
out: List[Dict[str, Any]] = []
# Alpha returns in reverse chronological; convert to ascending
for ts, row in reversed(list(series.items())):
# ts like '2024-11-01 10:05:00'
# Convert to seconds
# We avoid datetime parsing heavy ops; split string
date_part, time_part = ts.split(" ")
y, m, d = map(int, date_part.split("-"))
hh, mm, ss = map(int, time_part.split(":"))
import calendar, datetime as dt
seconds = int(calendar.timegm(dt.datetime(y, m, d, hh, mm, ss).timetuple()))
out.append(
{
"time": seconds,
"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)),
}
)
return out
async def fetch_fx_daily(symbol: str = "XAUUSD") -> List[Dict[str, Any]]:
from_symbol = symbol[:3].upper()
to_symbol = symbol[3:].upper()
params = {
"function": "FX_DAILY",
"from_symbol": from_symbol,
"to_symbol": to_symbol,
"outputsize": "compact",
"apikey": settings.ALPHA_VANTAGE_API_KEY or "demo",
}
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(ALPHA_BASE, params=params)
r.raise_for_status()
js = r.json()
key = "Time Series FX (Daily)"
series = js.get(key) or {}
out: List[Dict[str, Any]] = []
for ts, row in reversed(list(series.items())):
# ts like '2024-11-01'
import calendar, datetime as dt
y, m, d = map(int, ts.split("-"))
seconds = int(calendar.timegm(dt.datetime(y, m, d, 0, 0, 0).timetuple()))
out.append(
{
"time": seconds,
"open": float(row["1. open"]),
"high": float(row["2. high"]),
"low": float(row["3. low"]),
"close": float(row["4. close"]),
"volume": 0.0,
}
)
return out
+320
View File
@@ -0,0 +1,320 @@
import httpx
from typing import List, Dict
from datetime import datetime, timedelta
from textblob import TextBlob
import hashlib
from app.config import settings
from app.schemas.schemas import (
NewsArticle,
NewsFeedResponse,
Sentiment,
EconomicEvent,
EconomicCalendarResponse,
)
class NewsService:
def __init__(self):
self.alpha_vantage_key = settings.ALPHA_VANTAGE_API_KEY
self.finnhub_key = settings.FINNHUB_API_KEY
self.news_api_key = settings.NEWS_API_KEY
# Gold-related keywords for relevance scoring
self.gold_keywords = {
"high_relevance": [
"gold", "xau", "precious metals", "bullion", "gold price",
"gold market", "gold trading", "gold miners", "gold etf"
],
"medium_relevance": [
"federal reserve", "fed", "inflation", "interest rates",
"dollar", "usd", "monetary policy", "central bank",
"jerome powell", "treasury", "bonds"
],
"context_relevance": [
"geopolitics", "war", "sanctions", "recession",
"crisis", "safe haven", "risk off", "uncertainty"
]
}
# Impact categories
self.impact_categories = {
"MONETARY_POLICY": ["federal reserve", "fed", "interest rate", "monetary policy", "central bank"],
"GEOPOLITICS": ["war", "conflict", "sanctions", "tension", "geopolitical"],
"ECONOMIC_DATA": ["inflation", "cpi", "gdp", "employment", "jobs", "unemployment"],
"MARKET_SENTIMENT": ["risk", "sentiment", "volatility", "safe haven"],
"COMMODITY": ["gold", "precious metals", "bullion", "commodities"],
}
def _calculate_relevance_score(self, text: str) -> float:
"""Calculate how relevant a news article is to gold trading"""
text_lower = text.lower()
score = 0.0
# High relevance keywords
for keyword in self.gold_keywords["high_relevance"]:
if keyword in text_lower:
score += 0.4
# Medium relevance keywords
for keyword in self.gold_keywords["medium_relevance"]:
if keyword in text_lower:
score += 0.2
# Context relevance keywords
for keyword in self.gold_keywords["context_relevance"]:
if keyword in text_lower:
score += 0.1
return min(score, 1.0)
def _categorize_news(self, text: str) -> str:
"""Categorize news based on content"""
text_lower = text.lower()
for category, keywords in self.impact_categories.items():
for keyword in keywords:
if keyword in text_lower:
return category
return "OTHER"
def _analyze_sentiment(self, text: str) -> tuple[Sentiment, float]:
"""Analyze sentiment using TextBlob"""
try:
analysis = TextBlob(text)
polarity = analysis.sentiment.polarity
if polarity > 0.1:
sentiment = Sentiment.POSITIVE
elif polarity < -0.1:
sentiment = Sentiment.NEGATIVE
else:
sentiment = Sentiment.NEUTRAL
return sentiment, polarity
except Exception:
return Sentiment.NEUTRAL, 0.0
def _assess_gold_impact(self, sentiment: Sentiment, category: str, relevance: float) -> str:
"""Assess impact level on gold prices"""
# High impact categories
high_impact_cats = ["MONETARY_POLICY", "ECONOMIC_DATA"]
if relevance > 0.7:
if category in high_impact_cats:
return "HIGH"
return "MEDIUM"
elif relevance > 0.4:
return "MEDIUM"
else:
return "LOW"
async def fetch_alpha_vantage_news(self, topics: str = "economy_monetary,finance") -> List[NewsArticle]:
"""Fetch news from Alpha Vantage News Sentiment API"""
try:
params = {
"function": "NEWS_SENTIMENT",
"topics": topics,
"limit": 50,
"apikey": self.alpha_vantage_key,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
settings.ALPHA_VANTAGE_BASE_URL,
params=params
)
response.raise_for_status()
data = response.json()
if "feed" not in data:
return []
articles = []
for item in data["feed"]:
title = item.get("title", "")
summary = item.get("summary", "")
full_text = f"{title} {summary}"
relevance = self._calculate_relevance_score(full_text)
# Filter only gold-relevant news
if relevance < 0.3:
continue
sentiment, score = self._analyze_sentiment(full_text)
category = self._categorize_news(full_text)
impact = self._assess_gold_impact(sentiment, category, relevance)
# Parse published date
published_str = item.get("time_published", "")
try:
published_at = datetime.strptime(published_str, "%Y%m%dT%H%M%S")
except:
published_at = datetime.now()
article_id = hashlib.md5(f"{title}{published_str}".encode()).hexdigest()
articles.append(
NewsArticle(
id=article_id,
source=item.get("source", "Alpha Vantage"),
title=title,
description=summary,
url=item.get("url", ""),
published_at=published_at,
sentiment=sentiment,
sentiment_score=score,
impact_on_gold=impact,
relevance_score=relevance,
category=category,
)
)
return articles
except Exception as e:
print(f"Error fetching Alpha Vantage news: {e}")
return []
async def fetch_finnhub_news(self) -> List[NewsArticle]:
"""Fetch gold-related news from Finnhub"""
if not self.finnhub_key:
return []
try:
# Get general market news
params = {
"category": "forex",
"token": self.finnhub_key,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{settings.FINNHUB_BASE_URL}/news",
params=params
)
response.raise_for_status()
data = response.json()
articles = []
for item in data[:50]: # Limit to 50 articles
title = item.get("headline", "")
summary = item.get("summary", "")
full_text = f"{title} {summary}"
relevance = self._calculate_relevance_score(full_text)
# Filter only gold-relevant news
if relevance < 0.3:
continue
sentiment, score = self._analyze_sentiment(full_text)
category = self._categorize_news(full_text)
impact = self._assess_gold_impact(sentiment, category, relevance)
published_at = datetime.fromtimestamp(item.get("datetime", 0))
article_id = hashlib.md5(f"{title}{item.get('id', '')}".encode()).hexdigest()
articles.append(
NewsArticle(
id=article_id,
source=item.get("source", "Finnhub"),
title=title,
description=summary,
url=item.get("url", ""),
published_at=published_at,
sentiment=sentiment,
sentiment_score=score,
impact_on_gold=impact,
relevance_score=relevance,
category=category,
)
)
return articles
except Exception as e:
print(f"Error fetching Finnhub news: {e}")
return []
async def get_aggregated_news_feed(self) -> NewsFeedResponse:
"""Get aggregated news from all sources"""
# Fetch from multiple sources
alpha_news = await self.fetch_alpha_vantage_news()
finnhub_news = await self.fetch_finnhub_news() if self.finnhub_key else []
# Combine and deduplicate
all_articles = alpha_news + finnhub_news
# Remove duplicates based on similar titles
unique_articles = []
seen_titles = set()
for article in all_articles:
title_key = article.title.lower()[:50] # First 50 chars
if title_key not in seen_titles:
seen_titles.add(title_key)
unique_articles.append(article)
# Sort by published date (newest first)
unique_articles.sort(key=lambda x: x.published_at, reverse=True)
# Limit to most recent 50
unique_articles = unique_articles[:50]
# Calculate statistics
bullish_count = sum(1 for a in unique_articles if a.sentiment == Sentiment.POSITIVE)
bearish_count = sum(1 for a in unique_articles if a.sentiment == Sentiment.NEGATIVE)
neutral_count = sum(1 for a in unique_articles if a.sentiment == Sentiment.NEUTRAL)
avg_sentiment = (
sum(a.sentiment_score for a in unique_articles) / len(unique_articles)
if unique_articles else 0.0
)
# Determine overall sentiment
if avg_sentiment > 0.1:
overall_sentiment = Sentiment.POSITIVE
elif avg_sentiment < -0.1:
overall_sentiment = Sentiment.NEGATIVE
else:
overall_sentiment = Sentiment.NEUTRAL
return NewsFeedResponse(
articles=unique_articles,
total_count=len(unique_articles),
bullish_count=bullish_count,
bearish_count=bearish_count,
neutral_count=neutral_count,
overall_sentiment=overall_sentiment,
avg_sentiment_score=avg_sentiment,
)
async def get_economic_calendar(self) -> EconomicCalendarResponse:
"""Get upcoming economic events that impact gold"""
# This would integrate with economic calendar APIs
# For MVP, return curated list of upcoming events
# In production, integrate with:
# - Forex Factory API
# - Investing.com Economic Calendar
# - Alpha Vantage Economic Indicators
# For now, return empty with structure
events = []
# Count high-impact upcoming events
now = datetime.now()
upcoming_high_impact = sum(
1 for e in events
if e.importance == "HIGH" and e.event_date > now
)
return EconomicCalendarResponse(
events=events,
upcoming_high_impact=upcoming_high_impact,
)
news_service = NewsService()
+139
View File
@@ -0,0 +1,139 @@
import httpx
import json
from typing import List
from app.config import settings
from app.schemas.schemas import (
AIAnalysisRequest,
AIAnalysisResponse,
Recommendation,
RiskLevel,
SupportResistance,
)
class OpenRouterService:
def __init__(self):
self.base_url = settings.OPENROUTER_BASE_URL
self.api_key = settings.OPENROUTER_API_KEY
self.model = settings.OPENROUTER_MODEL
async def analyze_scenario(self, request: AIAnalysisRequest) -> AIAnalysisResponse:
"""
Analyze trading scenario using Claude 3.5 Sonnet via OpenRouter
Args:
request: AIAnalysisRequest with price data and indicators
Returns:
AIAnalysisResponse with recommendation and analysis
"""
# Prepare recent price data for analysis
recent_prices = request.price_data[-50:] if len(request.price_data) > 50 else request.price_data
# Format price data for the AI
price_summary = f"Current Price: ${request.current_price:.2f}\n"
price_summary += f"Recent Close Prices: {[f'${p.close:.2f}' for p in recent_prices[-10:]]}\n"
# Calculate basic statistics
prices = [p.close for p in recent_prices]
avg_price = sum(prices) / len(prices)
price_range = max(prices) - min(prices)
# Create analysis prompt
prompt = f"""You are a senior quantitative analyst specializing in gold (XAU/USD) trading. Analyze the following market data and provide a trading recommendation.
Market Data:
{price_summary}
Average Price (last 50 periods): ${avg_price:.2f}
Price Range: ${price_range:.2f}
Technical Indicators:
{json.dumps(request.indicators, indent=2)}
Based on this data, provide:
1. A clear recommendation: BUY, SELL, or HOLD
2. Confidence level (0-100%)
3. Detailed reasoning (2-3 sentences)
4. Support and resistance levels (up to 3 each)
5. Risk level assessment: LOW, MEDIUM, or HIGH
Respond in JSON format:
{{
"recommendation": "BUY|SELL|HOLD",
"confidence": 0-100,
"reasoning": "Your detailed analysis here",
"support_levels": [price1, price2, price3],
"resistance_levels": [price1, price2, price3],
"risk_level": "LOW|MEDIUM|HIGH"
}}
"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"HTTP-Referer": settings.OPENROUTER_SITE_URL,
"X-Title": settings.OPENROUTER_SITE_NAME,
}
payload = {
"model": self.model,
"messages": [
{
"role": "system",
"content": "You are a professional gold trading analyst. Always respond with valid JSON.",
},
{"role": "user", "content": prompt},
],
"temperature": 0.7,
"max_tokens": 1000,
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=payload,
)
response.raise_for_status()
data = response.json()
# Extract AI response
ai_content = data["choices"][0]["message"]["content"]
# Parse JSON response
try:
# Try to extract JSON from markdown code blocks if present
if "```json" in ai_content:
json_start = ai_content.find("```json") + 7
json_end = ai_content.find("```", json_start)
ai_content = ai_content[json_start:json_end].strip()
elif "```" in ai_content:
json_start = ai_content.find("```") + 3
json_end = ai_content.find("```", json_start)
ai_content = ai_content[json_start:json_end].strip()
analysis_data = json.loads(ai_content)
except json.JSONDecodeError:
# Fallback to default response if JSON parsing fails
return AIAnalysisResponse(
recommendation=Recommendation.HOLD,
confidence=50.0,
reasoning="Unable to parse AI response. Please try again.",
support_resistance=SupportResistance(support=[], resistance=[]),
risk_level=RiskLevel.MEDIUM,
)
# Map to response schema
return AIAnalysisResponse(
recommendation=Recommendation(analysis_data.get("recommendation", "HOLD")),
confidence=float(analysis_data.get("confidence", 50)),
reasoning=analysis_data.get("reasoning", "Analysis completed."),
support_resistance=SupportResistance(
support=analysis_data.get("support_levels", []),
resistance=analysis_data.get("resistance_levels", []),
),
risk_level=RiskLevel(analysis_data.get("risk_level", "MEDIUM")),
)
openrouter_service = OpenRouterService()
+198
View File
@@ -0,0 +1,198 @@
import random
import time
from datetime import datetime, timedelta
from typing import List, Optional
from app.schemas.schemas import PriceData
class GoldPriceSimulator:
"""
Simulates realistic gold price movements without external API calls.
Uses Geometric Brownian Motion for realistic price action.
"""
def __init__(self, initial_price: float = 2650.0):
"""
Initialize the simulator with a starting price.
Args:
initial_price: Starting gold price in USD per oz (default ~current market price)
"""
self.base_price = initial_price
self.current_price = initial_price
self.volatility = 0.0008 # Daily volatility (0.08%)
self.drift = 0.00001 # Slight upward drift
self.last_update = time.time()
# For trend simulation
self.trend_direction = 1 # 1 for up, -1 for down
self.trend_strength = 0.0001
self.trend_duration = 0
self.max_trend_duration = 100 # Max ticks before trend change
def _calculate_price_change(self) -> float:
"""Calculate the next price change using Geometric Brownian Motion."""
# Random walk component
random_shock = random.gauss(0, 1) * self.volatility
# Trend component (changes periodically)
self.trend_duration += 1
if self.trend_duration > self.max_trend_duration:
# Change trend direction
self.trend_direction = random.choice([1, -1])
self.trend_strength = random.uniform(0.00005, 0.0002)
self.trend_duration = 0
self.max_trend_duration = random.randint(50, 200)
trend_component = self.trend_direction * self.trend_strength
# Mean reversion (pulls price back toward base)
mean_reversion = (self.base_price - self.current_price) * 0.00001
# Combine components
total_change = self.drift + random_shock + trend_component + mean_reversion
return self.current_price * total_change
def get_current_price(self) -> float:
"""Get the current simulated gold price."""
# Update price based on time elapsed
current_time = time.time()
time_elapsed = current_time - self.last_update
# Update price (simulating continuous price movement)
if time_elapsed > 0:
# Multiple small updates for smoother price action
updates = max(1, int(time_elapsed))
for _ in range(min(updates, 10)): # Cap at 10 updates to avoid huge jumps
price_change = self._calculate_price_change()
self.current_price += price_change
# Keep price within reasonable bounds (±20% from base)
self.current_price = max(
self.base_price * 0.8,
min(self.base_price * 1.2, self.current_price)
)
self.last_update = current_time
return round(self.current_price, 2)
def get_live_candle(self, interval: str = "1min") -> PriceData:
"""
Generate a live price candle for the current interval.
Args:
interval: Time interval (1min, 5min, 15min, 30min, 60min)
Returns:
PriceData object with OHLC values
"""
current_price = self.get_current_price()
# Map intervals to seconds
interval_map = {
"1min": 60,
"5min": 5 * 60,
"15min": 15 * 60,
"30min": 30 * 60,
"60min": 60 * 60,
}
interval_seconds = interval_map.get(interval, 60)
current_time = int(time.time())
# Round up to next interval boundary to ensure newest timestamp
timestamp = ((current_time // interval_seconds) + 1) * interval_seconds
# Generate OHLC with small realistic variance
variance = current_price * 0.0005 # 0.05% variance
open_price = current_price + random.uniform(-variance, variance)
close_price = current_price + random.uniform(-variance, variance)
high_price = max(open_price, close_price) + random.uniform(0, variance)
low_price = min(open_price, close_price) - random.uniform(0, variance)
return PriceData(
time=timestamp,
open=round(open_price, 2),
high=round(high_price, 2),
low=round(low_price, 2),
close=round(close_price, 2),
)
def generate_historical_data(
self,
interval: str = "daily",
points: int = 100
) -> List[PriceData]:
"""
Generate historical price data using the simulator.
Args:
interval: Time interval (daily, 1min, 5min, etc.)
points: Number of data points to generate
Returns:
List of PriceData objects in chronological order
"""
# Map intervals to seconds
interval_map = {
"daily": 24 * 60 * 60,
"1min": 60,
"5min": 5 * 60,
"15min": 15 * 60,
"30min": 30 * 60,
"60min": 60 * 60,
}
interval_seconds = interval_map.get(interval, 24 * 60 * 60)
# Start from past and work forward
end_time = int(time.time())
start_time = end_time - (interval_seconds * points)
price_data = []
current_sim_price = self.base_price
for i in range(points):
timestamp = start_time + (interval_seconds * i)
# Simulate price evolution
price_change = random.gauss(0, 1) * self.volatility * current_sim_price
trend = random.uniform(-0.0001, 0.0001) * current_sim_price
current_sim_price += price_change + trend
# Keep within bounds
current_sim_price = max(
self.base_price * 0.85,
min(self.base_price * 1.15, current_sim_price)
)
# Generate OHLC for this candle
candle_variance = current_sim_price * 0.002 # 0.2% intra-candle variance
open_price = current_sim_price + random.uniform(-candle_variance/2, candle_variance/2)
close_price = current_sim_price + random.uniform(-candle_variance/2, candle_variance/2)
high_price = max(open_price, close_price) + random.uniform(0, candle_variance)
low_price = min(open_price, close_price) - random.uniform(0, candle_variance)
price_data.append(
PriceData(
time=timestamp,
open=round(open_price, 2),
high=round(high_price, 2),
low=round(low_price, 2),
close=round(close_price, 2),
)
)
# Set current price to the last closing price for continuity
if price_data:
self.current_price = price_data[-1].close
self.last_update = time.time()
return price_data
# Global simulator instance (maintains state across requests)
gold_simulator = GoldPriceSimulator(initial_price=2650.0)
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any, Dict, List
_TEMPLATES: Dict[str, Dict[str, Any]] = {
"analysis_default": {
"name": "analysis_default",
"description": "General market analysis prompt with technicals and news context",
"variables": ["symbol", "timeframe", "recent_news", "technicals"],
"body": (
"You are a trading assistant. Analyze {{symbol}} on {{timeframe}} timeframe.\n"
"Consider technical signals: {{technicals}} and relevant news: {{recent_news}}.\n"
"Provide a concise recommendation (BUY/SELL/HOLD) with reasoning and risk notes."
),
},
"risk_control_default": {
"name": "risk_control_default",
"description": "Risk control instructions for planning",
"variables": ["max_position_fraction", "min_rr_ratio"],
"body": (
"Adhere to risk rules: position <= {{max_position_fraction}} of equity,"
" risk-reward ratio >= {{min_rr_ratio}} whenever applicable."
),
},
}
def list_templates() -> List[Dict[str, Any]]:
return [
{"name": t["name"], "description": t["description"], "variables": t["variables"]}
for t in _TEMPLATES.values()
]
def get_template(name: str) -> Dict[str, Any]:
if name not in _TEMPLATES:
raise KeyError("Template not found")
return _TEMPLATES[name]
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from typing import Any, Dict
# Simple risk rules for MVP
MAX_POSITION_FRACTION = 0.6 # max 60% of equity in a single position
def _equity(sim_state: Dict[str, Any], price: float) -> float:
cash = float(sim_state.get("cash", 0.0))
pos = sim_state.get("position")
qty = float(pos["quantity"]) if pos else 0.0
return cash + qty * price
def validate_order(sim_state: Dict[str, Any], action: str, quantity: float, price: float) -> None:
action = str(action).upper()
if quantity <= 0 or price <= 0:
raise ValueError("Quantity and price must be positive")
if action == "BUY":
# Anti-stacking: only one symbol supported in MVP, allow averaging up to cap
pos = sim_state.get("position")
current_qty = float(pos["quantity"]) if pos else 0.0
new_qty = current_qty + float(quantity)
resulting_position_value = new_qty * float(price)
eq_now = _equity(sim_state, price)
if eq_now <= 0:
raise ValueError("Equity must be positive")
if resulting_position_value > MAX_POSITION_FRACTION * eq_now:
raise ValueError("Position exceeds max allowed exposure fraction")
elif action == "SELL":
pos = sim_state.get("position")
if not pos or float(quantity) > float(pos.get("quantity", 0.0)):
raise ValueError("Insufficient position to sell")
else:
raise ValueError("Unsupported action")
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Any, Dict
from app.config import settings
_state: Dict[str, Any] = {
"models": {
"default_model": settings.OPENROUTER_MODEL,
"temperature": 0.3,
"max_tokens": 800,
},
"exchanges": {
"binance": {"enabled": True},
"alpha_vantage": {
"enabled": True,
"has_api_key": bool(settings.ALPHA_VANTAGE_API_KEY),
},
},
}
def get_models() -> Dict[str, Any]:
return dict(_state["models"]) # shallow copy
def update_models(patch: Dict[str, Any]) -> Dict[str, Any]:
allowed = {"default_model", "temperature", "max_tokens"}
for k, v in patch.items():
if k in allowed:
_state["models"][k] = v
return get_models()
def get_exchanges() -> Dict[str, Any]:
return dict(_state["exchanges"]) # shallow copy
def update_exchanges(patch: Dict[str, Any]) -> Dict[str, Any]:
# Shallow merge per top-level key
for k, v in patch.items():
if k in _state["exchanges"] and isinstance(v, dict):
_state["exchanges"][k].update(v)
return get_exchanges()
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)
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import time
from typing import Any, Dict, Tuple, Optional
class TTLCache:
"""Simple in-memory TTL cache (per-process). Not thread-safe, but adequate for single-UVicorn worker.
Keys are arbitrary hashables. Values are any JSON-serializable structures.
"""
def __init__(self, default_ttl: int = 60, maxsize: int = 256) -> None:
self.default_ttl = default_ttl
self.maxsize = maxsize
self._data: Dict[Any, Tuple[float, Any]] = {}
def _now(self) -> float:
return time.time()
def get(self, key: Any) -> Optional[Any]:
item = self._data.get(key)
if not item:
return None
expires_at, value = item
if expires_at < self._now():
# expired
self._data.pop(key, None)
return None
return value
def set(self, key: Any, value: Any, ttl: Optional[int] = None) -> None:
if len(self._data) >= self.maxsize:
# naive eviction: remove oldest item
try:
oldest_key = min(self._data.items(), key=lambda kv: kv[1][0])[0]
self._data.pop(oldest_key, None)
except ValueError:
self._data.clear()
expires = self._now() + (ttl if ttl is not None else self.default_ttl)
self._data[key] = (expires, value)
def purge(self) -> None:
now = self._now()
for k, (exp, _) in list(self._data.items()):
if exp < now:
self._data.pop(k, None)
+19
View File
@@ -0,0 +1,19 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.0
pydantic-settings==2.1.0
sqlalchemy==2.0.25
psycopg2-binary==2.9.9
alembic==1.13.1
python-dotenv==1.0.0
httpx==0.26.0
pandas==2.1.4
numpy==1.26.3
python-multipart==0.0.6
aiohttp==3.9.1
websockets==12.0
orjson==3.10.7
apscheduler==3.10.4
backoff==2.2.1
tenacity==8.2.3
pyarrow==15.0.0
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Gold Trading Simulator - Backend Startup Script
# Uses simulated price feed - NO external API keys required!
cd "$(dirname "$0")"
echo "Starting Gold Trading Simulator Backend..."
echo "✓ Using simulated live price feed (no API calls)"
echo "✓ Listening on http://localhost:8000"
echo ""
# Activate virtual environment and start server
source ../.venv/bin/activate
PYTHONPATH=$(pwd) python -m uvicorn app.main:app --reload --port 8000