446 lines
17 KiB
Python
446 lines
17 KiB
Python
from pydantic import BaseModel
|
|
from typing import Optional
|
|
"""
|
|
Web Dashboard — FastAPI Application
|
|
|
|
Real-time trading dashboard with WebSocket support.
|
|
Serves the monitoring UI and provides REST/WS API endpoints.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from loguru import logger
|
|
|
|
from config.settings import get_settings
|
|
from database import crud
|
|
|
|
# ── App Setup ────────────────────────────────────────────────────────────
|
|
app = FastAPI(
|
|
title="XAUUSD AI Trading Bot",
|
|
description="Real-time trading dashboard",
|
|
version="1.0.0",
|
|
)
|
|
|
|
# Protect against Cross-Origin Request Forgery (CORS restriction)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[
|
|
"http://localhost:8080",
|
|
"http://127.0.0.1:8080",
|
|
],
|
|
allow_credentials=True,
|
|
allow_methods=["GET", "POST"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
STATIC_DIR = Path(__file__).parent / "static"
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
# Connected WebSocket clients
|
|
connected_clients: set[WebSocket] = set()
|
|
|
|
|
|
# ── Routes ───────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/")
|
|
async def index():
|
|
"""Serve the dashboard HTML."""
|
|
return FileResponse(str(STATIC_DIR / "index.html"))
|
|
|
|
|
|
@app.get("/api/account")
|
|
async def get_account():
|
|
"""Get current account info (fast non-blocking with credential masking)."""
|
|
try:
|
|
from core.mt5_connector import MT5Connector
|
|
mt5 = MT5Connector()
|
|
if mt5.connect(max_retries=1, retry_delay=0.1):
|
|
info = mt5.get_account_info(auto_reconnect=False)
|
|
if info:
|
|
sanitized_info = dict(info)
|
|
login_str = str(sanitized_info.get("login", ""))
|
|
sanitized_info["login"] = f"***{login_str[-4:]}" if len(login_str) >= 4 else "***"
|
|
sanitized_info["connected"] = True
|
|
return JSONResponse(sanitized_info)
|
|
return JSONResponse({"connected": False, "status": "MT5 Terminal Offline"})
|
|
except Exception as e:
|
|
return JSONResponse({"connected": False, "error": str(e)})
|
|
|
|
|
|
@app.get("/api/positions")
|
|
async def get_positions():
|
|
"""Get open positions (fast non-blocking)."""
|
|
try:
|
|
from core.mt5_connector import MT5Connector
|
|
mt5 = MT5Connector()
|
|
if mt5.connect(max_retries=1, retry_delay=0.1):
|
|
positions = mt5.get_open_positions(auto_reconnect=False)
|
|
for p in positions:
|
|
if "time" in p and hasattr(p["time"], "isoformat"):
|
|
p["time"] = p["time"].isoformat()
|
|
return JSONResponse(positions)
|
|
return JSONResponse([])
|
|
except Exception as e:
|
|
return JSONResponse({"error": str(e)})
|
|
|
|
|
|
@app.get("/api/trades")
|
|
async def get_trades():
|
|
"""Get recent trade history."""
|
|
trades = crud.get_recent_trades(limit=50)
|
|
return JSONResponse([
|
|
{
|
|
"ticket": t.ticket,
|
|
"type": t.order_type,
|
|
"strategy": t.strategy,
|
|
"volume": t.volume,
|
|
"entry": t.entry_price,
|
|
"exit": t.exit_price,
|
|
"sl": t.stop_loss,
|
|
"tp": t.take_profit,
|
|
"profit": t.profit,
|
|
"status": t.status,
|
|
"confluence": t.confluence_score,
|
|
"opened_at": t.opened_at.isoformat() if t.opened_at else None,
|
|
"closed_at": t.closed_at.isoformat() if t.closed_at else None,
|
|
}
|
|
for t in trades
|
|
])
|
|
|
|
|
|
@app.get("/api/stats")
|
|
async def get_stats():
|
|
"""Get trading statistics."""
|
|
stats = crud.get_trade_stats(days=30)
|
|
return JSONResponse(stats)
|
|
|
|
|
|
@app.get("/api/equity")
|
|
async def get_equity_curve():
|
|
"""Get equity curve data."""
|
|
data = crud.get_equity_curve(days=30)
|
|
return JSONResponse(data)
|
|
|
|
|
|
|
|
@app.get("/api/learning")
|
|
async def get_self_learning_metrics():
|
|
"""Get autonomous self-learning metrics, adaptive weights, and lessons."""
|
|
try:
|
|
from ai.trade_learner import trade_learner
|
|
metrics = trade_learner.get_learning_metrics()
|
|
return JSONResponse(metrics)
|
|
except Exception as e:
|
|
logger.error(f"Error fetching learning metrics: {e}")
|
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
|
|
@app.get("/api/news")
|
|
async def get_recent_news():
|
|
"""Get recent news with sentiment."""
|
|
from database.models import NewsEvent, get_session
|
|
from sqlalchemy import desc
|
|
|
|
session = get_session()
|
|
try:
|
|
events = (
|
|
session.query(NewsEvent)
|
|
.order_by(desc(NewsEvent.fetched_at))
|
|
.limit(20)
|
|
.all()
|
|
)
|
|
return JSONResponse([
|
|
{
|
|
"headline": e.headline,
|
|
"source": e.source,
|
|
"sentiment": e.sentiment,
|
|
"score": e.sentiment_score,
|
|
"impact": e.impact_level,
|
|
"analysis": e.gemini_analysis,
|
|
"time": e.fetched_at.isoformat() if e.fetched_at else None,
|
|
}
|
|
for e in events
|
|
])
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
# ── WebSocket ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class OrderRequest(BaseModel):
|
|
action: str
|
|
volume: float
|
|
target_price: Optional[float] = None
|
|
sl_pips: Optional[float] = None
|
|
tp_pips: Optional[float] = None
|
|
|
|
|
|
@app.get("/api/chart")
|
|
async def get_chart_data(timeframe: str = "H1", count: int = 150):
|
|
"""Get real OHLCV candlestick data from MT5 for TradingView Lightweight Charts."""
|
|
try:
|
|
from core.mt5_connector import MT5Connector
|
|
mt5 = MT5Connector()
|
|
if mt5.connect(max_retries=1, retry_delay=0.1):
|
|
tf = timeframe.upper()
|
|
df = mt5.get_rates(timeframe=tf, count=min(count, 500), auto_reconnect=False)
|
|
if df is not None and not df.empty:
|
|
candles = []
|
|
for idx, row in df.iterrows():
|
|
ts = int(idx.timestamp()) if hasattr(idx, "timestamp") else int(idx)
|
|
candles.append({
|
|
"time": ts,
|
|
"open": round(float(row["open"]), 2),
|
|
"high": round(float(row["high"]), 2),
|
|
"low": round(float(row["low"]), 2),
|
|
"close": round(float(row["close"]), 2),
|
|
"volume": float(row.get("volume", 0)),
|
|
})
|
|
return JSONResponse(candles)
|
|
return JSONResponse([])
|
|
except Exception as e:
|
|
logger.error(f"Error fetching chart candles: {e}")
|
|
return JSONResponse([])
|
|
|
|
|
|
@app.get("/api/regime")
|
|
async def get_regime():
|
|
"""Get live market regime, ADX, and recommended strategies."""
|
|
try:
|
|
from core.mt5_connector import MT5Connector
|
|
from strategies.regime_detector import RegimeDetector
|
|
from analysis.technical import TechnicalAnalyzer
|
|
|
|
mt5 = MT5Connector()
|
|
if mt5.connect(max_retries=1, retry_delay=0.1):
|
|
df_h4 = mt5.get_rates(timeframe="H4", count=200, auto_reconnect=False)
|
|
if df_h4 is not None and not df_h4.empty:
|
|
ta = TechnicalAnalyzer()
|
|
df_analyzed = ta.add_all_indicators(df_h4.copy())
|
|
rd = RegimeDetector()
|
|
regime = rd.analyze(df_analyzed)
|
|
return JSONResponse({
|
|
"regime": regime.regime.value,
|
|
"adx": round(float(regime.adx_value), 1),
|
|
"volatility_percentile": round(float(regime.volatility_percentile), 1),
|
|
"volatility_label": regime.volatility_label,
|
|
"position_size_modifier": round(float(regime.position_size_modifier), 2),
|
|
"should_trade": bool(regime.should_trade),
|
|
"recommended_strategies": regime.recommended_strategies,
|
|
})
|
|
return JSONResponse({"regime": "UNKNOWN", "error": "MT5 Offline"})
|
|
except Exception as e:
|
|
logger.error(f"Error computing market regime: {e}")
|
|
return JSONResponse({"regime": "UNKNOWN", "error": str(e)})
|
|
|
|
|
|
@app.get("/api/sentiment")
|
|
async def get_sentiment_overview():
|
|
"""Get 24H composite sentiment score and status."""
|
|
score = crud.get_recent_sentiment(hours=24)
|
|
label = "BULLISH" if score > 0.15 else ("BEARISH" if score < -0.15 else "NEUTRAL")
|
|
return JSONResponse({
|
|
"score": round(score, 2),
|
|
"label": label,
|
|
"direction": "UP" if score > 0.15 else ("DOWN" if score < -0.15 else "FLAT")
|
|
})
|
|
|
|
|
|
@app.post("/api/close-position/{ticket}")
|
|
async def close_position(ticket: int):
|
|
"""Close a specific open position."""
|
|
try:
|
|
from core.mt5_connector import MT5Connector
|
|
mt5 = MT5Connector()
|
|
if mt5.connect(max_retries=1, retry_delay=0.1):
|
|
success = mt5.close_position(ticket)
|
|
return JSONResponse({"success": success, "ticket": ticket})
|
|
return JSONResponse({"success": False, "error": "MT5 offline"}, status_code=500)
|
|
except Exception as e:
|
|
logger.error(f"Error closing position {ticket}: {e}")
|
|
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
|
|
|
|
|
@app.post("/api/close-all")
|
|
async def close_all_positions():
|
|
"""Emergency close all open positions."""
|
|
try:
|
|
from core.mt5_connector import MT5Connector
|
|
mt5 = MT5Connector()
|
|
if mt5.connect(max_retries=1, retry_delay=0.1):
|
|
closed_count = mt5.close_all_positions()
|
|
return JSONResponse({"success": True, "closed_count": closed_count})
|
|
return JSONResponse({"success": False, "error": "MT5 offline"}, status_code=500)
|
|
except Exception as e:
|
|
logger.error(f"Error closing all positions: {e}")
|
|
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
|
|
|
|
|
@app.get("/api/pending")
|
|
async def get_pending_orders():
|
|
"""Get active scheduled pending orders from MT5."""
|
|
try:
|
|
from core.mt5_connector import MT5Connector
|
|
mt5 = MT5Connector()
|
|
if mt5.connect(max_retries=1, retry_delay=0.1):
|
|
pending = mt5.get_pending_orders(auto_reconnect=False)
|
|
for p in pending:
|
|
if "time" in p and hasattr(p["time"], "isoformat"):
|
|
p["time"] = p["time"].isoformat()
|
|
return JSONResponse(pending)
|
|
return JSONResponse([])
|
|
except Exception as e:
|
|
logger.error(f"Error fetching pending orders: {e}")
|
|
return JSONResponse([])
|
|
|
|
|
|
@app.post("/api/cancel-order/{ticket}")
|
|
async def cancel_pending_order(ticket: int):
|
|
"""Cancel a scheduled pending order by ticket."""
|
|
try:
|
|
from core.mt5_connector import MT5Connector
|
|
mt5 = MT5Connector()
|
|
if mt5.connect(max_retries=1, retry_delay=0.1):
|
|
success = mt5.cancel_order(ticket)
|
|
return JSONResponse({"success": success, "ticket": ticket})
|
|
return JSONResponse({"success": False, "error": "MT5 offline"}, status_code=500)
|
|
except Exception as e:
|
|
logger.error(f"Error canceling order {ticket}: {e}")
|
|
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
|
|
|
|
|
@app.post("/api/order")
|
|
async def place_manual_order(req: OrderRequest):
|
|
"""Place a manual market or scheduled pending order from the dashboard."""
|
|
try:
|
|
from core.mt5_connector import MT5Connector
|
|
mt5 = MT5Connector()
|
|
if mt5.connect(max_retries=1, retry_delay=0.1):
|
|
symbol = "XAUUSD"
|
|
tick = mt5.get_current_tick(symbol)
|
|
if not tick:
|
|
return JSONResponse({"success": False, "error": "Could not fetch live XAUUSD tick"}, status_code=400)
|
|
|
|
act = req.action.upper()
|
|
vol = max(0.01, round(req.volume, 2))
|
|
pip = 0.10
|
|
|
|
if act in ["BUY", "SELL"]:
|
|
price = tick["ask"] if act == "BUY" else tick["bid"]
|
|
sl = (price - req.sl_pips * pip) if (req.sl_pips and act == "BUY") else ((price + req.sl_pips * pip) if req.sl_pips else 0.0)
|
|
tp = (price + req.tp_pips * pip) if (req.tp_pips and act == "BUY") else ((price - req.tp_pips * pip) if req.tp_pips else 0.0)
|
|
res = mt5.send_market_order(
|
|
order_type=act,
|
|
symbol=symbol,
|
|
volume=vol,
|
|
sl=sl,
|
|
tp=tp,
|
|
comment="DASHBOARD_MANUAL"
|
|
)
|
|
if res and (res.get("deal", 0) > 0 or res.get("order", 0) > 0):
|
|
return JSONResponse({"success": True, "ticket": res.get("order"), "deal": res.get("deal"), "price": res.get("price", price)})
|
|
err = res.get("comment", "Order rejected by broker") if res else "Order dispatch failed"
|
|
return JSONResponse({"success": False, "error": err}, status_code=400)
|
|
|
|
elif act in ["BUY_LIMIT", "SELL_LIMIT", "BUY_STOP", "SELL_STOP"]:
|
|
target_p = req.target_price if (req.target_price and req.target_price > 0) else (tick["bid"] if "BUY" in act else tick["ask"])
|
|
sl = (target_p - req.sl_pips * pip) if (req.sl_pips and "BUY" in act) else ((target_p + req.sl_pips * pip) if req.sl_pips else 0.0)
|
|
tp = (target_p + req.tp_pips * pip) if (req.tp_pips and "BUY" in act) else ((target_p - req.tp_pips * pip) if req.tp_pips else 0.0)
|
|
res = mt5.send_pending_order(
|
|
order_type=act,
|
|
price=target_p,
|
|
symbol=symbol,
|
|
volume=vol,
|
|
sl=sl,
|
|
tp=tp,
|
|
comment="DASHBOARD_PENDING"
|
|
)
|
|
if res and res.get("order", 0) > 0:
|
|
return JSONResponse({"success": True, "ticket": res.get("order"), "price": target_p})
|
|
err = res.get("comment", "Pending order rejected by broker") if res else "Pending order failed"
|
|
return JSONResponse({"success": False, "error": err}, status_code=400)
|
|
|
|
return JSONResponse({"success": False, "error": f"Unsupported action {act}"}, status_code=400)
|
|
|
|
return JSONResponse({"success": False, "error": "MT5 offline"}, status_code=500)
|
|
except Exception as e:
|
|
logger.error(f"Manual order placement error: {e}")
|
|
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
|
|
|
|
|
@app.post("/api/news/refresh")
|
|
async def refresh_news_feed():
|
|
"""Trigger background news refresh."""
|
|
try:
|
|
from news.news_fetcher import NewsFetcher
|
|
from news.news_analyzer import NewsAnalyzer
|
|
|
|
fetcher = NewsFetcher()
|
|
analyzer = NewsAnalyzer()
|
|
articles = await fetcher.fetch_all_news()
|
|
|
|
count = 0
|
|
for article in articles[:5]:
|
|
analysis = await analyzer.analyze_article(article)
|
|
crud.save_news_event(
|
|
source=article.get("source", ""),
|
|
headline=article.get("headline", ""),
|
|
summary=article.get("summary", ""),
|
|
url=article.get("url", ""),
|
|
sentiment=analysis["sentiment"],
|
|
sentiment_score=analysis["combined_score"],
|
|
finbert_score=analysis["finbert"]["score"],
|
|
gemini_score=analysis.get("gemini", {}).get("score", 0),
|
|
gemini_analysis=analysis.get("gemini", {}).get("analysis", ""),
|
|
impact_level=analysis["impact_level"],
|
|
published_at=article.get("published_at"),
|
|
)
|
|
count += 1
|
|
return JSONResponse({"success": True, "articles_processed": count})
|
|
except Exception as e:
|
|
logger.error(f"News refresh error: {e}")
|
|
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
|
|
|
|
|
@app.websocket("/ws")
|
|
async def websocket_endpoint(websocket: WebSocket):
|
|
"""WebSocket endpoint for real-time data streaming."""
|
|
await websocket.accept()
|
|
connected_clients.add(websocket)
|
|
logger.info(f"WebSocket client connected ({len(connected_clients)} total)")
|
|
|
|
try:
|
|
while True:
|
|
# Keep connection alive — receive messages from client
|
|
data = await websocket.receive_text()
|
|
# Handle client commands if needed
|
|
if data == "ping":
|
|
await websocket.send_text(json.dumps({"type": "pong"}))
|
|
|
|
except WebSocketDisconnect:
|
|
connected_clients.discard(websocket)
|
|
logger.info(f"WebSocket client disconnected ({len(connected_clients)} total)")
|
|
|
|
|
|
async def broadcast(data: dict):
|
|
"""Broadcast data to all connected WebSocket clients."""
|
|
if not connected_clients:
|
|
return
|
|
|
|
message = json.dumps(data, default=str)
|
|
disconnected = set()
|
|
|
|
for client in connected_clients:
|
|
try:
|
|
await client.send_text(message)
|
|
except Exception:
|
|
disconnected.add(client)
|
|
|
|
connected_clients -= disconnected
|