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>
This commit is contained in:
Brent Neale
2026-02-17 13:29:12 +10:00
parent ef950f25dd
commit 212f581d01
15 changed files with 1359 additions and 42 deletions
+69 -1
View File
@@ -1 +1,69 @@
# fx-quant
# fx-quant
## Web Dashboard
A Flask-based web UI for managing the trading bot remotely — edit config, monitor status, view logs, and toggle the kill switch from a browser instead of SSH + manual YAML editing.
### Setup
1. **Set your dashboard password** in `config/.env`:
```
DASHBOARD_PASSWORD=your-secure-password-here
```
2. **Build and start** both services:
```bash
docker-compose build && docker-compose up -d
```
3. **Access via SSH tunnel** (dashboard is not exposed publicly):
```bash
ssh -L 5000:localhost:5000 your-server
```
Then open http://localhost:5000 in your browser.
4. **Log in** with username `admin` and the password you set in step 1.
### Dashboard Pages
- **Status** (`/`) — Current mode (paper/live), strategy, instruments, loop interval, kill switch toggle, and last 10 orders
- **Config** (`/config`) — Form-based editor for all `system.yaml` sections: instruments, granularities, strategy params, feature windows, AI settings, execution settings. Saves with backup and signals the bot to reload.
- **Logs** (`/logs`) — Tabbed tables showing order history (`logs/order_log.csv`) and AI decisions (`logs/ai_decisions.csv`), newest first
### Config Reload Flow
When you save config changes through the dashboard:
1. Dashboard backs up `system.yaml` to `system.yaml.backup`
2. Dashboard writes the updated config
3. Dashboard creates a `RELOAD_CONFIG` signal file
4. Bot checks for this file at the top of each 60s loop iteration
5. Bot reloads config, deletes the signal file, and continues with new settings
### Kill Switch
The dashboard provides activate/deactivate buttons (with confirmation prompts) that create/remove the `STOP_ALL_TRADING` file — the same mechanism the bot already uses.
### Files Added/Changed
| File | What |
|------|------|
| `src/dashboard.py` | Flask app — routes, auth, config editor, status, logs |
| `templates/base.html` | Base layout (Bootstrap 5 via CDN) |
| `templates/index.html` | Status page |
| `templates/config.html` | Config editor form |
| `templates/logs.html` | Order log + AI decisions tables |
| `docker-compose.yml` | Added `dashboard` service on port 5000 |
| `requirements.docker.txt` | Added `flask`, `flask-httpauth` |
| `config/.env` | Added `DASHBOARD_PASSWORD` |
| `src/order_executor.py` | Added `RELOAD_CONFIG` signal check in main loop |
### Verification Checklist
- [ ] `docker-compose build && docker-compose up -d` — both containers start
- [ ] http://localhost:5000 shows login prompt (via SSH tunnel)
- [ ] Status page shows current config and kill switch state
- [ ] Edit a setting (e.g. add `GBP_USD` to instruments), save
- [ ] Bot logs show `CONFIG RELOAD REQUESTED` within 60s
- [ ] Logs page shows order history and AI decisions
- [ ] Kill switch toggle works with confirmation dialog
+34 -34
View File
@@ -1,57 +1,57 @@
general:
project_name: "fx-quant"
timezone: "UTC"
project_name: fx-quant
timezone: UTC
brokers:
- name: "oanda"
type: "oanda_v20"
enabled: true
instruments:
- "EUR_USD"
- "USD_JPY"
- name: oanda
type: oanda_v20
enabled: true
instruments:
- EUR_USD
data:
candle_count: 200
candle_granularities:
- "M1"
- "M5"
tick_export: false # true to capture raw ticks
- M5
- M15
tick_export: false
features:
sma_windows: [3, 20]
ema_windows: [20]
sma_windows:
- 3
- 20
- 21
- 50
ema_windows:
- 20
rsi_period: 14
atr_period: 14
vwap_window: 20
volatility_window: 20
ai:
model: "local-ensemble"
confidence_threshold: 0.85
model: local-ensemble
confidence_threshold: 0.75
retriever_enabled: true
retriever_source: "supabase"
ensemble_models: ["logistic_regression", "random_forest", "gradient_boosting"]
retriever_source: supabase
ensemble_models:
- logistic_regression
- random_forest
- gradient_boosting
backtest_validation_window: 50
sanity_checks:
rsi_overbought: 80
rsi_oversold: 20
rsi_overbought: 90
rsi_oversold: 10
volatility_multiplier: 3.0
strategy:
rule: "sma_cross" # human-readable name of the rule
rule: sma_cross
params:
short: 3
long: 20
trade_size_pct_of_equity: 0.01
short: 21
long: 50
trade_size_pct_of_equity: 0.025
max_drawdown_pct: 0.05
execution:
paper_mode: true
canary_size_pct: 0.01
max_positions: 3
max_positions: 5
interval_seconds: 60
supabase:
url: "https://<your>.supabase.co"
key_env_name: "SUPABASE_KEY" # key stored in .env
table: "fx_candles"
url: https://<your>.supabase.co
key_env_name: SUPABASE_KEY
table: fx_candles
+15 -1
View File
@@ -5,7 +5,21 @@ services:
env_file:
- config/.env
volumes:
- ./config/system.yaml:/app/config/system.yaml:ro
- ./config:/app/config
- ./logs:/app/logs
- ./models:/app/models
restart: unless-stopped
dashboard:
build: .
container_name: fx-quant-dashboard
command: python src/dashboard.py
env_file:
- config/.env
volumes:
- ./config:/app/config
- ./logs:/app/logs:ro
- ./templates:/app/templates:ro
ports:
- "5000:5000"
restart: unless-stopped
+113
View File
@@ -0,0 +1,113 @@
{
"instrument": "EUR_USD",
"granularity": "M15",
"run_time": "2026-02-17T03:21:00.971644+00:00",
"data_range": {
"start": "2025-02-17 15:30:00+00:00",
"end": "2026-02-17 03:15:00+00:00",
"bars": 24816
},
"metrics": {
"starting_equity": 100000.0,
"total_return_pct": 0.0729,
"max_drawdown_pct": 0.138,
"num_trades": 590,
"round_trips": 295,
"win_rate_pct": 33.22,
"sharpe_ratio": 1.965,
"final_equity": 100072.93
},
"monthly_pnl": [
{
"month": "2025-02",
"start_equity": 100000.0,
"end_equity": 99975.28,
"pnl": -24.72,
"pnl_pct": -0.0247
},
{
"month": "2025-03",
"start_equity": 99975.28,
"end_equity": 100010.04,
"pnl": 34.75,
"pnl_pct": 0.0348
},
{
"month": "2025-04",
"start_equity": 100010.04,
"end_equity": 100114.13,
"pnl": 104.09,
"pnl_pct": 0.1041
},
{
"month": "2025-05",
"start_equity": 100114.13,
"end_equity": 100147.54,
"pnl": 33.41,
"pnl_pct": 0.0334
},
{
"month": "2025-06",
"start_equity": 100147.54,
"end_equity": 100141.15,
"pnl": -6.38,
"pnl_pct": -0.0064
},
{
"month": "2025-07",
"start_equity": 100141.15,
"end_equity": 100108.29,
"pnl": -32.86,
"pnl_pct": -0.0328
},
{
"month": "2025-08",
"start_equity": 100108.29,
"end_equity": 100121.7,
"pnl": 13.41,
"pnl_pct": 0.0134
},
{
"month": "2025-09",
"start_equity": 100121.7,
"end_equity": 100121.11,
"pnl": -0.59,
"pnl_pct": -0.0006
},
{
"month": "2025-10",
"start_equity": 100121.11,
"end_equity": 100077.51,
"pnl": -43.6,
"pnl_pct": -0.0435
},
{
"month": "2025-11",
"start_equity": 100077.51,
"end_equity": 100068.99,
"pnl": -8.52,
"pnl_pct": -0.0085
},
{
"month": "2025-12",
"start_equity": 100068.99,
"end_equity": 100072.84,
"pnl": 3.85,
"pnl_pct": 0.0039
},
{
"month": "2026-01",
"start_equity": 100072.84,
"end_equity": 100084.84,
"pnl": 12.0,
"pnl_pct": 0.012
},
{
"month": "2026-02",
"start_equity": 100084.84,
"end_equity": 100072.93,
"pnl": -11.92,
"pnl_pct": -0.0119
}
]
}
+113
View File
@@ -0,0 +1,113 @@
{
"instrument": "EUR_USD",
"granularity": "M5",
"run_time": "2026-02-17T03:20:15.063316+00:00",
"data_range": {
"start": "2025-02-17 07:15:00+00:00",
"end": "2026-02-17 03:10:00+00:00",
"bars": 74536
},
"metrics": {
"starting_equity": 100000.0,
"total_return_pct": 0.0653,
"max_drawdown_pct": 0.1037,
"num_trades": 1684,
"round_trips": 842,
"win_rate_pct": 35.99,
"sharpe_ratio": 1.0001,
"final_equity": 100065.29
},
"monthly_pnl": [
{
"month": "2025-02",
"start_equity": 100000.0,
"end_equity": 99990.93,
"pnl": -9.07,
"pnl_pct": -0.0091
},
{
"month": "2025-03",
"start_equity": 99990.93,
"end_equity": 100043.93,
"pnl": 53.0,
"pnl_pct": 0.053
},
{
"month": "2025-04",
"start_equity": 100043.93,
"end_equity": 100065.95,
"pnl": 22.02,
"pnl_pct": 0.022
},
{
"month": "2025-05",
"start_equity": 100065.95,
"end_equity": 100122.9,
"pnl": 56.95,
"pnl_pct": 0.0569
},
{
"month": "2025-06",
"start_equity": 100122.9,
"end_equity": 100129.99,
"pnl": 7.09,
"pnl_pct": 0.0071
},
{
"month": "2025-07",
"start_equity": 100129.99,
"end_equity": 100070.84,
"pnl": -59.15,
"pnl_pct": -0.0591
},
{
"month": "2025-08",
"start_equity": 100070.84,
"end_equity": 100136.88,
"pnl": 66.04,
"pnl_pct": 0.066
},
{
"month": "2025-09",
"start_equity": 100136.88,
"end_equity": 100128.96,
"pnl": -7.92,
"pnl_pct": -0.0079
},
{
"month": "2025-10",
"start_equity": 100128.96,
"end_equity": 100114.83,
"pnl": -14.13,
"pnl_pct": -0.0141
},
{
"month": "2025-11",
"start_equity": 100114.83,
"end_equity": 100087.25,
"pnl": -27.58,
"pnl_pct": -0.0275
},
{
"month": "2025-12",
"start_equity": 100087.25,
"end_equity": 100088.28,
"pnl": 1.03,
"pnl_pct": 0.001
},
{
"month": "2026-01",
"start_equity": 100088.28,
"end_equity": 100092.01,
"pnl": 3.73,
"pnl_pct": 0.0037
},
{
"month": "2026-02",
"start_equity": 100092.01,
"end_equity": 100065.29,
"pnl": -26.72,
"pnl_pct": -0.0267
}
]
}
+2
View File
@@ -11,3 +11,5 @@ requests>=2.28
supabase>=2.0
python-dotenv>=1.0
PyYAML>=6.0
flask>=3.0
flask-httpauth>=4.8
+66 -6
View File
@@ -6,6 +6,7 @@ and produces P&L, drawdown, and trade log outputs.
"""
import os
import json
import math
from pathlib import Path
@@ -117,7 +118,7 @@ def run_backtest(df, strategy_cfg):
trade_size_pct = strategy_cfg.get("trade_size_pct_of_equity", 0.01)
max_dd_pct = strategy_cfg.get("max_drawdown_pct", 0.05)
starting_equity = 10_000.0
starting_equity = cfg_equity if (cfg_equity := strategy_cfg.get("starting_equity")) else 100_000.0
equity = starting_equity
peak_equity = equity
position = 0 # 0 = flat, 1 = long
@@ -213,7 +214,7 @@ def run_backtest(df, strategy_cfg):
# Metrics
# ---------------------------------------------------------------------------
def compute_metrics(equity_curve, trades, starting_equity=10_000.0):
def compute_metrics(equity_curve, trades, starting_equity=100_000.0):
"""
Compute summary metrics from equity curve and trade list.
"""
@@ -248,6 +249,7 @@ def compute_metrics(equity_curve, trades, starting_equity=10_000.0):
sharpe = (eq_returns.mean() / eq_returns.std()) * math.sqrt(252 * 24 * 60) # per-minute approx
return {
"starting_equity": round(starting_equity, 2),
"total_return_pct": round(total_return_pct, 4),
"max_drawdown_pct": round(max_drawdown_pct, 4),
"num_trades": num_trades,
@@ -262,20 +264,48 @@ def compute_metrics(equity_curve, trades, starting_equity=10_000.0):
# Results output
# ---------------------------------------------------------------------------
def save_results(instrument, granularity, trades, metrics):
def compute_monthly_pnl(equity_curve, starting_equity=100_000.0):
"""
Save trade log CSV and print summary metrics.
Compute P&L for each calendar month from the equity curve.
Returns list of dicts with month, start_equity, end_equity, pnl, pnl_pct.
"""
monthly = []
# Group by year-month
grouped = equity_curve.groupby(equity_curve.index.to_period("M"))
prev_end = starting_equity
for period, group in grouped:
end_eq = group.iloc[-1]
pnl = end_eq - prev_end
pnl_pct = (pnl / prev_end * 100) if prev_end != 0 else 0.0
monthly.append({
"month": str(period),
"start_equity": round(prev_end, 2),
"end_equity": round(end_eq, 2),
"pnl": round(pnl, 2),
"pnl_pct": round(pnl_pct, 4),
})
prev_end = end_eq
return monthly
def save_results(instrument, granularity, results, metrics):
"""
Save trade log CSV, monthly P&L, and a JSON summary for the dashboard.
"""
root = get_project_root()
logs_dir = root / "logs"
logs_dir.mkdir(exist_ok=True)
trades = results["trades"]
equity_curve = results["equity_curve"]
# Trade log CSV
if trades:
trade_df = pd.DataFrame(trades)
trade_df["instrument"] = instrument
trade_df["granularity"] = granularity
# Reorder columns
cols = ["time", "instrument", "granularity", "side", "price",
"position_size", "equity", "drawdown"]
trade_df = trade_df[cols]
@@ -285,6 +315,28 @@ def save_results(instrument, granularity, trades, metrics):
else:
print(" No trades to log.")
# Monthly P&L
starting_equity = metrics.get("starting_equity", 100_000.0)
monthly = compute_monthly_pnl(equity_curve, starting_equity)
# Save JSON summary for dashboard
summary = {
"instrument": instrument,
"granularity": granularity,
"run_time": pd.Timestamp.now(tz="UTC").isoformat(),
"data_range": {
"start": str(equity_curve.index[0]) if len(equity_curve) > 0 else "",
"end": str(equity_curve.index[-1]) if len(equity_curve) > 0 else "",
"bars": len(equity_curve),
},
"metrics": metrics,
"monthly_pnl": monthly,
}
json_path = logs_dir / f"backtest_summary_{instrument}_{granularity}.json"
with open(json_path, "w") as f:
json.dump(summary, f, indent=2, default=str)
print(f" Summary saved: {json_path}")
# Console summary
print(f"\n --- {instrument} / {granularity} Summary ---")
print(f" Total return: {metrics['total_return_pct']:.4f}%")
@@ -293,6 +345,14 @@ def save_results(instrument, granularity, trades, metrics):
print(f" Win rate: {metrics['win_rate_pct']:.2f}%")
print(f" Sharpe ratio: {metrics['sharpe_ratio']:.4f}")
print(f" Final equity: ${metrics['final_equity']:,.2f}")
# Monthly P&L table
print(f"\n Monthly P&L:")
print(f" {'Month':<10} {'Start':>12} {'End':>12} {'P&L':>10} {'%':>8}")
print(f" {'-'*54}")
for m in monthly:
sign = "+" if m["pnl"] >= 0 else ""
print(f" {m['month']:<10} ${m['start_equity']:>11,.2f} ${m['end_equity']:>11,.2f} {sign}${m['pnl']:>8,.2f} {sign}{m['pnl_pct']:.2f}%")
print()
@@ -334,7 +394,7 @@ def main():
continue
results = run_backtest(df, strategy_cfg)
save_results(instrument, granularity, results["trades"], results["metrics"])
save_results(instrument, granularity, results, results["metrics"])
print("Backtesting complete.")
+304
View File
@@ -0,0 +1,304 @@
# 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)
+129
View File
@@ -0,0 +1,129 @@
# src/historical_loader.py
"""
Fetch ~1 year of historical OANDA candles in paginated chunks (max 5000 per request),
compute features, and upload to Supabase.
Usage:
python src/historical_loader.py
"""
import os
import time
from datetime import datetime, timezone, timedelta
import requests
import pandas as pd
from config_loader import load_config
from data_engine import candles_to_df, build_all_features
from supabase_upload import upload_dataframe
cfg = load_config()
API_KEY = os.getenv("OANDA_API_KEY")
ENV = os.getenv("OANDA_ENV", "practice")
BASE = (
"https://api-fxpractice.oanda.com"
if ENV == "practice"
else "https://api-fxtrade.oanda.com"
)
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
def fetch_candles_chunk(instrument, granularity, from_dt, to_dt, count=5000):
"""Fetch up to `count` candles between from_dt and to_dt."""
params = {
"granularity": granularity,
"from": from_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
"to": to_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
}
url = f"{BASE}/v3/instruments/{instrument}/candles"
r = requests.get(url, headers=HEADERS, params=params)
r.raise_for_status()
return r.json().get("candles", [])
def fetch_all_candles(instrument, granularity, days_back=365):
"""
Paginate through OANDA history in chunks of 5000 candles.
Returns a single merged DataFrame with all candles.
"""
end = datetime.now(timezone.utc)
start = end - timedelta(days=days_back)
# Determine candle duration for stepping forward
gran_minutes = {
"M1": 1, "M5": 5, "M15": 15, "M30": 30,
"H1": 60, "H4": 240, "D": 1440, "W": 10080,
}
minutes = gran_minutes.get(granularity, 5)
chunk_duration = timedelta(minutes=minutes * 4999) # just under 5000 candles
all_candles = []
cursor = start
chunk_num = 0
while cursor < end:
chunk_end = min(cursor + chunk_duration, end)
chunk_num += 1
print(f" Chunk {chunk_num}: {cursor.strftime('%Y-%m-%d %H:%M')} -> {chunk_end.strftime('%Y-%m-%d %H:%M')} ...", end=" ")
candles = fetch_candles_chunk(instrument, granularity, cursor, chunk_end)
print(f"{len(candles)} candles")
if candles:
all_candles.extend(candles)
# Move cursor past the last candle we received
last_time = pd.to_datetime(candles[-1]["time"])
cursor = last_time.to_pydatetime().replace(tzinfo=timezone.utc) + timedelta(minutes=minutes)
else:
# No data in this window, skip forward
cursor = chunk_end
# Be polite to the API
time.sleep(0.5)
print(f" Total raw candles fetched: {len(all_candles)}")
if not all_candles:
return pd.DataFrame()
# Deduplicate by time (overlapping chunks)
df = candles_to_df(all_candles)
df = df[~df.index.duplicated(keep="last")]
df = df.sort_index()
print(f" After dedup: {len(df)} candles")
print(f" Range: {df.index[0]} -> {df.index[-1]}")
return df
def main():
feature_cfg = cfg.get("features", {})
broker = cfg["brokers"][0]
instruments = broker["instruments"]
granularities = cfg["data"]["candle_granularities"]
for instrument in instruments:
for granularity in granularities:
print(f"\n{'='*60}")
print(f"Fetching {instrument} / {granularity} — last 365 days")
print(f"{'='*60}")
df = fetch_all_candles(instrument, granularity, days_back=365)
if df.empty:
print(f" No data. Skipping.")
continue
print(f" Computing features...")
df = build_all_features(df, config=feature_cfg)
print(f" Uploading to Supabase...")
upload_dataframe(df, instrument=instrument, granularity=granularity, chunk_size=500)
print(f" Done: {instrument} / {granularity}")
print(f"\nAll historical data loaded.")
if __name__ == "__main__":
main()
+12
View File
@@ -465,6 +465,18 @@ if __name__ == "__main__":
print(f"Running on {interval}s loop. Press Ctrl+C to stop.\n")
while True:
try:
# Check for config reload signal from dashboard
reload_signal = get_project_root() / "RELOAD_CONFIG"
if reload_signal.exists():
print("\n*** CONFIG RELOAD REQUESTED ***")
try:
reload_signal.unlink()
except OSError:
pass
cfg = load_config()
interval = cfg.get("execution", {}).get("interval_seconds", 60)
print(f"Config reloaded. Interval now {interval}s.\n")
main()
print(f"\nSleeping {interval}s until next run...\n")
time.sleep(interval)
+102
View File
@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}fx-quant — Backtest Results{% endblock %}
{% block content %}
<h4 class="mb-4">Backtest Results</h4>
{% if not summaries %}
<div class="alert alert-info">No backtest results found. Run the backtester first.</div>
{% endif %}
{% for s in summaries %}
<div class="card mb-4">
<div class="card-header">
<strong>{{ s.instrument }} / {{ s.granularity }}</strong>
<span class="text-muted float-end">Run: {{ s.run_time[:19] }} | {{ s.data_range.bars | default(0) }} bars ({{ s.data_range.start[:10] }} to {{ s.data_range.end[:10] }})</span>
</div>
<div class="card-body">
<!-- Summary Metrics -->
<div class="row mb-3">
<div class="col-md-2 text-center">
<div class="text-muted small">Starting Equity</div>
<div class="fs-5">${{ "{:,.2f}".format(s.metrics.starting_equity) }}</div>
</div>
<div class="col-md-2 text-center">
<div class="text-muted small">Final Equity</div>
<div class="fs-5 {% if s.metrics.final_equity >= s.metrics.starting_equity %}text-success{% else %}text-danger{% endif %}">
${{ "{:,.2f}".format(s.metrics.final_equity) }}
</div>
</div>
<div class="col-md-2 text-center">
<div class="text-muted small">Total Return</div>
<div class="fs-5 {% if s.metrics.total_return_pct >= 0 %}text-success{% else %}text-danger{% endif %}">
{{ "{:+.2f}".format(s.metrics.total_return_pct) }}%
</div>
</div>
<div class="col-md-2 text-center">
<div class="text-muted small">Max Drawdown</div>
<div class="fs-5 text-danger">{{ "{:.2f}".format(s.metrics.max_drawdown_pct) }}%</div>
</div>
<div class="col-md-2 text-center">
<div class="text-muted small">Win Rate</div>
<div class="fs-5">{{ "{:.1f}".format(s.metrics.win_rate_pct) }}%</div>
</div>
<div class="col-md-2 text-center">
<div class="text-muted small">Sharpe</div>
<div class="fs-5">{{ "{:.2f}".format(s.metrics.sharpe_ratio) }}</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<span class="text-muted small">Trades:</span> {{ s.metrics.num_trades }} ({{ s.metrics.round_trips }} round-trips)
</div>
</div>
<!-- Monthly P&L Table -->
<h6>Monthly P&L</h6>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Month</th>
<th class="text-end">Start Equity</th>
<th class="text-end">End Equity</th>
<th class="text-end">P&L ($)</th>
<th class="text-end">P&L (%)</th>
<th style="width:30%">Performance</th>
</tr>
</thead>
<tbody>
{% for m in s.monthly_pnl %}
<tr>
<td><strong>{{ m.month }}</strong></td>
<td class="text-end">${{ "{:,.2f}".format(m.start_equity) }}</td>
<td class="text-end">${{ "{:,.2f}".format(m.end_equity) }}</td>
<td class="text-end {% if m.pnl >= 0 %}text-success{% else %}text-danger{% endif %}">
{{ "{:+,.2f}".format(m.pnl) }}
</td>
<td class="text-end {% if m.pnl_pct >= 0 %}text-success{% else %}text-danger{% endif %}">
{{ "{:+.2f}".format(m.pnl_pct) }}%
</td>
<td>
{% set bar_width = [m.pnl_pct | abs * 10, 100] | min %}
{% if m.pnl >= 0 %}
<div class="progress" style="height: 18px;">
<div class="progress-bar bg-success" style="width: {{ bar_width }}%"></div>
</div>
{% else %}
<div class="progress" style="height: 18px;">
<div class="progress-bar bg-danger" style="width: {{ bar_width }}%"></div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}fx-quant{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS/1p0TIMQ77Kv3OSpcXkig1hYZLRFss0K"
crossorigin="anonymous">
<style>
body { background: #f8f9fa; }
.navbar-brand { font-weight: 700; letter-spacing: 1px; }
.card { margin-bottom: 1rem; }
.table-sm td, .table-sm th { font-size: 0.85rem; }
.kill-active { background: #dc3545; color: #fff; padding: 4px 10px; border-radius: 4px; }
.kill-inactive { background: #198754; color: #fff; padding: 4px 10px; border-radius: 4px; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container">
<a class="navbar-brand" href="/">fx-quant</a>
<div class="navbar-nav">
<a class="nav-link" href="/">Status</a>
<a class="nav-link" href="/backtest">Backtest</a>
<a class="nav-link" href="/config">Config</a>
<a class="nav-link" href="/logs">Logs</a>
</div>
</div>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
{% extends "base.html" %}
{% block title %}fx-quant — Config{% endblock %}
{% block content %}
<h4 class="mb-3">Configuration Editor</h4>
<form method="post">
<div class="row">
<!-- Instruments & Data -->
<div class="col-md-6">
<div class="card">
<div class="card-header"><strong>Instruments & Data</strong></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Instruments (comma-separated)</label>
<input type="text" class="form-control" name="instruments"
value="{{ cfg.brokers[0].instruments | join(', ') }}">
<small class="text-muted">Valid: {{ valid_instruments[:8] | join(', ') }}, ...</small>
</div>
<div class="mb-3">
<label class="form-label">Granularities (comma-separated)</label>
<input type="text" class="form-control" name="granularities"
value="{{ cfg.data.candle_granularities | join(', ') }}">
</div>
<div class="mb-3">
<label class="form-label">Candle Count</label>
<input type="number" class="form-control" name="candle_count"
value="{{ cfg.data.candle_count }}" min="50" max="5000">
</div>
</div>
</div>
</div>
<!-- Strategy -->
<div class="col-md-6">
<div class="card">
<div class="card-header"><strong>Strategy</strong></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Rule</label>
<input type="text" class="form-control" name="strategy_rule"
value="{{ cfg.strategy.rule }}">
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">SMA Short</label>
<input type="number" class="form-control" name="sma_short"
value="{{ cfg.strategy.params.short }}" min="1">
</div>
<div class="col-6 mb-3">
<label class="form-label">SMA Long</label>
<input type="number" class="form-control" name="sma_long"
value="{{ cfg.strategy.params.long }}" min="2">
</div>
</div>
<div class="mb-3">
<label class="form-label">Trade Size (% of equity)</label>
<input type="number" class="form-control" name="trade_size_pct"
value="{{ cfg.strategy.trade_size_pct_of_equity }}" step="0.001" min="0.001" max="0.1">
</div>
<div class="mb-3">
<label class="form-label">Max Drawdown (%)</label>
<input type="number" class="form-control" name="max_drawdown_pct"
value="{{ cfg.strategy.max_drawdown_pct }}" step="0.01" min="0.01" max="0.5">
</div>
</div>
</div>
</div>
</div>
<div class="row mt-2">
<!-- Features -->
<div class="col-md-6">
<div class="card">
<div class="card-header"><strong>Features</strong></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">SMA Windows (comma-separated)</label>
<input type="text" class="form-control" name="sma_windows"
value="{{ cfg.features.sma_windows | join(', ') }}">
</div>
<div class="mb-3">
<label class="form-label">EMA Windows (comma-separated)</label>
<input type="text" class="form-control" name="ema_windows"
value="{{ cfg.features.ema_windows | join(', ') }}">
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">RSI Period</label>
<input type="number" class="form-control" name="rsi_period"
value="{{ cfg.features.rsi_period }}" min="2">
</div>
<div class="col-6 mb-3">
<label class="form-label">ATR Period</label>
<input type="number" class="form-control" name="atr_period"
value="{{ cfg.features.atr_period }}" min="2">
</div>
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">VWAP Window</label>
<input type="number" class="form-control" name="vwap_window"
value="{{ cfg.features.vwap_window }}" min="2">
</div>
<div class="col-6 mb-3">
<label class="form-label">Volatility Window</label>
<input type="number" class="form-control" name="volatility_window"
value="{{ cfg.features.volatility_window }}" min="2">
</div>
</div>
</div>
</div>
</div>
<!-- AI Settings -->
<div class="col-md-6">
<div class="card">
<div class="card-header"><strong>AI Settings</strong></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Confidence Threshold</label>
<input type="number" class="form-control" name="confidence_threshold"
value="{{ cfg.ai.confidence_threshold }}" step="0.01" min="0" max="1">
</div>
<div class="mb-3">
<label class="form-label">Ensemble Models (comma-separated)</label>
<input type="text" class="form-control" name="ensemble_models"
value="{{ cfg.ai.ensemble_models | join(', ') }}">
</div>
<div class="row">
<div class="col-4 mb-3">
<label class="form-label">RSI Overbought</label>
<input type="number" class="form-control" name="rsi_overbought"
value="{{ cfg.ai.sanity_checks.rsi_overbought }}" min="50" max="100">
</div>
<div class="col-4 mb-3">
<label class="form-label">RSI Oversold</label>
<input type="number" class="form-control" name="rsi_oversold"
value="{{ cfg.ai.sanity_checks.rsi_oversold }}" min="0" max="50">
</div>
<div class="col-4 mb-3">
<label class="form-label">Vol Multiplier</label>
<input type="number" class="form-control" name="volatility_multiplier"
value="{{ cfg.ai.sanity_checks.volatility_multiplier }}" step="0.1" min="1" max="10">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-2">
<!-- Execution -->
<div class="col-md-6">
<div class="card">
<div class="card-header"><strong>Execution</strong></div>
<div class="card-body">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="paper_mode" id="paper_mode"
{{ "checked" if cfg.execution.paper_mode }}>
<label class="form-check-label" for="paper_mode">Paper Mode</label>
</div>
<div class="mb-3">
<label class="form-label">Canary Size (%)</label>
<input type="number" class="form-control" name="canary_size_pct"
value="{{ cfg.execution.canary_size_pct }}" step="0.001" min="0.001" max="0.1">
</div>
<div class="mb-3">
<label class="form-label">Max Positions</label>
<input type="number" class="form-control" name="max_positions"
value="{{ cfg.execution.max_positions }}" min="1" max="20">
</div>
<div class="mb-3">
<label class="form-label">Interval (seconds)</label>
<input type="number" class="form-control" name="interval_seconds"
value="{{ cfg.execution.interval_seconds }}" min="10" max="3600">
</div>
</div>
</div>
</div>
</div>
<div class="mt-3 mb-4">
<button type="submit" class="btn btn-primary btn-lg">Save &amp; Signal Reload</button>
<a href="/config" class="btn btn-secondary btn-lg ms-2">Reset</a>
</div>
</form>
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}fx-quant — Status{% endblock %}
{% block content %}
<div class="row">
<!-- Mode & Kill Switch -->
<div class="col-md-6">
<div class="card">
<div class="card-header"><strong>Trading Status</strong></div>
<div class="card-body">
<table class="table table-borderless mb-3">
<tr>
<td>Mode</td>
<td><strong>{{ "PAPER" if cfg.execution.paper_mode else "LIVE" }}</strong></td>
</tr>
<tr>
<td>Strategy</td>
<td>{{ cfg.strategy.rule }} ({{ cfg.strategy.params.short }}/{{ cfg.strategy.params.long }})</td>
</tr>
<tr>
<td>Instruments</td>
<td>{{ cfg.brokers[0].instruments | join(", ") }}</td>
</tr>
<tr>
<td>Granularity</td>
<td>{{ cfg.data.candle_granularities | join(", ") }}</td>
</tr>
<tr>
<td>Loop Interval</td>
<td>{{ cfg.execution.interval_seconds }}s</td>
</tr>
<tr>
<td>Max Positions</td>
<td>{{ cfg.execution.max_positions }}</td>
</tr>
<tr>
<td>AI Confidence</td>
<td>{{ cfg.ai.confidence_threshold }}</td>
</tr>
</table>
</div>
</div>
</div>
<!-- Kill Switch -->
<div class="col-md-6">
<div class="card">
<div class="card-header"><strong>Kill Switch</strong></div>
<div class="card-body text-center">
{% if kill_active %}
<p class="kill-active d-inline-block mb-3">KILL SWITCH ACTIVE</p>
<form method="post" action="/killswitch">
<input type="hidden" name="action" value="deactivate">
<button class="btn btn-success btn-lg" onclick="return confirm('Resume trading?')">
Deactivate Kill Switch
</button>
</form>
{% else %}
<p class="kill-inactive d-inline-block mb-3">Trading Active</p>
<form method="post" action="/killswitch">
<input type="hidden" name="action" value="activate">
<button class="btn btn-danger btn-lg" onclick="return confirm('STOP ALL TRADING?')">
Activate Kill Switch
</button>
</form>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card mt-3">
<div class="card-header"><strong>Recent Activity</strong> (last 10 orders)</div>
<div class="card-body p-0">
{% if recent_orders %}
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Time</th><th>Instrument</th><th>Side</th><th>Units</th>
<th>Price</th><th>Mode</th><th>Status</th>
</tr>
</thead>
<tbody>
{% for row in recent_orders | reverse %}
<tr>
<td>{{ row[0][:19] }}</td>
<td>{{ row[1] }}</td>
<td>{{ row[2] }}</td>
<td>{{ row[3] }}</td>
<td>{{ row[4] }}</td>
<td>{{ row[6] }}</td>
<td>{{ row[7] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="p-3 text-muted mb-0">No orders yet.</p>
{% endif %}
</div>
</div>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}fx-quant — Logs{% endblock %}
{% block content %}
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#orders" type="button">
Order Log ({{ order_rows | length }})
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#ai" type="button">
AI Decisions ({{ ai_rows | length }})
</button>
</li>
</ul>
<div class="tab-content">
<!-- Order Log -->
<div class="tab-pane fade show active" id="orders">
{% if order_rows %}
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>{% for h in order_headers %}<th>{{ h }}</th>{% endfor %}</tr>
</thead>
<tbody>
{% for row in order_rows %}
<tr>{% for cell in row %}<td>{{ cell }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No order log entries.</p>
{% endif %}
</div>
<!-- AI Decisions -->
<div class="tab-pane fade" id="ai">
{% if ai_rows %}
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>{% for h in ai_headers %}<th>{{ h }}</th>{% endfor %}</tr>
</thead>
<tbody>
{% for row in ai_rows %}
<tr>{% for cell in row %}<td>{{ cell }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No AI decision entries.</p>
{% endif %}
</div>
</div>
{% endblock %}