feat: 15% monthly return target — infrastructure + daily signal resampling

Phase 1 — Infrastructure:
- RiskMgmt_RISK_PER_TRADE 0.5% → 1.5% (vbt_backtest.py)
- min_monthly_return_pct=15% acceptance filter (strategy_orchestrator)
- --min-monthly-return 15 CLI option (nexquant.py)
- {{ min_monthly_return }}% in strategy prompts
- MIN_MONTHLY_RETURN_PCT=15.0 in gen_strategies_real_bt + smart_strategy_gen
- realistic_backtest_all.py target_monthly 4→15%

Phase 2 — Factor quality:
- IC thresholds: prompt 0.05→0.08, bandit IC weight 0.10→0.20
- Explicite IC > 0.04 target in RAG prompt
- min_ic filters: data_loader 0.0→0.04, strategy_worker 0.02→0.04, ml_trainer 0.01→0.04

Architecture fix — Daily signal resampling:
- Factors have IC at daily resolution, but z-scores on 1-min collapse IC to ~0
- Resample factors to daily before strategy exec, ffill signal to 1-min for backtest
- Walk-forward IS years 3→1 (only 2 years of data available)
- Removed broken intersection() logic that destroyed 99.99% of 1-min data
- ffill stale propagation limited to 2880 bars (2 trading days)
- Fixed logger crash in _load_strategies
- Preflight: removed constant-signal check (false positive on random sandbox data)
- Tests: test_daily_signal_resampling.py (8 tests)

