Files
fx-quant/src/dashboard.py
T
Brent Neale 212f581d01 Add web dashboard, historical data loader, and backtest reporting
- Add Flask dashboard with status, config editor, logs, kill switch,
  and backtest results pages with monthly P&L breakdowns
- Add historical_loader.py for paginated OANDA candle fetching (1yr+)
- Update backtester to $100k starting equity, monthly P&L computation,
  and JSON summary output for dashboard display
- Update config to EUR_USD only on M5/M15 with SMA 21/50 strategy
- Add backtest.html template with performance metrics and bar charts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:29:12 +10:00

305 lines
12 KiB
Python

# src/dashboard.py
"""
Web dashboard for fx-quant trading bot.
Provides config editing, status monitoring, log viewing, and kill switch control.
"""
import os
import csv
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
from functools import wraps
import yaml
from flask import Flask, render_template, request, redirect, url_for, flash, Response
app = Flask(__name__, template_folder=str(Path(__file__).resolve().parent.parent / "templates"))
app.secret_key = os.urandom(24)
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
APP_ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = APP_ROOT / "config" / "system.yaml"
ENV_PATH = APP_ROOT / "config" / ".env"
RELOAD_SIGNAL = APP_ROOT / "RELOAD_CONFIG"
KILL_SWITCH_FILE = APP_ROOT / "STOP_ALL_TRADING"
ORDER_LOG = APP_ROOT / "logs" / "order_log.csv"
AI_LOG = APP_ROOT / "logs" / "ai_decisions.csv"
# When running in Docker, paths are under /app
if Path("/app/config/system.yaml").exists():
CONFIG_PATH = Path("/app/config/system.yaml")
ENV_PATH = Path("/app/config/.env")
RELOAD_SIGNAL = Path("/app/RELOAD_CONFIG")
KILL_SWITCH_FILE = Path("/app/STOP_ALL_TRADING")
ORDER_LOG = Path("/app/logs/order_log.csv")
AI_LOG = Path("/app/logs/ai_decisions.csv")
# Whitelisted OANDA instruments for validation
VALID_INSTRUMENTS = [
"EUR_USD", "USD_JPY", "GBP_USD", "USD_CHF", "AUD_USD",
"USD_CAD", "NZD_USD", "EUR_GBP", "EUR_JPY", "GBP_JPY",
"EUR_CHF", "AUD_JPY", "CHF_JPY", "EUR_AUD", "EUR_CAD",
"EUR_NZD", "GBP_AUD", "GBP_CAD", "GBP_CHF", "GBP_NZD",
"AUD_CAD", "AUD_CHF", "AUD_NZD", "CAD_CHF", "CAD_JPY",
"NZD_CAD", "NZD_CHF", "NZD_JPY",
]
VALID_GRANULARITIES = ["S5", "S10", "S15", "S30", "M1", "M2", "M4", "M5",
"M10", "M15", "M30", "H1", "H2", "H3", "H4", "H6",
"H8", "H12", "D", "W", "M"]
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
def check_auth(username, password):
dashboard_pw = os.environ.get("DASHBOARD_PASSWORD", "changeme")
return username == "admin" and password == dashboard_pw
def authenticate():
return Response(
"Login required.", 401,
{"WWW-Authenticate": 'Basic realm="fx-quant dashboard"'},
)
def requires_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if not auth or not check_auth(auth.username, auth.password):
return authenticate()
return f(*args, **kwargs)
return decorated
# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------
def load_config():
with open(CONFIG_PATH, "r") as f:
return yaml.safe_load(f)
def save_config(cfg):
"""Save config with backup."""
backup = CONFIG_PATH.with_suffix(".yaml.backup")
shutil.copy2(CONFIG_PATH, backup)
with open(CONFIG_PATH, "w") as f:
yaml.dump(cfg, f, default_flow_style=False, sort_keys=False)
# Signal bot to reload
RELOAD_SIGNAL.touch()
def read_csv_tail(csv_path, max_rows=50):
"""Read last N rows from a CSV file, return (headers, rows)."""
if not csv_path.exists():
return [], []
with open(csv_path, "r") as f:
reader = csv.reader(f)
rows = list(reader)
if not rows:
return [], []
headers = rows[0]
data = rows[1:]
return headers, data[-max_rows:]
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def validate_config(cfg):
"""Validate config values. Returns list of error strings."""
errors = []
# Instruments
instruments = cfg.get("brokers", [{}])[0].get("instruments", [])
for inst in instruments:
if inst not in VALID_INSTRUMENTS:
errors.append(f"Invalid instrument: {inst}")
# Granularities
grans = cfg.get("data", {}).get("candle_granularities", [])
for g in grans:
if g not in VALID_GRANULARITIES:
errors.append(f"Invalid granularity: {g}")
# Numeric ranges
strategy = cfg.get("strategy", {})
params = strategy.get("params", {})
if params.get("short", 1) < 1:
errors.append("SMA short period must be >= 1")
if params.get("long", 2) < 2:
errors.append("SMA long period must be >= 2")
if params.get("short", 1) >= params.get("long", 2):
errors.append("SMA short period must be less than long period")
trade_size = strategy.get("trade_size_pct_of_equity", 0.01)
if not (0.001 <= trade_size <= 0.1):
errors.append("trade_size_pct_of_equity must be between 0.001 and 0.1")
execution = cfg.get("execution", {})
if execution.get("max_positions", 1) < 1:
errors.append("max_positions must be >= 1")
if execution.get("interval_seconds", 10) < 10:
errors.append("interval_seconds must be >= 10")
ai = cfg.get("ai", {})
threshold = ai.get("confidence_threshold", 0.5)
if not (0.0 <= threshold <= 1.0):
errors.append("confidence_threshold must be between 0.0 and 1.0")
return errors
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/")
@requires_auth
def index():
cfg = load_config()
kill_active = KILL_SWITCH_FILE.exists()
# Read recent orders for activity feed
_, recent_orders = read_csv_tail(ORDER_LOG, max_rows=10)
return render_template("index.html",
cfg=cfg,
kill_active=kill_active,
recent_orders=recent_orders)
@app.route("/config", methods=["GET", "POST"])
@requires_auth
def config_editor():
cfg = load_config()
if request.method == "POST":
# Parse form into config structure
try:
# Instruments
instruments_raw = request.form.get("instruments", "").strip()
instruments = [i.strip() for i in instruments_raw.split(",") if i.strip()]
cfg["brokers"][0]["instruments"] = instruments
# Granularities
grans_raw = request.form.get("granularities", "").strip()
grans = [g.strip() for g in grans_raw.split(",") if g.strip()]
cfg["data"]["candle_granularities"] = grans
# Candle count
cfg["data"]["candle_count"] = int(request.form.get("candle_count", 200))
# Features
cfg["features"]["sma_windows"] = _parse_int_list(request.form.get("sma_windows", "3,20"))
cfg["features"]["ema_windows"] = _parse_int_list(request.form.get("ema_windows", "20"))
cfg["features"]["rsi_period"] = int(request.form.get("rsi_period", 14))
cfg["features"]["atr_period"] = int(request.form.get("atr_period", 14))
cfg["features"]["vwap_window"] = int(request.form.get("vwap_window", 20))
cfg["features"]["volatility_window"] = int(request.form.get("volatility_window", 20))
# Strategy
cfg["strategy"]["rule"] = request.form.get("strategy_rule", "sma_cross")
cfg["strategy"]["params"]["short"] = int(request.form.get("sma_short", 3))
cfg["strategy"]["params"]["long"] = int(request.form.get("sma_long", 20))
cfg["strategy"]["trade_size_pct_of_equity"] = float(request.form.get("trade_size_pct", 0.01))
cfg["strategy"]["max_drawdown_pct"] = float(request.form.get("max_drawdown_pct", 0.05))
# AI
cfg["ai"]["confidence_threshold"] = float(request.form.get("confidence_threshold", 0.85))
cfg["ai"]["ensemble_models"] = [m.strip() for m in request.form.get("ensemble_models", "").split(",") if m.strip()]
cfg["ai"]["sanity_checks"]["rsi_overbought"] = int(request.form.get("rsi_overbought", 80))
cfg["ai"]["sanity_checks"]["rsi_oversold"] = int(request.form.get("rsi_oversold", 20))
cfg["ai"]["sanity_checks"]["volatility_multiplier"] = float(request.form.get("volatility_multiplier", 3.0))
# Execution
cfg["execution"]["paper_mode"] = request.form.get("paper_mode") == "on"
cfg["execution"]["canary_size_pct"] = float(request.form.get("canary_size_pct", 0.01))
cfg["execution"]["max_positions"] = int(request.form.get("max_positions", 3))
cfg["execution"]["interval_seconds"] = int(request.form.get("interval_seconds", 60))
# Validate
errors = validate_config(cfg)
if errors:
for e in errors:
flash(e, "danger")
return render_template("config.html", cfg=cfg,
valid_instruments=VALID_INSTRUMENTS,
valid_granularities=VALID_GRANULARITIES)
save_config(cfg)
flash("Config saved. Bot will reload on next loop iteration.", "success")
return redirect(url_for("config_editor"))
except (ValueError, KeyError) as e:
flash(f"Invalid input: {e}", "danger")
return render_template("config.html", cfg=cfg,
valid_instruments=VALID_INSTRUMENTS,
valid_granularities=VALID_GRANULARITIES)
return render_template("config.html", cfg=cfg,
valid_instruments=VALID_INSTRUMENTS,
valid_granularities=VALID_GRANULARITIES)
@app.route("/logs")
@requires_auth
def logs():
order_headers, order_rows = read_csv_tail(ORDER_LOG, max_rows=100)
ai_headers, ai_rows = read_csv_tail(AI_LOG, max_rows=100)
# Reverse so newest first
order_rows = list(reversed(order_rows))
ai_rows = list(reversed(ai_rows))
return render_template("logs.html",
order_headers=order_headers,
order_rows=order_rows,
ai_headers=ai_headers,
ai_rows=ai_rows)
@app.route("/backtest")
@requires_auth
def backtest():
"""Show backtest results from saved JSON summaries."""
logs_dir = APP_ROOT / "logs"
summaries = []
for f in sorted(logs_dir.glob("backtest_summary_*.json")):
with open(f, "r") as fh:
summaries.append(json.load(fh))
return render_template("backtest.html", summaries=summaries)
@app.route("/killswitch", methods=["POST"])
@requires_auth
def killswitch():
action = request.form.get("action")
if action == "activate":
KILL_SWITCH_FILE.touch()
flash("Kill switch ACTIVATED. All trading halted.", "warning")
elif action == "deactivate":
if KILL_SWITCH_FILE.exists():
KILL_SWITCH_FILE.unlink()
flash("Kill switch deactivated. Trading will resume on next loop.", "success")
return redirect(url_for("index"))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_int_list(s):
"""Parse comma-separated string into list of ints."""
return [int(x.strip()) for x in s.split(",") if x.strip()]
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)