Files
fx-quant/src/param_sweep.py
T
Brent Neale f4734f57c7 Optimize strategy to SMA 50/100 with RSI 80/20 filtering
Added parameter sweep tool that tested 320 combinations across SMA periods,
trade sizes, and RSI filters. Best result: SMA 50/100 on M15 with RSI 80/20
(Sharpe 5.69, 49% win rate). Updated backtester with RSI overbought/oversold
signal filtering and config to match optimal parameters.

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

309 lines
10 KiB
Python

# src/param_sweep.py
"""
Parameter sweep for fx-quant strategy optimization.
Tests combinations of SMA periods, trade sizes, and confidence thresholds
against historical data and ranks by Sharpe ratio.
Usage:
python src/param_sweep.py
"""
import os
import json
import math
import itertools
from pathlib import Path
import pandas as pd
import numpy as np
from supabase import create_client
from config_loader import load_config, get_project_root
cfg = load_config()
# ---------------------------------------------------------------------------
# Data fetching (reuse from backtester)
# ---------------------------------------------------------------------------
def fetch_data(instrument, granularity, sb, table):
"""Fetch all candles from Supabase for one instrument/granularity."""
print(f" Fetching {instrument} / {granularity}...")
all_rows = []
page_size = 1000
offset = 0
while True:
resp = (
sb.table(table)
.select("*")
.eq("instrument", instrument)
.eq("granularity", granularity)
.order("time", desc=False)
.range(offset, offset + page_size - 1)
.execute()
)
rows = resp.data or []
all_rows.extend(rows)
if len(rows) < page_size:
break
offset += page_size
if not all_rows:
return pd.DataFrame()
df = pd.DataFrame(all_rows)
df["time"] = pd.to_datetime(df["time"])
df = df.set_index("time").sort_index()
numeric_cols = [c for c in df.columns if c not in ("instrument", "granularity")]
for col in numeric_cols:
df[col] = pd.to_numeric(df[col], errors="coerce")
print(f" Got {len(df)} rows.")
return df
# ---------------------------------------------------------------------------
# Fast backtest (no file I/O, just returns metrics)
# ---------------------------------------------------------------------------
def fast_backtest(df, sma_short, sma_long, trade_size_pct, max_dd_pct=0.05,
starting_equity=100_000.0, rsi_period=14,
rsi_overbought=None, rsi_oversold=None):
"""
Run a quick backtest with given SMA params on raw close data.
Computes SMAs on-the-fly so we're not limited to pre-computed columns.
Optionally filters entries using RSI overbought/oversold thresholds.
Returns metrics dict or None if not enough data.
"""
# Compute SMAs from close price
df = df.copy()
df["sma_s"] = df["close"].rolling(sma_short).mean()
df["sma_l"] = df["close"].rolling(sma_long).mean()
if "ret" not in df.columns:
df["ret"] = df["close"].pct_change()
# Compute RSI if filtering is enabled
rsi_values = None
if rsi_overbought is not None or rsi_oversold is not None:
delta = df["close"].diff()
up = delta.clip(lower=0)
down = -1 * delta.clip(upper=0)
ma_up = up.rolling(rsi_period).mean()
ma_down = down.rolling(rsi_period).mean()
rs = ma_up / (ma_down.replace(0, np.nan))
df["_rsi"] = 100 - (100 / (1 + rs))
df = df.dropna(subset=["sma_s", "sma_l", "ret"])
if len(df) < 100:
return None
# Generate signals
signals = np.where(df["sma_s"].values > df["sma_l"].values, 1, 0)
# Apply RSI filter: block BUY when RSI is overbought, block when oversold
if rsi_overbought is not None or rsi_oversold is not None:
rsi_vals = df["_rsi"].values
for i in range(len(signals)):
if signals[i] == 1 and not np.isnan(rsi_vals[i]):
if rsi_overbought is not None and rsi_vals[i] > rsi_overbought:
signals[i] = 0 # Block buy — overbought
if rsi_oversold is not None and rsi_vals[i] < rsi_oversold:
signals[i] = 0 # Block buy — oversold
closes = df["close"].values
rets = df["ret"].values
equity = starting_equity
peak_equity = equity
position = 0
position_size = 0.0
stopped = False
equity_curve = []
wins = 0
losses = 0
buy_equity = None
num_trades = 0
for i in range(len(df)):
sig = signals[i]
if stopped:
equity_curve.append(equity)
continue
# P&L from existing position
if position == 1 and i > 0:
equity += position_size * rets[i]
# Drawdown check
if equity > peak_equity:
peak_equity = equity
dd = (peak_equity - equity) / peak_equity if peak_equity > 0 else 0.0
if dd >= max_dd_pct:
stopped = True
if position == 1:
if equity > buy_equity:
wins += 1
else:
losses += 1
position = 0
position_size = 0.0
equity_curve.append(equity)
continue
# Position changes
if sig != position:
if sig == 1 and position == 0:
position_size = equity * trade_size_pct
buy_equity = equity
num_trades += 1
elif sig == 0 and position == 1:
num_trades += 1
if equity > buy_equity:
wins += 1
else:
losses += 1
position_size = 0.0
position = sig
equity_curve.append(equity)
# Metrics
final_equity = equity_curve[-1] if equity_curve else starting_equity
total_return_pct = ((final_equity - starting_equity) / starting_equity) * 100
eq_series = pd.Series(equity_curve, index=df.index)
peak = eq_series.cummax()
dd_series = (peak - eq_series) / peak
max_drawdown_pct = dd_series.max() * 100
round_trips = wins + losses
win_rate = (wins / round_trips * 100) if round_trips > 0 else 0.0
eq_returns = eq_series.pct_change().dropna()
sharpe = 0.0
if len(eq_returns) > 1 and eq_returns.std() > 0:
# Annualize based on bars per year (approximate)
sharpe = (eq_returns.mean() / eq_returns.std()) * math.sqrt(252 * 24 * 12)
return {
"sma_short": sma_short,
"sma_long": sma_long,
"trade_size_pct": trade_size_pct,
"rsi_overbought": rsi_overbought,
"rsi_oversold": rsi_oversold,
"starting_equity": starting_equity,
"final_equity": round(final_equity, 2),
"total_return_pct": round(total_return_pct, 4),
"max_drawdown_pct": round(max_drawdown_pct, 4),
"num_trades": num_trades,
"round_trips": round_trips,
"win_rate_pct": round(win_rate, 2),
"sharpe_ratio": round(sharpe, 4),
"stopped_out": stopped,
}
def main():
# Supabase client
supabase_url = os.getenv("SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_KEY")
sb = create_client(supabase_url, supabase_key)
table = cfg.get("supabase", {}).get("table", "fx_candles")
instruments = cfg["brokers"][0]["instruments"]
granularities = cfg["data"]["candle_granularities"]
# Parameter grid
sma_combos = [
(5, 20), (10, 30), (10, 50), (15, 50), (20, 50),
(21, 50), (10, 100), (20, 100), (50, 100), (50, 200),
]
trade_sizes = [0.01, 0.025, 0.05, 0.10]
rsi_filters = [
(None, None, "No RSI filter"),
(70, 30, "RSI 70/30"),
(75, 25, "RSI 75/25"),
(80, 20, "RSI 80/20"),
]
max_dd = 0.05
all_results = []
for instrument in instruments:
for granularity in granularities:
print(f"\n{'='*60}")
print(f"Sweeping {instrument} / {granularity}")
print(f"{'='*60}")
df = fetch_data(instrument, granularity, sb, table)
if df.empty:
continue
combos = list(itertools.product(sma_combos, trade_sizes, rsi_filters))
total = len(combos)
for idx, ((sma_s, sma_l), ts, (rsi_ob, rsi_os, rsi_label)) in enumerate(combos, 1):
result = fast_backtest(df, sma_s, sma_l, ts, max_dd,
rsi_overbought=rsi_ob, rsi_oversold=rsi_os)
if result is None:
continue
result["instrument"] = instrument
result["granularity"] = granularity
result["rsi_label"] = rsi_label
all_results.append(result)
print(f" Tested {total} combinations.")
# Sort by Sharpe ratio descending
all_results.sort(key=lambda x: x["sharpe_ratio"], reverse=True)
# Save to JSON
root = get_project_root()
out_path = root / "logs" / "param_sweep_results.json"
with open(out_path, "w") as f:
json.dump(all_results, f, indent=2)
# Print top 20
print(f"\n{'='*60}")
print(f"TOP 20 RESULTS (by Sharpe ratio)")
print(f"{'='*60}")
print(f"{'Rank':<5} {'Inst':<8} {'Gran':<5} {'SMA':>7} {'Size':>6} {'RSI':<15} {'Return':>9} {'MaxDD':>8} {'WinR':>6} {'Sharpe':>8} {'Trades':>7} {'Final Eq':>12}")
print("-" * 115)
for i, r in enumerate(all_results[:20], 1):
sma_label = f"{r['sma_short']}/{r['sma_long']}"
rsi_label = r.get("rsi_label", "None")
stop_flag = " *" if r["stopped_out"] else ""
print(f"{i:<5} {r['instrument']:<8} {r['granularity']:<5} {sma_label:>7} {r['trade_size_pct']:>6.1%} "
f"{rsi_label:<15} {r['total_return_pct']:>+8.3f}% {r['max_drawdown_pct']:>7.3f}% {r['win_rate_pct']:>5.1f}% "
f"{r['sharpe_ratio']:>8.2f} {r['round_trips']:>7} ${r['final_equity']:>11,.2f}{stop_flag}")
# Focus comparison: SMA 50/100 on M15 across RSI filters
print(f"\n{'='*60}")
print(f"FOCUS: SMA 50/100 on M15 — RSI filter comparison (10% size)")
print(f"{'='*60}")
print(f"{'RSI Filter':<20} {'Return':>9} {'MaxDD':>8} {'WinR':>6} {'Sharpe':>8} {'Trades':>7} {'Final Eq':>12}")
print("-" * 80)
focus = [r for r in all_results
if r["sma_short"] == 50 and r["sma_long"] == 100
and r["granularity"] == "M15" and r["trade_size_pct"] == 0.10]
focus.sort(key=lambda x: x["sharpe_ratio"], reverse=True)
for r in focus:
rsi_label = r.get("rsi_label", "None")
print(f"{rsi_label:<20} {r['total_return_pct']:>+8.3f}% {r['max_drawdown_pct']:>7.3f}% {r['win_rate_pct']:>5.1f}% "
f"{r['sharpe_ratio']:>8.2f} {r['round_trips']:>7} ${r['final_equity']:>11,.2f}")
print(f"\nFull results saved to: {out_path}")
print(f"Total combinations tested: {len(all_results)}")
if __name__ == "__main__":
main()