Initial commit: Gold Trading Simulator with AI-powered analysis
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Gold Trading Simulator Backend
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1 @@
|
||||
# API routes package
|
||||
@@ -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"]))
|
||||
}
|
||||
]
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
# Database package
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Models package
|
||||
from .models import Simulation, Trade, Position, AIAnalysisLog
|
||||
|
||||
__all__ = ["Simulation", "Trade", "Position", "AIAnalysisLog"]
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
# Schemas package
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user