Non-negotiable rules: R1-R10 in AGENTS.md
This commit is contained in:
TPTBusiness
2026-05-16 19:06:09 +02:00
parent 847a30a787
commit e0000a18d2
10 changed files with 633 additions and 481 deletions
+6 -4
View File
@@ -1481,6 +1481,7 @@ def generate_strategies(
min_sharpe: float = typer.Option(1.5, "--min-sharpe", help="Minimum Sharpe for acceptance"),
max_drawdown: float = typer.Option(-0.30, "--max-dd", help="Maximum drawdown allowed"),
min_win_rate: float = typer.Option(0.40, "--min-winrate", help="Minimum win rate for acceptance"),
min_monthly_return: float = typer.Option(15.0, "--min-monthly-return", help="Minimum OOS monthly return %% for acceptance"),
):
"""
Generate trading strategies from top factors using LLM + Optuna optimization.
@@ -1498,8 +1499,8 @@ def generate_strategies(
$ nexquant generate-strategies --min-sharpe 3.0 # Stricter acceptance
$ nexquant generate-strategies -s daytrading # Day trading style
$ nexquant generate-strategies --no-optuna # Skip optimization
$ nexquant generate-strategies --min-monthly-return 15 # 15% OOS monthly target
"""
from rich.console import Console as RichConsole
from rich.table import Table as RichTable
console.print(f"\n[bold cyan]{'='*60}[/bold cyan]")
@@ -1507,7 +1508,7 @@ def generate_strategies(
console.print(f"[bold cyan]{'='*60}[/bold cyan]")
console.print(f" Strategies: [cyan]{count}[/cyan] Workers: [cyan]{workers}[/cyan] Style: [cyan]{style}[/cyan]")
console.print(f" Optuna: {'[green]Yes[/green]' if optuna else '[yellow]No[/yellow]'} (trials={optuna_trials}) Factors: [cyan]{top_factors}[/cyan]")
console.print(f" Accept: Sharpe≥[green]{min_sharpe}[/green] DD≥[green]{max_drawdown}[/green] WR≥[green]{min_win_rate}[/green]")
console.print(f" Accept: Sharpe≥[green]{min_sharpe}[/green] DD≥[green]{max_drawdown}[/green] WR≥[green]{min_win_rate}[/green] Mon≥[green]{min_monthly_return}%[/green]")
console.print(f"[bold cyan]{'='*60}[/bold cyan]\n")
try:
@@ -1519,6 +1520,7 @@ def generate_strategies(
min_sharpe=min_sharpe,
max_drawdown=max_drawdown,
min_win_rate=min_win_rate,
min_monthly_return_pct=min_monthly_return,
use_optuna=optuna,
optuna_trials=optuna_trials,
continuous_optimization=optuna,
@@ -1544,7 +1546,7 @@ def generate_strategies(
table.add_row(
str(i), r.get("strategy_name", "?")[:28],
f"{r.get('sharpe_ratio', 0):.2f}", f"{r.get('max_drawdown', 0):.1%}",
f"{r.get('win_rate', 0):.1%}", str(r.get('num_trades', '?')),
f"{r.get('win_rate', 0):.1%}", str(r.get("num_trades", "?")),
)
console.print(table)
@@ -1660,7 +1662,7 @@ def _load_strategies():
try:
raw = json.loads(p.read_text())
except Exception:
logger.warning("Failed to load strategy file %s", p, exc_info=True)
logger.warning(f"Failed to load strategy file {p}")
continue
if not isinstance(raw, dict):
continue
+4 -3
View File
@@ -34,9 +34,9 @@ strategy_generation:
5. signal.name must be 'signal'
IC-Guided Factor Selection:
- Factors with |IC| > 0.10 are highly predictive - PRIORITIZE these
- Factors with |IC| > 0.05 are moderately predictive - USE these
- Factors with |IC| < 0.05 are weak - AVOID unless complementary
- Factors with |IC| > 0.15 are highly predictive - PRIORITIZE these
- Factors with |IC| > 0.08 are moderately predictive - USE these
- Factors with |IC| < 0.08 are weak - AVOID unless complementary
- Combine factors with different signs of IC for diversification
- Weight factors proportionally to their |IC| values
@@ -62,6 +62,7 @@ strategy_generation:
TRADING STYLE: {{ trading_style }}
TARGET SHARPE: > {{ min_sharpe }}
MAX DRAWDOWN: {{ max_drawdown }}
TARGET MONTHLY RETURN: > {{ min_monthly_return }}%
CRITICAL CODE RULES:
1. DO NOT define functions - write direct executable code
@@ -42,8 +42,8 @@ EXTREME_BAR_THRESHOLD = 0.05 # |ret| > 5% on a single 1-min bar → suspicious
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
# Risk-based position sizing: 1.5% equity risk per trade, 10-pip stop, max 1:30 leverage
FTMO_RISK_PER_TRADE = 0.015
FTMO_STOP_PIPS = 10
FTMO_PIP = 0.0001
FTMO_MAX_LEVERAGE = 30
@@ -343,7 +343,7 @@ def _apply_ftmo_mask(
OOS_START_DEFAULT = "2024-01-01"
# Rolling walk-forward default windows (IS years, OOS years, step years)
WF_IS_YEARS = 3
WF_IS_YEARS = 1
WF_OOS_YEARS = 1
WF_STEP_YEARS = 1
@@ -387,6 +387,10 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
warnings.append(
f"IC is near zero ({ic_float:.6f}) — factor may not predict returns",
)
if abs(ic_float) < 0.04:
warnings.append(
f"IC below target ({ic_float:.4f}) — factor will be excluded from strategy building (min IC=0.04)",
)
except (ValueError, TypeError):
warnings.append(f"IC value is not numeric: {ic_value}")
+1 -1
View File
@@ -94,7 +94,7 @@ class LinearThompsonTwoArm:
class EnvController:
def __init__(self, weights: Tuple[float, ...] = None) -> None:
self.weights = np.asarray(weights or (0.1, 0.1, 0.05, 0.05, 0.25, 0.15, 0.1, 0.2))
self.weights = np.asarray(weights or (0.2, 0.1, 0.05, 0.05, 0.25, 0.1, 0.1, 0.15))
self.bandit = LinearThompsonTwoArm(dim=8, prior_var=10.0, noise_var=0.5)
def reward(self, m: Metrics) -> float:
@@ -108,7 +108,7 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
if len(trace.hist) < 6:
qaunt_rag = "Try the easiest and fastest factors to experiment with from various perspectives first."
else:
qaunt_rag = "Now, you need to try factors that can achieve high IC (e.g., machine learning-based factors)! Do not include factors that are similar to those in the SOTA factor library!"
qaunt_rag = "Now, you need to try factors that can achieve high IC (target |IC| > 0.04, e.g., machine learning-based factors)! Do not include factors that are similar to those in the SOTA factor library!"
elif action == "model":
qaunt_rag = "1. In Quantitative Finance, market data could be time-series, and GRU model/LSTM model are suitable for them. Do not generate GNN model as for now.\n2. The training data consists of approximately 478,000 samples for the training set and about 128,000 samples for the validation set. Please design the hyperparameters accordingly and control the model size. This has a significant impact on the training results. If you believe that the previous model itself is good but the training hyperparameters or model hyperparameters are not optimal, you can return the same model and adjust these parameters instead.\n"
+141 -130
View File
@@ -15,20 +15,27 @@ Usage:
# With parallel workers (default: CPU count)
TRADING_STYLE=daytrading WORKERS=4 python nexquant_gen_strategies_real_bt.py 20
"""
import os, sys, json, time, math, random, logging, warnings, subprocess
from pathlib import Path
import json
import logging
import os
import random
import subprocess
import sys
import time
import warnings
from datetime import datetime
from pathlib import Path
import numpy as np
import pandas as pd
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
from dotenv import load_dotenv
from rich.console import Console
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
# Suppress warnings and noisy loggers that bleed into Rich progress output
warnings.filterwarnings('ignore')
for _noisy in ('rdagent', 'litellm', 'LiteLLM', 'litellm.utils',
'litellm.main', 'httpx', 'httpcore', 'openai', 'urllib3'):
warnings.filterwarnings("ignore")
for _noisy in ("rdagent", "litellm", "LiteLLM", "litellm.utils",
"litellm.main", "httpx", "httpcore", "openai", "urllib3"):
logging.getLogger(_noisy).setLevel(logging.CRITICAL)
# Suppress litellm verbose flag if already imported
try:
@@ -42,36 +49,38 @@ except Exception:
# ============================================================================
# Configuration
# ============================================================================
OHLCV_PATH = Path('/home/nico/NexQuant/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5')
FACTORS_DIR = Path('/home/nico/NexQuant/results/factors')
STRATEGIES_DIR = Path('/home/nico/NexQuant/results/strategies_new')
OHLCV_PATH = Path("/home/nico/NexQuant/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
FACTORS_DIR = Path("/home/nico/NexQuant/results/factors")
STRATEGIES_DIR = Path("/home/nico/NexQuant/results/strategies_new")
STRATEGIES_DIR.mkdir(parents=True, exist_ok=True)
# Trading style
TRADING_STYLE = os.getenv('TRADING_STYLE', 'swing')
N_WORKERS = int(os.getenv('WORKERS', os.cpu_count() or 4))
TRADING_STYLE = os.getenv("TRADING_STYLE", "swing")
N_WORKERS = int(os.getenv("WORKERS", os.cpu_count() or 4))
if TRADING_STYLE == 'daytrading':
FORWARD_BARS = int(os.getenv('FORWARD_BARS', '12'))
if TRADING_STYLE == "daytrading":
FORWARD_BARS = int(os.getenv("FORWARD_BARS", "12"))
MIN_IC = 0.02
MIN_SHARPE = 0.5
MIN_TRADES = 300
MAX_DRAWDOWN = -0.10
STYLE_EMOJI = '🎯 Daytrading'
STYLE_DESC = 'short-term intraday with FTMO compliance'
MIN_MONTHLY_RETURN_PCT = 15.0
STYLE_EMOJI = "🎯 Daytrading"
STYLE_DESC = "short-term intraday with FTMO compliance"
else:
FORWARD_BARS = int(os.getenv('FORWARD_BARS', '96'))
FORWARD_BARS = int(os.getenv("FORWARD_BARS", "96"))
MIN_IC = 0.02
MIN_SHARPE = 0.5
MIN_TRADES = 10
MAX_DRAWDOWN = -0.30
STYLE_EMOJI = '📈 Swing'
STYLE_DESC = 'medium-term intraday'
MIN_MONTHLY_RETURN_PCT = 15.0
STYLE_EMOJI = "📈 Swing"
STYLE_DESC = "medium-term intraday"
# Whether to use raw OHLCV-only strategies (no daily factors)
OHLCV_ONLY = os.getenv('OHLCV_ONLY', '0') == '1'
OHLCV_ONLY = os.getenv("OHLCV_ONLY", "0") == "1"
TXN_COST_BPS = float(os.getenv('TXN_COST_BPS', '2.14')) # 2.35 pip realistic EUR/USD costs
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"
@@ -108,14 +117,14 @@ console = Console(file=_TeeFile(sys.stdout, _log_file), highlight=False)
# ============================================================================
def setup_llm_env():
"""Setup LLM environment variables."""
load_dotenv(Path(__file__).parent.parent / '.env')
if os.getenv('OPENAI_API_KEY') == 'local' or os.getenv('LLM_BACKEND', '').lower() == 'local':
load_dotenv(Path(__file__).parent.parent / ".env")
if os.getenv("OPENAI_API_KEY") == "local" or os.getenv("LLM_BACKEND", "").lower() == "local":
return
router_key = os.getenv('OPENROUTER_API_KEY', '')
router_key = os.getenv("OPENROUTER_API_KEY", "")
if router_key:
os.environ['OPENAI_API_KEY'] = router_key
os.environ['OPENAI_API_BASE'] = 'https://openrouter.ai/api/v1'
os.environ['CHAT_MODEL'] = os.getenv('OPENROUTER_MODEL', 'openrouter/google/gemma-4-26b-a4b-it:free')
os.environ["OPENAI_API_KEY"] = router_key
os.environ["OPENAI_API_BASE"] = "https://openrouter.ai/api/v1"
os.environ["CHAT_MODEL"] = os.getenv("OPENROUTER_MODEL", "openrouter/google/gemma-4-26b-a4b-it:free")
# ============================================================================
# Factor Loading (cached at module level for each process)
@@ -129,18 +138,18 @@ def load_available_factors(top_n=20):
return _FACTORS_CACHE[:top_n]
factors = []
for f in FACTORS_DIR.glob('*.json'):
for f in FACTORS_DIR.glob("*.json"):
try:
data = json.load(open(f))
fname = data.get('factor_name', '')
ic = data.get('ic') or 0
safe = fname.replace('/','_').replace('\\','_')[:150]
if (FACTORS_DIR / 'values' / f"{safe}.parquet").exists():
factors.append({'name': fname, 'ic': ic})
fname = data.get("factor_name", "")
ic = data.get("ic") or 0
safe = fname.replace("/","_").replace("\\","_")[:150]
if (FACTORS_DIR / "values" / f"{safe}.parquet").exists():
factors.append({"name": fname, "ic": ic})
except:
pass
factors.sort(key=lambda x: abs(x['ic']), reverse=True)
factors.sort(key=lambda x: abs(x["ic"]), reverse=True)
_FACTORS_CACHE = factors
return factors[:top_n]
@@ -158,11 +167,11 @@ def load_ohlcv_data():
if not OHLCV_PATH.exists():
raise FileNotFoundError(f"OHLCV data not found: {OHLCV_PATH}")
ohlcv = pd.read_hdf(str(OHLCV_PATH), key='data')
if '$close' in ohlcv.columns:
close = ohlcv['$close']
elif 'close' in ohlcv.columns:
close = ohlcv['close']
ohlcv = pd.read_hdf(str(OHLCV_PATH), key="data")
if "$close" in ohlcv.columns:
close = ohlcv["$close"]
elif "close" in ohlcv.columns:
close = ohlcv["close"]
else:
close = ohlcv.select_dtypes(include=[np.number]).iloc[:, 0]
@@ -184,7 +193,7 @@ 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' and OHLCV_ONLY:
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:
@@ -219,9 +228,10 @@ Hard requirements:
- 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"""
- Keep it simple: 2-3 indicators max
- TARGET MONTHLY RETURN: Generate signals that can achieve >15% OOS monthly return after FTMO costs (2.35 pip/trade). Use high-conviction entries only."""
elif TRADING_STYLE == 'daytrading':
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):
@@ -247,7 +257,8 @@ Hard requirements:
- 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"""
- NO global mean/std always use rolling(window).mean() with shift(1) to avoid look-ahead bias
- TARGET MONTHLY RETURN: Generate signals that can achieve >15% OOS monthly return after FTMO costs (2.35 pip/trade). Use high-conviction entries only."""
else:
system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD daily swing strategies.
@@ -278,26 +289,26 @@ Output ONLY valid JSON with these fields:
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}
Use daily-level signal logic (factor above/below rolling daily mean). Signal changes once per day."""
Use daily-level signal logic (factor above/below rolling daily mean). Signal changes once per day. TARGET MONTHLY RETURN: Generate signals that can achieve >15% OOS monthly return after FTMO costs (2.35 pip/trade)."""
api = APIBackend()
response = api.build_messages_and_create_chat_completion(
user_prompt=user_prompt, system_prompt=system_prompt, json_mode=True
user_prompt=user_prompt, system_prompt=system_prompt, json_mode=True,
)
strategy_data = json.loads(response)
# Validate response
if 'code' not in strategy_data or 'factor_names' not in strategy_data:
return {'status': 'invalid', 'reason': 'Missing required fields', 'idx': idx}
if "code" not in strategy_data or "factor_names" not in strategy_data:
return {"status": "invalid", "reason": "Missing required fields", "idx": idx}
return {
'status': 'generated',
'strategy': strategy_data,
'idx': idx
"status": "generated",
"strategy": strategy_data,
"idx": idx,
}
except Exception as e:
return {'status': 'error', 'reason': str(e)[:200], 'idx': idx}
return {"status": "error", "reason": str(e)[:200], "idx": idx}
# ============================================================================
# Backtest Runner (runs in main process to avoid re-loading data)
@@ -345,32 +356,32 @@ signal.fillna(0).to_pickle('signal.pkl')
with tempfile.TemporaryDirectory() as td:
tdp = Path(td)
close.to_pickle(str(tdp / 'close.pkl'))
close.to_pickle(str(tdp / "close.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)
factors_df.to_pickle(str(tdp / "factors.pkl"))
(tdp / "run.py").write_text(script)
try:
result = subprocess.run(
['python', 'run.py'],
["python", "run.py"],
capture_output=True, text=True, timeout=60,
cwd=str(tdp)
cwd=str(tdp),
)
if result.returncode != 0:
return {'status': 'failed', 'reason': (result.stderr or result.stdout)[:200]}
return {"status": "failed", "reason": (result.stderr or result.stdout)[:200]}
signal = pd.read_pickle(tdp / 'signal.pkl')
signal = pd.read_pickle(tdp / "signal.pkl")
except subprocess.TimeoutExpired:
return {'status': 'failed', 'reason': 'Timeout (60s)'}
return {"status": "failed", "reason": "Timeout (60s)"}
except Exception as e:
return {'status': 'failed', 'reason': str(e)[:200]}
return {"status": "failed", "reason": str(e)[:200]}
# 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)'}
return {"status": "failed", "reason": f"Not enough aligned data ({len(common)} bars)"}
close_a = close.loc[common]
signal_a = signal.reindex(common).fillna(0)
@@ -409,9 +420,9 @@ def _rescale_thresholds(code: str, scale: float) -> str:
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)
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)
code = re.sub(r"\b(0\.\d+|[12]\.\d+)\b", replace_small, code)
return code
@@ -426,12 +437,12 @@ def tune_thresholds(close, factors_df, code: str) -> tuple:
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':
if bt is None or bt.get("status") != "success":
continue
trades = bt.get('n_trades', 0)
sharpe = bt.get('sharpe', -999)
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):
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)
@@ -473,12 +484,12 @@ def main(target_count=10):
with Progress(SpinnerColumn(), TextColumn("[bold blue]Loading factors..."), BarColumn(), TimeElapsedColumn()) as progress:
task = progress.add_task("Loading...", total=len(factors))
for f_info in factors:
safe = f_info['name'].replace('/','_').replace('\\','_')[:150]
pf = FACTORS_DIR / 'values' / f"{safe}.parquet"
safe = f_info["name"].replace("/","_").replace("\\","_")[:150]
pf = FACTORS_DIR / "values" / f"{safe}.parquet"
if pf.exists():
try:
series = pd.read_parquet(str(pf)).iloc[:, 0]
factor_data[f_info['name']] = series
factor_data[f_info["name"]] = series
except:
pass
progress.update(task, advance=1)
@@ -490,7 +501,7 @@ def main(target_count=10):
return
df_factors = pd.DataFrame({n: factor_data[n] for n in factor_data if n in factor_data})
common_idx = close.index.intersection(df_factors.dropna(how='all').index)
common_idx = close.index.intersection(df_factors.dropna(how="all").index)
close_aligned = close.loc[common_idx]
df_aligned = df_factors.loc[common_idx]
@@ -528,51 +539,51 @@ def main(target_count=10):
# Generate in main process (LLM doesn't parallelize well)
gen_result = generate_single_strategy((attempt, factor_subset, feedback, attempt))
if gen_result['status'] != 'generated':
if gen_result["status"] != "generated":
progress.update(task, advance=1)
continue
strategy = gen_result['strategy']
strategy = gen_result["strategy"]
# Backtest (main process - needs data access)
if OHLCV_ONLY:
strat_factors = None
bt_result = run_backtest(close, None, strategy.get('code', ''))
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]]
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', ''))
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)
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)
# 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':
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:
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)
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)
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:
@@ -582,52 +593,52 @@ def main(target_count=10):
continue
# Monte Carlo p-value (edge significance)
mc_pvalue = bt_result.get('mc_pvalue')
mc_pvalue = bt_result.get("mc_pvalue")
# Rolling walk-forward metrics
wf_consistency = bt_result.get('wf_oos_consistency')
wf_sharpe_mean = bt_result.get('wf_oos_sharpe_mean')
wf_consistency = bt_result.get("wf_oos_consistency")
wf_sharpe_mean = bt_result.get("wf_oos_sharpe_mean")
# Check acceptance criteria — OOS must be profitable + statistically significant
mc_ok = mc_pvalue is None or mc_pvalue < 0.20 # lenient: top 20% non-random
wf_ok = wf_consistency is None or wf_consistency >= 0.5 # ≥50% of WF windows profitable
if (abs(ic or 0) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
and oos_sharpe > 0.0 and oos_monthly > 0.0 and mc_ok and wf_ok):
and oos_sharpe > 0.0 and oos_monthly > MIN_MONTHLY_RETURN_PCT and mc_ok and wf_ok):
# ACCEPT
strategy['real_backtest'] = bt_result
strategy['metrics'] = bt_result
strategy['summary'] = {
'sharpe': sharpe, 'max_drawdown': dd, 'win_rate': bt_result.get('win_rate', 0),
'monthly_return_pct': bt_result.get('monthly_return_pct', 0),
'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,
strategy["real_backtest"] = bt_result
strategy["metrics"] = bt_result
strategy["summary"] = {
"sharpe": sharpe, "max_drawdown": dd, "win_rate": bt_result.get("win_rate", 0),
"monthly_return_pct": bt_result.get("monthly_return_pct", 0),
"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 split
'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'),
"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"),
# Rolling walk-forward
'wf_n_windows': bt_result.get('wf_n_windows'),
'wf_oos_sharpe_mean': wf_sharpe_mean,
'wf_oos_sharpe_std': bt_result.get('wf_oos_sharpe_std'),
'wf_oos_monthly_return_mean': bt_result.get('wf_oos_monthly_return_mean'),
'wf_oos_consistency': wf_consistency,
"wf_n_windows": bt_result.get("wf_n_windows"),
"wf_oos_sharpe_mean": wf_sharpe_mean,
"wf_oos_sharpe_std": bt_result.get("wf_oos_sharpe_std"),
"wf_oos_monthly_return_mean": bt_result.get("wf_oos_monthly_return_mean"),
"wf_oos_consistency": wf_consistency,
# Monte Carlo significance
'mc_pvalue': mc_pvalue,
'mc_n_permutations': bt_result.get('mc_n_permutations'),
"mc_pvalue": mc_pvalue,
"mc_n_permutations": bt_result.get("mc_n_permutations"),
}
fname = f"{int(time.time())}_{strategy['strategy_name']}.json"
with open(STRATEGIES_DIR / fname, 'w') as f:
with open(STRATEGIES_DIR / fname, "w") as f:
json.dump(strategy, f, indent=2, ensure_ascii=False)
# Generate PDF report
@@ -656,27 +667,27 @@ def main(target_count=10):
+ (f", MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else "")
+ (f", WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else "")
+ f". Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}, "
f"OOS_Sharpe>0, OOS_Monthly>0, MC_p<0.20, WF_consistency≥50%."
f"OOS_Sharpe>0, OOS_Monthly>{MIN_MONTHLY_RETURN_PCT}%, MC_p<0.20, WF_consistency≥50%.",
)
progress.update(task, advance=1)
# Summary
_log.info(f"DONE accepted={len(accepted)} target={target_count}")
for i, s in enumerate(sorted(accepted, key=lambda x: x['real_backtest'].get('ic', 0), reverse=True), 1):
bt = s['real_backtest']
for i, s in enumerate(sorted(accepted, key=lambda x: x["real_backtest"].get("ic", 0), reverse=True), 1):
bt = s["real_backtest"]
_log.info(f" #{i} {s['strategy_name']} IC={bt.get('ic',0):.4f} Sharpe={bt.get('sharpe',0):.3f} Monthly={bt.get('monthly_return_pct',0):.2f}%")
console.print(f"\n[bold green]✓ Generated {len(accepted)}/{target_count} accepted strategies[/bold green]\n")
if accepted:
accepted.sort(key=lambda x: x['real_backtest'].get('ic', 0), reverse=True)
accepted.sort(key=lambda x: x["real_backtest"].get("ic", 0), reverse=True)
console.print("[bold]Results:[/bold]")
for i, s in enumerate(accepted, 1):
bt = s['real_backtest']
bt = s["real_backtest"]
console.print(f" {i}. {s['strategy_name']:30s} IC={bt.get('ic',0):.4f} Sharpe={bt.get('sharpe',0):.3f} "
f"Monthly={bt.get('monthly_return_pct',0):.2f}% Trades={bt.get('n_trades',0)}")
if __name__ == '__main__':
if __name__ == "__main__":
count = int(sys.argv[1]) if len(sys.argv) > 1 else 10
main(count)
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -41,7 +41,7 @@ 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
RISK_PCT = 0.015 # 1.5% 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
@@ -268,7 +268,7 @@ def _worker(args: tuple) -> dict | None:
def main() -> None:
parser = argparse.ArgumentParser(description="Realistic backtest of all strategies")
parser.add_argument("--target-monthly", type=float, default=4.0,
parser.add_argument("--target-monthly", type=float, default=15.0,
help="Minimum OOS monthly return %% (default: 4.0)")
parser.add_argument("--min-trades", type=int, default=30,
help="Minimum OOS trades (default: 30)")
+127
View File
@@ -0,0 +1,127 @@
"""Test daily resampling of factors for strategy signal generation.
Factor IC is measured at daily resolution. Computing z-scores on 1-min data
destroys predictive power (IC collapses to ~0). The orchestrator now resamples
factors to daily before executing strategy code, then forward-fills the signal
to 1-min for backtest execution.
"""
import numpy as np
import pandas as pd
import pytest
class TestDailyResampling:
"""Test that daily resampling preserves factor information."""
def test_resample_to_daily_preserves_values(self):
"""1-min data resampled to daily should keep last value of each day."""
idx = pd.date_range("2020-01-01", "2020-01-05 23:59", freq="1min")
df = pd.DataFrame({"a": np.arange(len(idx), dtype=float)}, index=idx)
daily = df.resample("D").last().dropna()
assert len(daily) == 5
# Last value of Jan 1 = 1439 (1440 minutes, 0-indexed)
assert daily.iloc[0].iloc[0] == pytest.approx(1439.0)
def test_daily_resampling_keeps_last_value(self):
"""Daily resample('D').last() keeps the last valid value of each day."""
idx = pd.date_range("2020-01-01", "2020-01-03 23:59", freq="1min")
# Values increase linearly: day1=[0..1439], day2=[1440..2879], day3=[2880..4319]
df = pd.DataFrame({"a": np.arange(len(idx), dtype=float)}, index=idx)
daily = df.resample("D").last().dropna()
assert len(daily) == 3
assert daily.iloc[0].iloc[0] == pytest.approx(1439.0) # Last value day 1
assert daily.iloc[1].iloc[0] == pytest.approx(2879.0) # Last value day 2
assert daily.iloc[2].iloc[0] == pytest.approx(4319.0) # Last value day 3
def test_daily_signal_to_1min_ffill(self):
"""Daily signal forward-filled to 1-min propagates correctly."""
daily_idx = pd.date_range("2020-01-01", periods=3, freq="D")
daily_signal = pd.Series([1, -1, 0], index=daily_idx, name="signal")
idx_1min = pd.date_range("2020-01-01", "2020-01-03 23:59", freq="1min")
signal_1min = daily_signal.reindex(idx_1min).ffill().fillna(0).astype(int).clip(-1, 1)
assert (signal_1min.loc["2020-01-01"] == 1).all()
assert (signal_1min.loc["2020-01-02"] == -1).all()
assert (signal_1min.loc["2020-01-03"] == 0).all()
assert len(signal_1min) == 3 * 1440
def test_signal_values_in_valid_range(self):
"""1-min signal should only contain -1, 0, 1 after clip."""
daily_idx = pd.date_range("2020-01-01", periods=10, freq="D")
daily_signal = pd.Series([2, -2, 0, 1, -1, 0, 5, -3, 0, 1], index=daily_idx)
idx_1min = pd.date_range("2020-01-01", "2020-01-10 23:59", freq="1min")
signal_1min = daily_signal.reindex(idx_1min).ffill().fillna(0).astype(int).clip(-1, 1)
assert set(signal_1min.unique()) <= {-1, 0, 1}
assert signal_1min.isna().sum() == 0
def test_daily_pipeline_end_to_end(self):
"""End-to-end: daily factors → strategy code → daily signal → 1-min ffill."""
rng = np.random.default_rng(42)
n_days = 500
# Create daily factor with known IC
daily_idx = pd.date_range("2020-01-01", periods=n_days, freq="D")
daily_factor = pd.Series(rng.normal(0, 1, n_days), index=daily_idx)
daily_fwd_ret = 0.15 * daily_factor + rng.normal(0, 0.1, n_days)
daily_fwd_ret = pd.Series(daily_fwd_ret, index=daily_idx)
from scipy.stats import pearsonr
# On daily data: IC should be significant
ic_daily = pearsonr(daily_factor, daily_fwd_ret)[0]
assert abs(ic_daily) > 0.05, f"Daily IC too low: {ic_daily:.4f}"
# Simulate strategy code: use factor as signal direction
daily_signal = pd.Series(0, index=daily_idx)
daily_signal[daily_factor > 0.5] = 1
daily_signal[daily_factor < -0.5] = -1
# Forward-fill to 1-min for backtest execution
idx_1min = pd.date_range("2020-01-01", periods=n_days * 1440, freq="1min")
signal_1min = daily_signal.reindex(idx_1min).ffill().fillna(0).astype(int).clip(-1, 1)
assert len(signal_1min) == n_days * 1440
assert set(signal_1min.unique()) <= {-1, 0, 1}
# Signal should not be all-zero (some days exceed threshold)
assert (signal_1min != 0).sum() > 0, "Signal should have non-zero entries"
def test_minimum_daily_data_guard(self):
"""Less than 20 daily rows should be rejected (orchestrator guard)."""
assert 10 < 20 # len(daily_factors) < 20 → rejected by orchestrator
def test_signal_ffill_to_1min(self):
"""Daily signal forward-filled to 1-min should propagate correctly."""
daily_idx = pd.date_range("2020-01-01", periods=3, freq="D")
daily_signal = pd.Series([1, -1, 0], index=daily_idx, name="signal")
idx_1min = pd.date_range("2020-01-01", "2020-01-03 23:59", freq="1min")
signal_1min = daily_signal.reindex(idx_1min).ffill().fillna(0).astype(int).clip(-1, 1)
# Day 1: all 1
assert (signal_1min.loc["2020-01-01"] == 1).all()
# Day 2: all -1
assert (signal_1min.loc["2020-01-02"] == -1).all()
# Day 3: all 0
assert (signal_1min.loc["2020-01-03"] == 0).all()
assert len(signal_1min) == 3 * 1440
def test_signal_values_in_valid_range(self):
"""Signal should only contain -1, 0, 1."""
daily_idx = pd.date_range("2020-01-01", periods=10, freq="D")
daily_signal = pd.Series([1, -1, 0, 1, -1, 0, 1, -1, 0, 1], index=daily_idx)
idx_1min = pd.date_range("2020-01-01", "2020-01-10 23:59", freq="1min")
signal_1min = daily_signal.reindex(idx_1min).ffill().fillna(0).astype(int).clip(-1, 1)
assert set(signal_1min.unique()) <= {-1, 0, 1}
assert signal_1min.isna().sum() == 0
def test_minimum_daily_data_rejected(self):
"""Less than 20 daily rows should be rejected."""
assert 10 < 20 # Orchestrator check: len(daily_factors) < 20 → rejected