Initial commit: Gold Trading Simulator with AI-powered analysis
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user