mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-07-29 11:27:45 +00:00
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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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.")
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 & Signal Reload</button>
|
||||
<a href="/config" class="btn btn-secondary btn-lg ms-2">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user