mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-29 16:37:43 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a757eb4b79 | |||
| 1dc44d33ed | |||
| e672433305 | |||
| f875439105 | |||
| 4afd03dbaf | |||
| 90b36c174d | |||
| 5da3ba6752 | |||
| d8b5bc4237 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "1.2.2"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.0–3.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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user