Compare commits

...

8 Commits

Author SHA1 Message Date
github-actions[bot] a757eb4b79 chore(master): release 1.2.2 (#21)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-19 15:47:03 +02:00
TPTBusiness 1dc44d33ed chore(release): reset version to 1.2.1 2026-04-19 15:32:47 +02:00
TPTBusiness e672433305 chore(release): use patch bumps for feat commits before v1.0
Add release-please-config.json with bump-patch-for-minor-pre-major=true
so feat: commits produce patch bumps (2.2.0 → 2.2.1) instead of minor
bumps (2.2.0 → 2.3.0) while the project is pre-1.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 15:03:25 +02:00
TPTBusiness f875439105 feat(strategies): make OOS validation mandatory in strategy generator
OOS split is now enforced — no fallback to IS metrics. Strategies are
rejected if OOS data is missing or OOS sharpe/monthly <= 0. Feedback
to LLM now includes OOS metrics so it learns to build generalising strategies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:54:56 +02:00
TPTBusiness 4afd03dbaf feat(backtest): add walk-forward OOS validation to backtest_signal_ftmo
Split IS (2020-2023) and OOS (2024-2026) periods with independent FTMO
simulations. Strategy acceptance now requires OOS sharpe > 0 and
OOS monthly return > 0 to prevent overfitting. OOS metrics stored in
strategy JSON summary and CSV reports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 12:53:00 +02:00
TPTBusiness 90b36c174d feat(scripts): add full file logging to strategy generation and rebacktest scripts
Each script now creates a timestamped log file in git_ignore_folder/logs/,
captures all logging calls and Rich console output via _TeeFile, and prints
the log path at startup for easy tail access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:37:03 +02:00
TPTBusiness 5da3ba6752 feat(backtest): use backtest_signal_ftmo in strategy orchestrator and optuna optimizer 2026-04-18 15:29:39 +02:00
TPTBusiness d8b5bc4237 feat(backtest): add FTMO-realistic backtest mode with leverage, daily/total loss limits and realistic EUR/USD costs 2026-04-18 15:27:41 +02:00
11 changed files with 931 additions and 63 deletions
+2 -1
View File
@@ -14,5 +14,6 @@ jobs:
steps:
- uses: googleapis/release-please-action@v4
with:
release-type: python
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
+3
View File
@@ -0,0 +1,3 @@
{
".": "1.2.2"
}
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## [1.2.2](https://github.com/TPTBusiness/Predix/compare/v1.2.1...v1.2.2) (2026-04-19)
### Documentation
* **claude:** auto-merge release-please PR after every push ([f500917](https://github.com/TPTBusiness/Predix/commit/f500917b699ee78dc676e84e01574d49bdc8e796))
## [2.2.0](https://github.com/TPTBusiness/Predix/compare/v2.1.0...v2.2.0) (2026-04-18)
+10 -1
View File
@@ -5,13 +5,22 @@ from .risk_management import CorrelationAnalyzer, PortfolioOptimizer, AdvancedRi
from .vbt_backtest import (
DEFAULT_BARS_PER_YEAR,
DEFAULT_TXN_COST_BPS,
FTMO_INITIAL_CAPITAL,
FTMO_MAX_DAILY_LOSS,
FTMO_MAX_TOTAL_LOSS,
FTMO_MAX_LEVERAGE,
FTMO_RISK_PER_TRADE,
OOS_START_DEFAULT,
backtest_from_forward_returns,
backtest_signal,
backtest_signal_ftmo,
)
__all__ = [
'BacktestMetrics', 'FactorBacktester', 'ResultsDatabase',
'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager',
'backtest_signal', 'backtest_from_forward_returns',
'backtest_signal', 'backtest_signal_ftmo', 'backtest_from_forward_returns',
'DEFAULT_BARS_PER_YEAR', 'DEFAULT_TXN_COST_BPS',
'FTMO_INITIAL_CAPITAL', 'FTMO_MAX_DAILY_LOSS', 'FTMO_MAX_TOTAL_LOSS',
'FTMO_MAX_LEVERAGE', 'FTMO_RISK_PER_TRADE', 'OOS_START_DEFAULT',
]
+185 -1
View File
@@ -32,10 +32,22 @@ except ImportError:
VBT_AVAILABLE = False
DEFAULT_TXN_COST_BPS = 1.5
# 2.35 pip realistic EUR/USD cost: 1.5 spread + 0.5 slippage + 0.35 commission
# At EUR/USD ≈ 1.10: 2.35 pip * (0.0001/1.10) ≈ 2.14 bps of notional.
DEFAULT_TXN_COST_BPS = 2.14
DEFAULT_BARS_PER_YEAR = 252 * 1440 # 252 trading days * 1440 min/day = 362,880
EXTREME_BAR_THRESHOLD = 0.05 # |ret| > 5% on a single 1-min bar → suspicious
# FTMO 100k account rules (enforced in backtest_signal when ftmo=True)
FTMO_INITIAL_CAPITAL = 100_000.0
FTMO_MAX_DAILY_LOSS = 0.05 # 5% of initial → block new trades rest of day
FTMO_MAX_TOTAL_LOSS = 0.10 # 10% of initial → simulation ends
# Risk-based position sizing: 0.5% equity risk per trade, 10-pip stop, max 1:30 leverage
FTMO_RISK_PER_TRADE = 0.005
FTMO_STOP_PIPS = 10
FTMO_PIP = 0.0001
FTMO_MAX_LEVERAGE = 30
def _compute_trade_pnl(position: pd.Series, strategy_returns: pd.Series) -> pd.Series:
"""
@@ -259,6 +271,178 @@ def backtest_signal(
return result
def _apply_ftmo_mask(
signal: pd.Series,
close: pd.Series,
leverage: float,
txn_cost_bps: float,
) -> tuple[pd.Series, dict]:
"""
Apply FTMO daily/total loss rules to a signal series.
Returns a masked signal (positions zeroed after each limit breach) and
a dict of FTMO compliance metrics.
"""
txn_cost = txn_cost_bps / 10_000.0
position = signal.shift(1).fillna(0) * leverage
bar_ret = close.pct_change().fillna(0)
equity = FTMO_INITIAL_CAPITAL
peak_day = FTMO_INITIAL_CAPITAL
masked = signal.copy()
daily_breaches = 0
total_breached = False
total_breach_ts: Optional[pd.Timestamp] = None
current_day = None
day_start_eq = FTMO_INITIAL_CAPITAL
pos_prev = 0.0
for ts, sig_i in signal.items():
day = ts.date() if hasattr(ts, "date") else ts
if day != current_day:
current_day = day
day_start_eq = equity
pos_i = float(signal.at[ts]) * leverage
ret_i = float(bar_ret.get(ts, 0.0))
cost_i = abs(pos_i - pos_prev) * txn_cost
ret_net = pos_prev * ret_i - cost_i
equity = equity * (1.0 + ret_net / FTMO_INITIAL_CAPITAL * FTMO_INITIAL_CAPITAL / equity
if equity > 0 else 1.0)
# Simpler: track as fraction
equity += FTMO_INITIAL_CAPITAL * ret_net
pos_prev = pos_i
if total_breached:
masked.at[ts] = 0
continue
daily_loss = (equity - day_start_eq) / FTMO_INITIAL_CAPITAL
total_loss = (equity - FTMO_INITIAL_CAPITAL) / FTMO_INITIAL_CAPITAL
if daily_loss < -FTMO_MAX_DAILY_LOSS:
daily_breaches += 1
day_start_eq = -999 # block rest of day
masked.at[ts] = 0
if total_loss < -FTMO_MAX_TOTAL_LOSS:
total_breached = True
total_breach_ts = ts
masked.at[ts] = 0
return masked, {
"ftmo_daily_breaches": daily_breaches,
"ftmo_total_breached": total_breached,
"ftmo_total_breach_ts": str(total_breach_ts) if total_breach_ts else None,
"ftmo_compliant": not total_breached and daily_breaches == 0,
}
OOS_START_DEFAULT = "2024-01-01"
def backtest_signal_ftmo(
close: pd.Series,
signal: pd.Series,
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
eurusd_price: float = 1.10,
risk_pct: float = FTMO_RISK_PER_TRADE,
stop_pips: float = FTMO_STOP_PIPS,
max_leverage: float = FTMO_MAX_LEVERAGE,
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
forward_returns: Optional[pd.Series] = None,
oos_start: Optional[str] = OOS_START_DEFAULT,
) -> Dict[str, Any]:
"""
FTMO-compliant backtest of a strategy signal on EUR/USD.
Applies on top of ``backtest_signal``:
- Realistic costs: default 2.14 bps (≈ 2.35 pip spread+slippage+commission)
- Risk-based position sizing: risk_pct equity per trade, stop_pips hard stop
- Max leverage cap: max_leverage (default 1:30, FTMO standard)
- FTMO daily loss limit (5%): positions zeroed rest of day after breach
- FTMO total loss limit (10%): all positions zeroed after breach
- FTMO-specific metrics added to result dict
- Walk-forward OOS split: IS metrics (before oos_start) + OOS metrics (after)
Parameters
----------
close : pd.Series
1-min EUR/USD close prices.
signal : pd.Series
Raw strategy signal in {-1, 0, +1}.
txn_cost_bps : float
Transaction cost in bps (default 2.14 ≈ 2.35 pip on EUR/USD).
eurusd_price : float
Representative EUR/USD price for pip→bps conversion (default 1.10).
risk_pct : float
Fraction of equity risked per trade (default 0.005 = 0.5%).
stop_pips : float
Hard stop-loss distance in pips (default 10).
max_leverage : float
Maximum leverage (default 30 = FTMO 1:30).
oos_start : str or None
Start of out-of-sample period (ISO date). None disables OOS split.
"""
stop_price = stop_pips * FTMO_PIP
leverage_by_risk = risk_pct / (stop_price / eurusd_price)
leverage = min(leverage_by_risk, max_leverage)
masked_signal, ftmo_metrics = _apply_ftmo_mask(signal, close, leverage, txn_cost_bps)
result = backtest_signal(
close=close,
signal=masked_signal,
txn_cost_bps=txn_cost_bps,
bars_per_year=bars_per_year,
forward_returns=forward_returns,
)
result.update(ftmo_metrics)
result["ftmo_leverage"] = round(leverage, 2)
result["ftmo_risk_pct"] = risk_pct
result["ftmo_stop_pips"] = stop_pips
# Re-scale reported equity metrics to FTMO_INITIAL_CAPITAL
result["ftmo_end_equity"] = FTMO_INITIAL_CAPITAL * (1 + result.get("total_return", 0))
result["ftmo_monthly_profit"] = FTMO_INITIAL_CAPITAL * result.get("monthly_return", 0)
# Walk-forward OOS split
if oos_start is not None:
oos_ts = pd.Timestamp(oos_start)
is_mask = close.index < oos_ts
oos_mask = close.index >= oos_ts
def _split_bt(mask: "pd.Series[bool]", prefix: str) -> None:
if mask.sum() < 100:
return
close_s = close.loc[mask]
signal_s = signal.loc[mask] # raw signal, not masked — fresh FTMO sim per period
fwd_split = forward_returns.loc[mask] if forward_returns is not None else None
masked_s, _ = _apply_ftmo_mask(signal_s, close_s, leverage, txn_cost_bps)
split_result = backtest_signal(
close=close_s,
signal=masked_s,
txn_cost_bps=txn_cost_bps,
bars_per_year=bars_per_year,
forward_returns=fwd_split,
)
for k, v in split_result.items():
if k not in ("equity_curve", "status"):
result[f"{prefix}_{k}"] = v
_split_bt(is_mask, "is")
_split_bt(oos_mask, "oos")
result["oos_start"] = oos_start
result["is_n_bars"] = int(is_mask.sum())
result["oos_n_bars"] = int(oos_mask.sum())
return result
def backtest_from_forward_returns(
factor_values: pd.Series,
forward_returns: pd.Series,
+2 -3
View File
@@ -605,16 +605,15 @@ class OptunaOptimizer:
synthetic_close = (1 + combined_ret).cumprod() * 100.0
from rdagent.components.backtesting.vbt_backtest import (
backtest_signal,
backtest_signal_ftmo,
DEFAULT_TXN_COST_BPS,
)
import os as _os
bt = backtest_signal(
bt = backtest_signal_ftmo(
close=synthetic_close,
signal=signal,
txn_cost_bps=float(_os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS)),
freq="1min",
)
if bt.get("status") != "success":
return self._default_metrics()
@@ -854,7 +854,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
# Delegate all metric computation to the single source of truth.
# Same formulas as every other backtest path in the repo.
from rdagent.components.backtesting.vbt_backtest import (
backtest_signal,
backtest_signal_ftmo,
DEFAULT_TXN_COST_BPS,
)
@@ -868,11 +868,10 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
close_for_bt = close.reindex(signal.index).ffill()
txn_cost_bps = float(os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS))
bt = backtest_signal(
bt = backtest_signal_ftmo(
close=close_for_bt,
signal=signal,
txn_cost_bps=txn_cost_bps,
freq="1min",
)
if bt.get("status") != "success":
@@ -1265,7 +1264,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
signal = local_vars["signal"]
from rdagent.components.backtesting.vbt_backtest import (
backtest_signal,
backtest_signal_ftmo,
DEFAULT_TXN_COST_BPS,
)
@@ -1273,11 +1272,10 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
if close_for_bt is None:
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
bt = backtest_signal(
bt = backtest_signal_ftmo(
close=close_for_bt,
signal=signal,
txn_cost_bps=float(os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS)),
freq="1min",
)
if bt.get("status") != "success":
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
+12
View File
@@ -0,0 +1,12 @@
{
"release-type": "python",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"packages": {
".": {
"release-type": "python",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true
}
}
}
+229 -45
View File
@@ -55,7 +55,7 @@ if TRADING_STYLE == 'daytrading':
FORWARD_BARS = int(os.getenv('FORWARD_BARS', '12'))
MIN_IC = 0.02
MIN_SHARPE = 0.5
MIN_TRADES = 20
MIN_TRADES = 300
MAX_DRAWDOWN = -0.10
STYLE_EMOJI = '🎯 Daytrading'
STYLE_DESC = 'short-term intraday with FTMO compliance'
@@ -68,9 +68,40 @@ else:
STYLE_EMOJI = '📈 Swing'
STYLE_DESC = 'medium-term intraday'
TXN_COST_BPS = float(os.getenv('TXN_COST_BPS', '1.0'))
# Whether to use raw OHLCV-only strategies (no daily factors)
OHLCV_ONLY = os.getenv('OHLCV_ONLY', '0') == '1'
console = Console()
TXN_COST_BPS = float(os.getenv('TXN_COST_BPS', '2.14')) # 2.35 pip realistic EUR/USD costs
# ── Logging setup: everything printed goes to log file + stdout ───────────────
_LOG_DIR = Path(__file__).parent.parent / "git_ignore_folder" / "logs"
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_log_file_path = _LOG_DIR / f"gen_strategies_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
_log_file = open(_log_file_path, "w", encoding="utf-8", buffering=1) # line-buffered
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(_log_file_path, encoding="utf-8"),
],
)
class _TeeFile:
"""Writes to both stdout and log file — used as Rich Console file."""
def __init__(self, *files):
self._files = files
def write(self, data):
for f in self._files:
f.write(data)
def flush(self):
for f in self._files:
f.flush()
def fileno(self):
return self._files[0].fileno()
console = Console(file=_TeeFile(sys.stdout, _log_file), highlight=False)
# ============================================================================
# LLM Configuration (Process-safe)
@@ -153,7 +184,44 @@ def generate_single_strategy(args):
factor_list = "\n".join([f"- {f['name']} (IC={f['ic']:.4f})" for f in factor_subset])
# Optimized prompts for daytrading vs swing
if TRADING_STYLE == 'daytrading':
if TRADING_STYLE == 'daytrading' and OHLCV_ONLY:
system_prompt = """You are an expert EUR/USD intraday quant. You build strategies that work ONLY on raw price data (OHLCV), computing all indicators directly from the 1-minute close series.
CRITICAL RULES:
1. The code receives ONLY a pandas Series called 'close' (1-minute EUR/USD close prices, UTC timestamps).
2. 'factors' is NOT available compute everything from 'close' directly.
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral).
4. signal.index MUST match close.index exactly.
5. signal.name must be 'signal'.
6. Use ONLY pandas/numpy no external libraries.
7. MANDATORY: The signal MUST flip at least 300 times across the full dataset. Use low thresholds.
Allowed intraday techniques (pick 2-3 and combine):
- Session timing: London open (07:00-09:00 UTC), NY open (13:00-15:00 UTC), session overlap
- Short-window RSI (7-14 bars) on 1-min close
- EMA crossovers (fast=5-15 bars, slow=20-60 bars)
- Bollinger Bands (20-bar, 1.5σ) for mean reversion
- ATR-based volatility breakouts
- VWAP deviation (approximate with rolling mean)
- Time-of-day filters combined with momentum
Output ONLY valid JSON:
{"strategy_name": "short_name", "factor_names": [], "description": "one sentence", "code": "python code"}"""
user_prompt = f"""Create a EUR/USD 1-minute intraday strategy using ONLY the raw close price series.
{f'Previous feedback: {feedback}' if feedback else 'First attempt — be creative and combine session timing with a momentum or mean-reversion indicator!'}
Hard requirements:
- Signal must change direction at least 300 times total (~4-8 trades per trading day)
- NEVER use ffill() or forward-fill on the signal recompute fresh at every bar
- Use RSI thresholds between 35-45 (long) and 55-65 (short) NOT extreme values like 10/90
- Use EMA crossover thresholds of 0 (cross above/below) for maximum trade frequency
- Use causal indicators only: rolling windows, shift(1) NO look-ahead bias
- No factor data compute everything from 'close'
- Keep it simple: 2-3 indicators max"""
elif TRADING_STYLE == 'daytrading':
system_prompt = f"""You are an expert daytrading quant specializing in EUR/USD scalping and intraday strategies.
CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWARD_BARS} minutes):
@@ -162,25 +230,25 @@ CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWAR
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral)
4. signal.index MUST match close.index
5. signal.name must be 'signal'
6. Optimize for FREQUENT signals (many trades) since the horizon is only {FORWARD_BARS} minutes
7. Use LOWER thresholds (0.2-0.5) to generate more trades for daytrading
6. MANDATORY: signal must flip direction at least 300 times total use low thresholds (0.1-0.3)
7. Use rolling z-scores with SHORT windows (5-20 bars) and TIGHT thresholds
Output ONLY valid JSON with these fields:
{{"strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code"}}"""
user_prompt = f"""Create a EUR/USD DAYTRADING strategy ({FORWARD_BARS}-minute horizon) using these factors:
{factor_list}
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}
Requirements for daytrading:
- Use {FORWARD_BARS}-minute forward returns (not daily)
- Generate frequent signals (aim for 20+ trades in the dataset)
- Use rolling z-scores with short windows (10-30 bars)
- Apply tight thresholds (0.2-0.5) for more trades
- Combine momentum + mean-reversion effectively"""
Hard requirements:
- signal must change at least 300 times total (~4 trades/day) use thresholds of 0.1-0.3
- NEVER use ffill() or forward-fill on the signal recompute fresh at every bar
- Use rolling z-scores with windows of 5-20 bars (not 50-100), thresholds ±0.2 to ±0.5
- Combine 2 factors: one momentum, one mean-reversion
- NO global mean/std always use rolling(window).mean() with shift(1) to avoid look-ahead bias"""
else:
system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD intraday strategies.
@@ -193,7 +261,7 @@ CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWAR
Output ONLY valid JSON with these fields:
{{"strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code"}}"""
user_prompt = f"""Create a EUR/USD trading strategy using these factors:
{factor_list}
@@ -228,19 +296,27 @@ def run_backtest(close, factors_df, strategy_code):
the signal, then delegate all metric computation to the unified
``backtest_signal`` engine in the main process.
"""
if close is None or factors_df is None or len(factors_df.columns) < 2:
if close is None:
return None
if not OHLCV_ONLY and (factors_df is None or len(factors_df.columns) < 2):
return None
# Flatten MultiIndex — strategy code expects a plain DatetimeIndex
if isinstance(close.index, pd.MultiIndex):
close = close.droplevel(-1)
close = close.sort_index()
import tempfile
# Subprocess stays minimal: it only runs the untrusted strategy code
# and pickles the resulting signal. All numbers come from the shared engine.
factors_line = "" if OHLCV_ONLY else "factors = pd.read_pickle('factors.pkl')"
script = f"""
import pandas as pd
import numpy as np
close = pd.read_pickle('close.pkl')
factors = pd.read_pickle('factors.pkl')
{factors_line}
try:
{chr(10).join(' ' + l for l in strategy_code.split(chr(10)))}
@@ -258,7 +334,8 @@ signal.fillna(0).to_pickle('signal.pkl')
with tempfile.TemporaryDirectory() as td:
tdp = Path(td)
close.to_pickle(str(tdp / 'close.pkl'))
factors_df.to_pickle(str(tdp / 'factors.pkl'))
if not OHLCV_ONLY and factors_df is not None:
factors_df.to_pickle(str(tdp / 'factors.pkl'))
(tdp / 'run.py').write_text(script)
try:
@@ -276,27 +353,78 @@ signal.fillna(0).to_pickle('signal.pkl')
except Exception as e:
return {'status': 'failed', 'reason': str(e)[:200]}
# Main process: unified backtest (identical formulas everywhere).
from rdagent.components.backtesting.vbt_backtest import backtest_signal
# Main process: FTMO-realistic backtest (leverage + daily/total loss limits).
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
common = close.index.intersection(signal.index)
if len(common) < 100:
return {'status': 'failed', 'reason': f'Not enough aligned data ({len(common)} bars)'}
close_a = close.loc[common]
close_a = close.loc[common]
signal_a = signal.reindex(common).fillna(0)
# Forward returns at the configured horizon feed IC computation.
fwd_returns = close_a.pct_change(FORWARD_BARS).shift(-FORWARD_BARS)
return backtest_signal(
from rdagent.components.backtesting.vbt_backtest import OOS_START_DEFAULT
return backtest_signal_ftmo(
close=close_a,
signal=signal_a,
txn_cost_bps=TXN_COST_BPS,
freq='1min',
forward_returns=fwd_returns,
oos_start=OOS_START_DEFAULT,
)
# ============================================================================
# Threshold Tuner — relax numeric thresholds until MIN_TRADES is reached
# ============================================================================
def _rescale_thresholds(code: str, scale: float) -> str:
"""
Scale numeric literals in the strategy code that look like signal thresholds.
RSI thresholds (30-70 range) are moved toward 50.
Z-score / ratio thresholds (0.03.0 range) are multiplied by scale.
"""
import re
def replace_rsi(m):
val = float(m.group(0))
# Pull toward 50 by (1-scale) fraction
new_val = 50 + (val - 50) * scale
return f"{new_val:.1f}"
def replace_small(m):
val = float(m.group(0))
return f"{val * scale:.3f}"
# RSI-style thresholds: integers/floats between 10 and 90
code = re.sub(r'\b([1-9]\d(?:\.\d+)?)\b', replace_rsi, code)
# Small float thresholds: 0.05 2.99
code = re.sub(r'\b(0\.\d+|[12]\.\d+)\b', replace_small, code)
return code
def tune_thresholds(close, factors_df, code: str) -> tuple:
"""
Binary-search scale factor (1.0 0.05) until n_trades >= MIN_TRADES.
Returns (best_bt_result, tuned_code) where best_bt_result has max Sharpe
among all runs that hit MIN_TRADES.
"""
best_bt, best_code = None, code
for scale in [1.0, 0.7, 0.5, 0.35, 0.2, 0.1, 0.05]:
tuned = _rescale_thresholds(code, scale) if scale < 1.0 else code
bt = run_backtest(close, factors_df, tuned)
if bt is None or bt.get('status') != 'success':
continue
trades = bt.get('n_trades', 0)
sharpe = bt.get('sharpe', -999)
if trades >= MIN_TRADES:
if best_bt is None or sharpe > best_bt.get('sharpe', -999):
best_bt = bt
best_code = tuned
break # first scale that hits MIN_TRADES wins (they get looser after this)
return best_bt, best_code
# ============================================================================
# Main Parallel Strategy Generation
# ============================================================================
@@ -314,6 +442,7 @@ def main(target_count=10):
)
console.print(f"\n[bold cyan]{STYLE_EMOJI} Parallel Strategy Generation[/bold cyan]")
console.print(f"[dim]Log: {_log_file_path}[/dim]")
console.print(f" Style: {STYLE_DESC}")
console.print(f" Forward bars: {FORWARD_BARS}")
console.print(f" Target: {target_count} accepted strategies")
@@ -373,38 +502,74 @@ def main(target_count=10):
if len(accepted) >= target_count:
break
# Select random factor subset (2-5 factors)
n_factors = random.randint(2, min(5, len(factors)))
factor_subset = random.sample(factors, n_factors)
# Select random factor subset (2-5 factors) — empty for OHLCV-only mode
if OHLCV_ONLY:
factor_subset = []
else:
n_factors = random.randint(2, min(5, len(factors)))
factor_subset = random.sample(factors, n_factors)
feedback = feedback_history[-1] if feedback_history and random.random() < 0.7 else None
# Generate in main process (LLM doesn't parallelize well)
gen_result = generate_single_strategy((attempt, factor_subset, feedback, attempt))
if gen_result['status'] != 'generated':
progress.update(task, advance=1)
continue
strategy = gen_result['strategy']
# Backtest (main process - needs data access)
# Build factors DataFrame for this strategy
strat_factors = df_aligned[[f for f in strategy.get('factor_names', []) if f in df_aligned.columns]]
if len(strat_factors.columns) < 2:
progress.update(task, advance=1)
continue
bt_result = run_backtest(close_aligned, strat_factors, strategy.get('code', ''))
if OHLCV_ONLY:
strat_factors = None
bt_result = run_backtest(close, None, strategy.get('code', ''))
else:
strat_factors = df_aligned[[f for f in strategy.get('factor_names', []) if f in df_aligned.columns]]
if len(strat_factors.columns) < 2:
progress.update(task, advance=1)
continue
bt_result = run_backtest(close_aligned, strat_factors, strategy.get('code', ''))
if bt_result and bt_result.get('status') == 'success':
ic = bt_result.get('ic', 0)
sharpe = bt_result.get('sharpe', 0)
trades = bt_result.get('n_trades', 0)
dd = bt_result.get('max_drawdown', 0)
# Check acceptance criteria
if abs(ic) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN:
# If too few trades, auto-tune thresholds before giving up
original_code = strategy.get('code', '')
if trades < MIN_TRADES and bt_result.get('status') == 'success':
_log.info(f"TUNING trades={trades}<{MIN_TRADES} — trying looser thresholds")
tuned_bt, tuned_code = tune_thresholds(
close if OHLCV_ONLY else close_aligned,
None if OHLCV_ONLY else strat_factors,
original_code,
)
if tuned_bt and tuned_bt.get('n_trades', 0) >= MIN_TRADES:
bt_result = tuned_bt
strategy['code'] = tuned_code
ic = bt_result.get('ic', 0)
sharpe = bt_result.get('sharpe', 0)
trades = bt_result.get('n_trades', 0)
dd = bt_result.get('max_drawdown', 0)
_log.info(f"TUNED Sharpe={sharpe:.2f} Trades={trades}")
# OOS metrics — mandatory, no fallback to IS values
oos_sharpe = bt_result.get('oos_sharpe')
oos_monthly = bt_result.get('oos_monthly_return_pct')
oos_trades = bt_result.get('oos_n_trades', 0)
# Reject if OOS data is missing (strategy trained on data without OOS period)
if oos_sharpe is None or oos_monthly is None:
_log.info(f"REJECTED no OOS data (data ends before {OOS_START_DEFAULT}?)")
feedback_history.append(f"Rejected: no out-of-sample data after {OOS_START_DEFAULT}.")
progress.update(task, advance=1)
continue
# Check acceptance criteria — OOS must be profitable (primary filter)
if (abs(ic) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
and oos_sharpe > 0.0 and oos_monthly > 0.0):
# ACCEPT
strategy['real_backtest'] = bt_result
strategy['metrics'] = bt_result
@@ -414,6 +579,19 @@ def main(target_count=10):
'annual_return_pct': bt_result.get('annual_return_pct', 0),
'real_ic': ic, 'real_n_trades': trades, 'real_backtest_status': 'success',
'n_bars': bt_result.get('n_bars', 0), 'n_months': bt_result.get('n_months', 0),
'trading_style': TRADING_STYLE,
'ohlcv_only': OHLCV_ONLY,
'engine': 'ftmo_v2',
'txn_cost_bps': TXN_COST_BPS,
# Walk-forward OOS metrics
'oos_sharpe': bt_result.get('oos_sharpe'),
'oos_monthly_return_pct': bt_result.get('oos_monthly_return_pct'),
'oos_max_drawdown': bt_result.get('oos_max_drawdown'),
'oos_win_rate': bt_result.get('oos_win_rate'),
'oos_n_trades': bt_result.get('oos_n_trades'),
'is_sharpe': bt_result.get('is_sharpe'),
'is_monthly_return_pct': bt_result.get('is_monthly_return_pct'),
'oos_start': bt_result.get('oos_start'),
}
fname = f"{int(time.time())}_{strategy['strategy_name']}.json"
@@ -435,8 +613,14 @@ def main(target_count=10):
progress.console.print(f"[green]✓ Strategy #{len(accepted)}:[/green] {strategy['strategy_name']} "
f"IC={ic:.4f}, Sharpe={sharpe:.3f}, Trades={trades}, DD={dd:.1%}")
else:
_log.info(f"REJECTED IC={ic:.4f} Sharpe={sharpe:.2f} Trades={trades} DD={dd:.1%}")
feedback_history.append(f"Failed: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}. Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}")
oos_info = f"OOS_Sharpe={oos_sharpe:+.2f} OOS_Mon={oos_monthly:+.2f}%" if oos_sharpe is not None else ""
_log.info(f"REJECTED IC={ic:.4f} Sharpe={sharpe:.2f} Trades={trades} DD={dd:.1%} {oos_info}")
feedback_history.append(
f"Failed: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}, "
f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%. "
f"Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}, "
f"OOS_Sharpe>0 AND OOS_Monthly>0 — strategy must generalise to unseen data (2024+)."
)
progress.update(task, advance=1)
+82 -6
View File
@@ -22,9 +22,11 @@ from __future__ import annotations
import argparse
import csv
import json
import logging
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -34,13 +36,41 @@ from rich.console import Console
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from rdagent.components.backtesting.vbt_backtest import backtest_signal # noqa: E402
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo # noqa: E402
OHLCV_PATH = Path("/home/nico/Predix/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
FACTORS_VALUES_DIR = Path("/home/nico/Predix/results/factors/values")
STRATEGIES_DIR = Path("/home/nico/Predix/results/strategies_new")
console = Console()
# ── Logging setup: everything printed goes to log file + stdout ───────────────
_LOG_DIR = Path(__file__).resolve().parent.parent / "git_ignore_folder" / "logs"
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_log_file_path = _LOG_DIR / f"rebacktest_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
_log_file = open(_log_file_path, "w", encoding="utf-8", buffering=1)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(_log_file_path, encoding="utf-8"),
],
)
class _TeeFile:
"""Writes to both stdout and log file — used as Rich Console file."""
def __init__(self, *files):
self._files = files
def write(self, data):
for f in self._files:
f.write(data)
def flush(self):
for f in self._files:
f.flush()
def fileno(self):
return self._files[0].fileno()
console = Console(file=_TeeFile(sys.stdout, _log_file), highlight=False)
def load_close() -> pd.Series:
@@ -154,11 +184,10 @@ def rebacktest_one(
# Signal can arrive on either the factor index or the close index.
signal = signal.reindex(close_a.index).ffill().fillna(0)
result = backtest_signal(
result = backtest_signal_ftmo(
close=close_a,
signal=signal,
txn_cost_bps=txn_cost_bps,
freq="1min",
)
result["status_detail"] = result.pop("status")
result["status"] = "ok"
@@ -173,9 +202,13 @@ def main() -> None:
help="Strategy directory to re-backtest")
parser.add_argument("--csv", type=Path, default=None,
help="Write a CSV report to this path")
parser.add_argument("--txn-cost-bps", type=float, default=1.5)
parser.add_argument("--txn-cost-bps", type=float, default=2.14,
help="Transaction cost bps (default 2.14 ≈ 2.35 pip EUR/USD)")
parser.add_argument("--write-back", action="store_true",
help="Overwrite summary field in strategy JSON files with new results")
args = parser.parse_args()
console.print(f"[dim]Log: {_log_file_path}[/dim]")
console.print(f"[cyan]Loading OHLCV close...[/cyan]")
close = load_close()
console.print(f"[green]✓[/green] {len(close):,} 1-min bars "
@@ -207,6 +240,42 @@ def main() -> None:
bt = rebacktest_one(data, close, args.txn_cost_bps)
if args.write_back and bt.get("status") == "ok":
data["summary"] = {
"sharpe": bt.get("sharpe"),
"max_drawdown": bt.get("max_drawdown"),
"win_rate": bt.get("win_rate"),
"monthly_return_pct": bt.get("monthly_return_pct"),
"real_ic": data.get("summary", {}).get("real_ic"),
"real_n_trades": bt.get("n_trades"),
"total_return": bt.get("total_return"),
"annualized_return": bt.get("annualized_return"),
"ftmo_daily_loss_hit": bt.get("ftmo_daily_loss_hit"),
"ftmo_total_loss_hit": bt.get("ftmo_total_loss_hit"),
"trading_style": data.get("summary", {}).get("trading_style"),
"engine": "ftmo_v2",
"txn_cost_bps": args.txn_cost_bps,
# Walk-forward OOS
"is_sharpe": bt.get("is_sharpe"),
"is_monthly_return_pct": bt.get("is_monthly_return_pct"),
"oos_sharpe": bt.get("oos_sharpe"),
"oos_monthly_return_pct": bt.get("oos_monthly_return_pct"),
"oos_max_drawdown": bt.get("oos_max_drawdown"),
"oos_win_rate": bt.get("oos_win_rate"),
"oos_n_trades": bt.get("oos_n_trades"),
"oos_start": bt.get("oos_start"),
}
data["sharpe_ratio"] = bt.get("sharpe")
data["max_drawdown"] = bt.get("max_drawdown")
data["win_rate"] = bt.get("win_rate")
data["total_return"] = bt.get("total_return")
data["reevaluation_status"] = "ftmo_v2"
try:
import json as _json
f.write_text(_json.dumps(data, indent=2, ensure_ascii=False))
except Exception as _e:
logging.warning(f"write-back failed for {f.name}: {_e}")
row = {
"file": f.name,
"name": name,
@@ -220,10 +289,17 @@ def main() -> None:
"new_dd": bt.get("max_drawdown"),
"new_trades": bt.get("n_trades"),
"new_total_return": bt.get("total_return"),
"new_monthly_pct": bt.get("monthly_return_pct"),
"new_annual_return_cagr": None,
"data_quality": bt.get("data_quality_flag"),
# OOS walk-forward
"is_sharpe": bt.get("is_sharpe"),
"is_monthly_pct": bt.get("is_monthly_return_pct"),
"oos_sharpe": bt.get("oos_sharpe"),
"oos_monthly_pct": bt.get("oos_monthly_return_pct"),
"oos_dd": bt.get("oos_max_drawdown"),
"oos_trades": bt.get("oos_n_trades"),
}
# annualized CAGR is not in forward_returns wrapper; use annual_return_pct/100 proxy
if "annualized_return" in bt:
row["new_annual_return_cagr"] = bt["annualized_return"]
rows.append(row)
+395
View File
@@ -0,0 +1,395 @@
"""
Realistic backtest of all strategies in results/strategies_new/.
Costs modeled per trade:
1.5 pip spread + 0.5 pip slippage + 0.35 pip commission = 2.35 pip total
FTMO 100k rules enforced:
- Max daily loss: 5% of initial balance ($5,000) no trading rest of day if hit
- Max total loss: 10% of initial balance ($10,000) account blown, simulation ends
- Position sizing: 1% equity risk per trade, 10-pip stop (no artificial lot cap)
- Max leverage: 1:30 (EU regulation standard, FTMO default)
- Compounding: position size grows with equity each trade
Out-of-sample window: 2024-01-01 onwards (never seen during factor research).
Usage:
conda activate predix
python scripts/realistic_backtest_all.py
python scripts/realistic_backtest_all.py --target-monthly 4.0 --min-trades 50
python scripts/realistic_backtest_all.py --workers 8
"""
from __future__ import annotations
import argparse
import json
import glob
import os
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
# ── Constants ──────────────────────────────────────────────────────────────────
DATA_H5 = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
FACTOR_DIR = Path("results/factors/values")
STRAT_DIR = Path("results/strategies_new")
OUTPUT_DIR = Path("results/realistic_backtest")
PIP = 0.0001
COST_ENTRY = 2.0 * PIP # spread + slippage
COST_EXIT = 0.35 * PIP # commission
RISK_PCT = 0.01 # 1% equity risk per trade
STOP = 10 * PIP # 10-pip hard stop
MAX_LEVERAGE = 30 # 1:30 max leverage (FTMO / EU standard)
FTMO_MAX_DAILY = 0.05 # 5% max daily loss of initial balance
FTMO_MAX_TOTAL = 0.10 # 10% max total loss of initial balance
OOS_START = "2024-01-01"
def _load_market_data() -> tuple[pd.Series, str]:
raw = pd.read_hdf(DATA_H5, key="data")
instrument = raw.index.get_level_values("instrument").unique()[0]
ohlcv = raw.xs(instrument, level="instrument").rename(columns={
"$open": "open", "$high": "high", "$low": "low",
"$close": "close", "$volume": "volume",
})
return ohlcv["close"], instrument
def _load_factor(name: str, full_idx: pd.Index, instrument: str) -> pd.Series | None:
path = FACTOR_DIR / f"{name}.parquet"
if not path.exists():
return None
df = pd.read_parquet(path)
if isinstance(df.index, pd.MultiIndex):
try:
s = df.xs(instrument, level="instrument").iloc[:, 0]
except KeyError:
s = df.iloc[:, 0]
else:
s = df.iloc[:, 0]
return s.reindex(full_idx)
def _build_signal(factor_names: list[str], full_idx: pd.Index,
instrument: str, code: str) -> pd.Series | None:
"""Build composite z-score signal (same logic as the strategy code uses)."""
factors: dict[str, pd.Series] = {}
for fn in factor_names:
s = _load_factor(fn, full_idx, instrument)
if s is None:
return None
factors[fn] = s
# Try to reproduce the signal via the original strategy code
close = pd.Series(np.zeros(len(full_idx)), index=full_idx) # not used by signal code
try:
local_ns: dict = {"pd": pd, "np": np, "close": close, "factors": factors}
exec(code, local_ns) # noqa: S102
sig = local_ns.get("signal")
if sig is not None and isinstance(sig, pd.Series):
return sig.reindex(full_idx).fillna(0).astype(int)
except Exception:
pass
# Fallback: generic composite z-score (same as original loop)
composite = pd.Series(0.0, index=full_idx)
for fn, s in factors.items():
s = s.fillna(0)
std = s.std()
if std > 0:
composite += (s - s.mean()) / std
sig = pd.Series(0, index=full_idx)
sig[composite > 0.5] = 1
sig[composite < -0.5] = -1
return sig
def _run_engine(sig_arr: np.ndarray, px_arr: np.ndarray,
ts_arr: np.ndarray) -> dict:
"""
FTMO-compliant backtest engine.
Rules enforced:
- Daily loss limit: if daily PnL < -5% of initial ($5k), no new trades that day
- Total loss limit: if equity < $90k (10% below initial), simulation ends (account blown)
- Position sizing: 1% equity risk per trade, 10-pip stop, max leverage 1:30
- Full compounding: position size recalculated from current equity each trade
"""
INITIAL = 100_000.0
equity = INITIAL
peak = INITIAL
max_dd = 0.0
pos = 0
entry_px = 0.0
pos_size = 0.0
n_wins = 0
trade_rets: list[float] = []
blown = False
# Daily tracking
current_day = None
day_start_eq = INITIAL
day_blocked = False
for i in range(1, len(px_arr)):
p = float(px_arr[i])
sig_i = int(sig_arr[i])
day = ts_arr[i].astype("datetime64[D]")
# ── New day: reset daily loss tracker ────────────────────────────────
if day != current_day:
current_day = day
day_start_eq = equity
day_blocked = False
# ── Close position if signal flips ────────────────────────────────────
if pos != 0 and sig_i != pos:
exit_p = p - pos * COST_EXIT
raw_pnl = (exit_p - entry_px) * pos_size * pos
equity += raw_pnl
if equity > peak:
peak = equity
dd = (peak - equity) / peak
if dd > max_dd:
max_dd = dd
ret = raw_pnl / (pos_size * entry_px) if (pos_size * entry_px) > 0 else 0.0
trade_rets.append(ret)
if raw_pnl > 0:
n_wins += 1
pos = 0
# Check daily loss limit
if (equity - day_start_eq) / INITIAL < -FTMO_MAX_DAILY:
day_blocked = True
# Check total loss limit → account blown
if equity < INITIAL * (1 - FTMO_MAX_TOTAL):
blown = True
break
# ── Open new position (if not blocked) ───────────────────────────────
if sig_i != 0 and pos == 0 and not day_blocked and not blown:
pos = sig_i
entry_px = p + pos * COST_ENTRY
# Full compounding: size from current equity, capped by max leverage
max_by_leverage = equity * MAX_LEVERAGE / p
pos_size = min(equity * RISK_PCT / STOP, max_by_leverage)
ret_arr = np.array(trade_rets) if trade_rets else np.array([0.0])
n_trades = len(trade_rets)
total_ret = (equity - INITIAL) / INITIAL
sharpe = float("nan")
if n_trades > 1 and ret_arr.std() > 0:
sharpe = float(ret_arr.mean() / ret_arr.std() * np.sqrt(n_trades))
return dict(
end_equity=equity,
total_return=total_ret,
max_drawdown=-max_dd,
sharpe=sharpe,
n_trades=n_trades,
win_rate=n_wins / n_trades if n_trades else 0.0,
trade_rets=ret_arr,
blown=blown,
)
def _monthly_ret(total_ret: float, n_months: float) -> float:
return float((1 + total_ret) ** (1 / max(n_months, 1)) - 1)
def backtest_strategy(json_path: str, close: pd.Series, instrument: str) -> dict | None:
try:
d = json.load(open(json_path))
except Exception:
return None
factor_names = d.get("factor_names", [])
code = d.get("code", "")
name = d.get("strategy_name", Path(json_path).stem)
if not factor_names:
return None
sig = _build_signal(factor_names, close.index, instrument, code)
if sig is None:
return None
# Full period
full = _run_engine(sig.values, close.values, close.index.values)
n_days_full = (close.index[-1] - close.index[0]).days
n_months_full = n_days_full / 30.44
# OOS only
oos_mask = close.index >= OOS_START
if oos_mask.sum() < 1000:
return None
oos_close = close[oos_mask]
oos_sig = sig[oos_mask]
oos = _run_engine(oos_sig.values, oos_close.values, oos_close.index.values)
n_months_oos = (oos_close.index[-1] - oos_close.index[0]).days / 30.44
return dict(
name=name,
path=json_path,
factors=factor_names,
# Full
full_monthly_pct=_monthly_ret(full["total_return"], n_months_full) * 100,
full_annual_pct=((1 + _monthly_ret(full["total_return"], n_months_full)) ** 12 - 1) * 100,
full_dd_pct=full["max_drawdown"] * 100,
full_sharpe=full["sharpe"],
full_trades=full["n_trades"],
full_winrate=full["win_rate"] * 100,
full_blown=full["blown"],
# OOS
oos_monthly_pct=_monthly_ret(oos["total_return"], n_months_oos) * 100,
oos_annual_pct=((1 + _monthly_ret(oos["total_return"], n_months_oos)) ** 12 - 1) * 100,
oos_dd_pct=oos["max_drawdown"] * 100,
oos_sharpe=oos["sharpe"],
oos_trades=oos["n_trades"],
oos_winrate=oos["win_rate"] * 100,
oos_end_equity=oos["end_equity"],
oos_blown=oos["blown"],
n_months_oos=n_months_oos,
)
def _worker(args: tuple) -> dict | None:
json_path, close_bytes, instrument = args
close = pd.read_pickle(close_bytes) if isinstance(close_bytes, (str, Path)) else close_bytes
return backtest_strategy(json_path, close, instrument)
def main() -> None:
parser = argparse.ArgumentParser(description="Realistic backtest of all strategies")
parser.add_argument("--target-monthly", type=float, default=4.0,
help="Minimum OOS monthly return %% (default: 4.0)")
parser.add_argument("--min-trades", type=int, default=30,
help="Minimum OOS trades (default: 30)")
parser.add_argument("--max-dd", type=float, default=-8.0,
help="Maximum OOS drawdown %% (default: -8.0)")
parser.add_argument("--workers", type=int, default=4,
help="Parallel workers (default: 4)")
parser.add_argument("--top", type=int, default=20,
help="Show top N strategies (default: 20)")
args = parser.parse_args()
print(f"\nLoading market data...")
close, instrument = _load_market_data()
print(f" {close.index[0].date()}{close.index[-1].date()} | {len(close):,} bars")
print(f" OOS window: {OOS_START} onwards")
print(f" Costs: 2.35 pip/trade (1.5 spread + 0.5 slip + 0.35 comm)")
print(f" Filters: OOS monthly ≥ {args.target_monthly}% | trades ≥ {args.min_trades} | DD ≥ {args.max_dd}%\n")
json_files = sorted(glob.glob(str(STRAT_DIR / "*.json")))
print(f"Backtesting {len(json_files)} strategies with {args.workers} workers...\n")
# Save close to temp file for multiprocessing
import tempfile
tmp = tempfile.NamedTemporaryFile(suffix=".pkl", delete=False)
close.to_pickle(tmp.name)
tmp.close()
results = []
done = 0
errors = 0
try:
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futures = {
ex.submit(backtest_strategy, fp, close, instrument): fp
for fp in json_files
}
for fut in as_completed(futures):
done += 1
try:
res = fut.result()
if res is not None:
results.append(res)
except Exception:
errors += 1
if done % 100 == 0 or done == len(json_files):
print(f" {done}/{len(json_files)} done, {len(results)} valid, {errors} errors")
finally:
os.unlink(tmp.name)
if not results:
print("No valid results.")
return
df = pd.DataFrame(results)
# ── Save full results ──────────────────────────────────────────────────────
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
out_csv = OUTPUT_DIR / "all_strategies_realistic.csv"
df.sort_values("oos_monthly_pct", ascending=False).to_csv(out_csv, index=False)
print(f"\nFull results saved → {out_csv}")
# ── Filter for target ──────────────────────────────────────────────────────
hits = df[
(df["oos_monthly_pct"] >= args.target_monthly) &
(df["oos_trades"] >= args.min_trades) &
(df["oos_dd_pct"] >= args.max_dd) &
(df["oos_blown"] == False) # noqa: E712
].sort_values("oos_monthly_pct", ascending=False)
print(f"\n{'='*70}")
print(f" Strategies meeting target: OOS monthly ≥ {args.target_monthly}% | "
f"trades ≥ {args.min_trades} | DD ≥ {args.max_dd}%")
print(f" Found: {len(hits)} / {len(df)}")
print(f"{'='*70}\n")
top = hits.head(args.top)
if top.empty:
print(" No strategies met the criteria.")
# Show best available
best = df.sort_values("oos_monthly_pct", ascending=False).head(10)
print(f"\n Best available (by OOS monthly return):\n")
_print_table(best)
else:
_print_table(top)
# ── Save filtered results ──────────────────────────────────────────────────
if not hits.empty:
out_hits = OUTPUT_DIR / f"strategies_oos_{args.target_monthly}pct_monthly.csv"
hits.to_csv(out_hits, index=False)
print(f"\nFiltered results saved → {out_hits}")
# ── FTMO projection for #1 ────────────────────────────────────────────────
best_row = (hits if not hits.empty else df.sort_values("oos_monthly_pct", ascending=False)).iloc[0]
mon = best_row["oos_monthly_pct"]
dd = abs(best_row["oos_dd_pct"])
gross = 100_000 * mon / 100
challenge_m = 10 / max(mon, 0.01)
print(f"\n{'='*70}")
print(f" FTMO 100k projection — #{1}: {best_row['name']}")
print(f"{'='*70}")
print(f" OOS monthly return: {mon:+.2f}%")
print(f" Monthly gross profit: ${gross:,.0f}")
print(f" Trader share (80%): ${gross*0.8:,.0f} / month")
print(f" Trader annual (80%): ${gross*0.8*12:,.0f} / year")
print(f" OOS Max Drawdown: {-dd:.2f}% (FTMO limit: 10%)")
print(f" Challenge duration: ~{challenge_m:.1f} months to hit +10%")
print(f" FTMO safe? {'YES ✓' if dd < 8 else 'BORDERLINE ⚠' if dd < 10 else 'NO ✗'}")
def _print_table(df: pd.DataFrame) -> None:
hdr = f"{'#':>3} {'Name':<35} {'OOS Mon%':>8} {'OOS DD%':>8} {'Sharpe':>7} {'WinR%':>6} {'Trades':>7} {'Blown':>6} {'Factors'}"
print(hdr)
print("-" * len(hdr))
for i, (_, r) in enumerate(df.iterrows(), 1):
factors_str = ",".join(r["factors"][:2]) + ("" if len(r["factors"]) > 2 else "")
blown = "💥YES" if r.get("oos_blown") else " no"
print(f"{i:>3} {r['name']:<35} {r['oos_monthly_pct']:>+7.2f}% "
f"{r['oos_dd_pct']:>+7.2f}% {r['oos_sharpe']:>7.2f} "
f"{r['oos_winrate']:>5.1f}% {r['oos_trades']:>7,} {blown} {factors_str}")
if __name__ == "__main__":
main()