Files
XauBot/web-dashboard/api/main.py
T
GifariKemal 85161b4965 fix: H1 bias calculate on first loop + add filter config infrastructure
**H1 Bias Fix:**
- Fixed cache check to ensure loop_count=1 always calculates H1 bias
- Changed exception log from DEBUG to WARNING for visibility
- Added log on first calculation (loop==1) in addition to every 4 loops
- Result: H1 bias now correctly calculated from first candle

**Filter Config Infrastructure (WIP):**
- Added FilterConfigManager (src/filter_config.py) for dynamic filter control
- Added data/filter_config.json with 11 entry filters (flash_crash, regime, risk, session, spread, h1_bias, ml_confidence, signal_combination, cooldown, time_filter, market_close)
- Added API endpoints: GET/POST /api/filters/config
- Note: Bot integration pending — requires wrapper around all filter checks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 08:09:01 +07:00

478 lines
16 KiB
Python

"""
FastAPI Backend for Web Dashboard (Docker-compatible)
=====================================================
Serves trading bot status data to the web frontend.
Reads from data/bot_status.json (written by main_live.py)
and from PostgreSQL database for trade history, signals, model data.
"""
import json
import logging
from pathlib import Path
from datetime import datetime
from zoneinfo import ZoneInfo
from typing import Optional
from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware
import db
logger = logging.getLogger(__name__)
app = FastAPI(title="Trading Bot API", version="2.0.0")
# CORS for frontend
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Status file path (mounted as volume in Docker)
STATUS_FILE = Path("/app/data/bot_status.json")
MODEL_METRICS_FILE = Path("/app/data/model_metrics.json")
FILTER_CONFIG_FILE = Path("/app/data/filter_config.json")
# Default empty response
DEFAULT_STATUS = {
"timestamp": "00:00:00",
"connected": False,
"price": 0.0,
"spread": 0.0,
"priceChange": 0.0,
"priceHistory": [],
"balance": 0.0,
"equity": 0.0,
"profit": 0.0,
"equityHistory": [],
"balanceHistory": [],
"session": "Unknown",
"isGoldenTime": False,
"canTrade": False,
"dailyLoss": 0.0,
"dailyProfit": 0.0,
"consecutiveLosses": 0,
"riskPercent": 0.0,
"smc": {"signal": "", "confidence": 0.0, "reason": ""},
"ml": {"signal": "", "confidence": 0.0, "buyProb": 0.0, "sellProb": 0.0},
"regime": {"name": "", "volatility": 0.0, "confidence": 0.0},
"positions": [],
"logs": [],
"entryFilters": [],
"riskMode": {"mode": "unknown", "reason": "", "recommendedLot": 0, "maxAllowedLot": 0, "totalLoss": 0, "maxTotalLoss": 0, "remainingDailyRisk": 0},
"cooldown": {"active": False, "secondsRemaining": 0, "totalSeconds": 150},
"timeFilter": {"wibHour": 0, "isBlocked": False, "blockedHours": [9, 21]},
"sessionMultiplier": 1.0,
"positionDetails": [],
"autoTrainer": {"lastRetrain": None, "currentAuc": None, "minAucThreshold": 0.65, "hoursSinceRetrain": 0, "nextRetrainHour": 5, "modelsFitted": False},
"performance": {"loopCount": 0, "avgExecutionMs": 0, "uptimeHours": 0, "totalSessionTrades": 0, "totalSessionProfit": 0},
"marketClose": {"hoursToDailyClose": 0, "hoursToWeekendClose": 0, "nearWeekend": False, "marketOpen": False},
"h1BiasDetails": {"bias": "NEUTRAL", "ema20": 0, "price": 0},
}
# ─── Startup / Shutdown ───
@app.on_event("startup")
async def startup():
try:
db.init_pool()
logger.info("DB pool ready")
except Exception as e:
logger.warning("DB not available: %s (trade history features disabled)", e)
@app.on_event("shutdown")
async def shutdown():
db.close_pool()
# ─── Status Endpoints ───
@app.get("/api/status")
async def get_status():
"""Get current trading status from bot's status file."""
for path in [STATUS_FILE, Path("data/bot_status.json")]:
if path.exists():
try:
data = json.loads(path.read_text())
return data
except (json.JSONDecodeError, OSError):
continue
now = datetime.now(ZoneInfo("Asia/Jakarta"))
result = DEFAULT_STATUS.copy()
result["timestamp"] = now.strftime("%H:%M:%S")
result["logs"] = [
{
"time": now.strftime("%H:%M:%S"),
"level": "warning",
"message": "Bot is not running — waiting for bot_status.json",
}
]
return result
@app.get("/api/health")
async def health():
"""Health check endpoint."""
bot_running = STATUS_FILE.exists() or Path("data/bot_status.json").exists()
return {"status": "ok", "bot_running": bot_running, "db_available": db.is_available()}
# ─── Trade History Endpoints ───
def _date_filter(field: str, start_date: Optional[str], end_date: Optional[str]):
"""Build date filter SQL clauses."""
clauses = []
params = []
if start_date:
clauses.append(f"{field} >= %s")
params.append(start_date)
if end_date:
clauses.append(f"{field} <= %s")
params.append(end_date + " 23:59:59")
return clauses, params
@app.get("/api/trades")
async def get_trades(
page: int = Query(1, ge=1),
limit: int = Query(25, ge=1, le=100),
direction: str = Query("ALL"),
start_date: Optional[str] = None,
end_date: Optional[str] = None,
):
"""Get paginated trade history."""
if not db.is_available():
return {"trades": [], "total": 0, "page": page, "limit": limit}
where = ["closed_at IS NOT NULL"]
params = []
if direction and direction != "ALL":
where.append("direction = %s")
params.append(direction.upper())
date_clauses, date_params = _date_filter("closed_at", start_date, end_date)
where.extend(date_clauses)
params.extend(date_params)
where_sql = " AND ".join(where)
offset = (page - 1) * limit
count_row = db.query(f"SELECT COUNT(*) as cnt FROM trades WHERE {where_sql}", tuple(params), one=True)
total = count_row["cnt"] if count_row else 0
params_with_pagination = params + [limit, offset]
trades = db.query(
f"""SELECT id, ticket, direction, entry_price, exit_price, lot_size,
profit_usd, profit_pips, sl_price, tp_price,
opened_at, closed_at, exit_reason, confidence,
regime, session, duration_minutes
FROM trades
WHERE {where_sql}
ORDER BY closed_at DESC
LIMIT %s OFFSET %s""",
tuple(params_with_pagination),
)
for t in trades:
for k in ("opened_at", "closed_at"):
if t.get(k) and hasattr(t[k], "isoformat"):
t[k] = t[k].isoformat()
return {"trades": trades, "total": total, "page": page, "limit": limit}
@app.get("/api/trades/stats")
async def get_trade_stats(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
):
"""Get aggregate trade statistics."""
if not db.is_available():
return {"totalTrades": 0, "winRate": 0, "netProfit": 0, "profitFactor": 0, "avgWin": 0, "avgLoss": 0, "bestTrade": 0, "worstTrade": 0}
where = ["closed_at IS NOT NULL"]
params = []
date_clauses, date_params = _date_filter("closed_at", start_date, end_date)
where.extend(date_clauses)
params.extend(date_params)
where_sql = " AND ".join(where)
row = db.query(
f"""SELECT
COUNT(*) as total_trades,
COUNT(*) FILTER (WHERE profit_usd > 0) as wins,
COALESCE(SUM(profit_usd), 0) as net_profit,
COALESCE(SUM(profit_usd) FILTER (WHERE profit_usd > 0), 0) as gross_profit,
COALESCE(ABS(SUM(profit_usd) FILTER (WHERE profit_usd < 0)), 0.01) as gross_loss,
COALESCE(AVG(profit_usd) FILTER (WHERE profit_usd > 0), 0) as avg_win,
COALESCE(AVG(profit_usd) FILTER (WHERE profit_usd < 0), 0) as avg_loss,
COALESCE(MAX(profit_usd), 0) as best_trade,
COALESCE(MIN(profit_usd), 0) as worst_trade
FROM trades WHERE {where_sql}""",
tuple(params),
one=True,
)
if not row or row["total_trades"] == 0:
return {"totalTrades": 0, "winRate": 0, "netProfit": 0, "profitFactor": 0, "avgWin": 0, "avgLoss": 0, "bestTrade": 0, "worstTrade": 0}
return {
"totalTrades": row["total_trades"],
"winRate": round(row["wins"] / row["total_trades"] * 100, 1) if row["total_trades"] > 0 else 0,
"netProfit": round(float(row["net_profit"]), 2),
"profitFactor": round(float(row["gross_profit"]) / float(row["gross_loss"]), 2),
"avgWin": round(float(row["avg_win"]), 2),
"avgLoss": round(float(row["avg_loss"]), 2),
"bestTrade": round(float(row["best_trade"]), 2),
"worstTrade": round(float(row["worst_trade"]), 2),
}
@app.get("/api/trades/equity-curve")
async def get_equity_curve(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
):
"""Get cumulative equity curve from closed trades."""
if not db.is_available():
return {"points": []}
where = ["closed_at IS NOT NULL"]
params = []
date_clauses, date_params = _date_filter("closed_at", start_date, end_date)
where.extend(date_clauses)
params.extend(date_params)
where_sql = " AND ".join(where)
rows = db.query(
f"""SELECT closed_at, profit_usd,
SUM(profit_usd) OVER (ORDER BY closed_at) as cumulative
FROM trades WHERE {where_sql}
ORDER BY closed_at ASC""",
tuple(params),
)
points = []
for r in rows:
dt = r["closed_at"].isoformat() if hasattr(r["closed_at"], "isoformat") else str(r["closed_at"])
points.append({
"time": dt,
"profit": round(float(r["profit_usd"]), 2),
"cumulative": round(float(r["cumulative"]), 2),
})
return {"points": points}
# ─── Model Insights Endpoints ───
@app.get("/api/model/metrics")
async def get_model_metrics():
"""Read model metrics from JSON file (written by bot on startup/retrain)."""
for path in [MODEL_METRICS_FILE, Path("data/model_metrics.json")]:
if path.exists():
try:
return json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
continue
return {"featureImportance": [], "trainAuc": 0, "testAuc": 0, "sampleCount": 0, "updatedAt": None}
@app.get("/api/model/training-history")
async def get_training_history():
"""Get model training run history."""
if not db.is_available():
return {"runs": []}
rows = db.query(
"""SELECT id, started_at, completed_at, train_auc, test_auc,
sample_count, features_used, trigger_reason
FROM training_runs
ORDER BY started_at DESC
LIMIT 20"""
)
for r in rows:
for k in ("started_at", "completed_at"):
if r.get(k) and hasattr(r[k], "isoformat"):
r[k] = r[k].isoformat()
return {"runs": rows}
@app.get("/api/model/regime-distribution")
async def get_regime_distribution():
"""Get regime distribution from recent market snapshots."""
if not db.is_available():
return {"distribution": []}
rows = db.query(
"""SELECT regime, COUNT(*) as count
FROM market_snapshots
WHERE snapshot_time > NOW() - INTERVAL '7 days'
GROUP BY regime
ORDER BY count DESC"""
)
return {"distribution": rows}
# ─── Signal / Alert Endpoints ───
@app.get("/api/signals")
async def get_signals(
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
type: str = Query("ALL"),
executed: str = Query("all"),
start_date: Optional[str] = None,
end_date: Optional[str] = None,
):
"""Get paginated signal/alert history."""
if not db.is_available():
return {"signals": [], "total": 0, "page": page, "limit": limit}
where = ["1=1"]
params = []
if type and type != "ALL":
where.append("signal_type = %s")
params.append(type.upper())
if executed == "yes":
where.append("executed = TRUE")
elif executed == "no":
where.append("executed = FALSE")
date_clauses, date_params = _date_filter("signal_time", start_date, end_date)
where.extend(date_clauses)
params.extend(date_params)
where_sql = " AND ".join(where)
offset = (page - 1) * limit
count_row = db.query(f"SELECT COUNT(*) as cnt FROM signals WHERE {where_sql}", tuple(params), one=True)
total = count_row["cnt"] if count_row else 0
params_with_pagination = params + [limit, offset]
signals = db.query(
f"""SELECT id, signal_time, signal_type, confidence, executed,
execution_reason, regime, session, smc_signal, ml_signal,
entry_price, sl_price, tp_price
FROM signals
WHERE {where_sql}
ORDER BY signal_time DESC
LIMIT %s OFFSET %s""",
tuple(params_with_pagination),
)
for s in signals:
if s.get("signal_time") and hasattr(s["signal_time"], "isoformat"):
s["signal_time"] = s["signal_time"].isoformat()
return {"signals": signals, "total": total, "page": page, "limit": limit}
@app.get("/api/signals/stats")
async def get_signal_stats(hours: int = Query(24, ge=1, le=168)):
"""Get signal statistics for the last N hours."""
if not db.is_available():
return {"total": 0, "executed": 0, "executionRate": 0, "avgConfidence": 0, "byType": {}}
row = db.query(
"""SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE executed = TRUE) as executed,
COALESCE(AVG(confidence), 0) as avg_confidence
FROM signals
WHERE signal_time > NOW() - MAKE_INTERVAL(hours => %s)""",
(hours,),
one=True,
)
if not row or row["total"] == 0:
return {"total": 0, "executed": 0, "executionRate": 0, "avgConfidence": 0, "byType": {}}
by_type = db.query(
"""SELECT signal_type, COUNT(*) as count
FROM signals
WHERE signal_time > NOW() - MAKE_INTERVAL(hours => %s)
GROUP BY signal_type""",
(hours,),
)
return {
"total": row["total"],
"executed": row["executed"],
"executionRate": round(row["executed"] / row["total"] * 100, 1) if row["total"] > 0 else 0,
"avgConfidence": round(float(row["avg_confidence"]), 1),
"byType": {r["signal_type"]: r["count"] for r in by_type},
}
# ============================================================
# FILTER CONFIG ENDPOINTS
# ============================================================
@app.get("/api/filters/config")
async def get_filter_config():
"""Get current filter enable/disable states."""
try:
if not FILTER_CONFIG_FILE.exists():
return {"filters": {}, "metadata": {}}
with open(FILTER_CONFIG_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
except Exception as e:
logger.error(f"Failed to load filter config: {e}")
return {"filters": {}, "metadata": {}, "error": str(e)}
@app.post("/api/filters/config")
async def update_filter_config(updates: dict):
"""
Update filter enable/disable states.
Body: { "filter_key": true/false, ... }
Example: { "h1_bias": false, "ml_confidence": true }
"""
try:
# Load current config
if not FILTER_CONFIG_FILE.exists():
return {"success": False, "error": "Filter config file not found"}
with open(FILTER_CONFIG_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
# Update enabled states
for filter_key, enabled in updates.items():
if filter_key in data["filters"]:
data["filters"][filter_key]["enabled"] = enabled
# Update metadata
data["metadata"]["updated_at"] = datetime.now(ZoneInfo("Asia/Jakarta")).isoformat()
# Save
with open(FILTER_CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(f"Filter config updated: {list(updates.keys())}")
return {"success": True, "updated": list(updates.keys())}
except Exception as e:
logger.error(f"Failed to update filter config: {e}")
return {"success": False, "error": str(e)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)