mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
cbe1c52e00
Rename all source files, scripts, tests, documentation, and configuration from Predix/predix to NexQuant/nexquant across the entire codebase.
468 lines
17 KiB
Python
468 lines
17 KiB
Python
#!/usr/bin/env python
|
|
"""
|
|
Quick Daytrading Strategy Generator with CORRECT factor alignment.
|
|
|
|
Uses forward-fill to align daily factors to 1-min frequency,
|
|
then runs fast backtests without LLM calls.
|
|
|
|
Usage:
|
|
python nexquant_quick_daytrading.py 5
|
|
python nexquant_quick_daytrading.py 10
|
|
"""
|
|
import json, time, subprocess, tempfile # nosec
|
|
from pathlib import Path
|
|
import numpy as np
|
|
import pandas as pd
|
|
from rich.console import Console
|
|
|
|
console = Console()
|
|
|
|
STRATEGIES_DIR = Path('results/strategies_new')
|
|
STRATEGIES_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
FACTOR_FILES = Path('results/factors')
|
|
VALUE_FILES = FACTOR_FILES / 'values'
|
|
OHLCV_PATH = Path('git_ignore_folder/factor_implementation_source_data/intraday_pv.h5')
|
|
|
|
# Best daytrading strategies (12-min horizon, optimized for FTMO)
|
|
DAYTRADING_COMBOS = [
|
|
{
|
|
'name': 'MomentumDivergence12min',
|
|
'factors': ['daily_close_return_96', 'daily_session_momentum_divergence_1d'],
|
|
'code': '''mom = factors['daily_close_return_96']
|
|
div = factors['daily_session_momentum_divergence_1d']
|
|
|
|
w = 20
|
|
mom_z = (mom - mom.rolling(w).mean()) / (mom.rolling(w).std() + 1e-8)
|
|
div_z = (div - div.rolling(w).mean()) / (div.rolling(w).std() + 1e-8)
|
|
|
|
composite = (mom_z - div_z).fillna(0)
|
|
signal = pd.Series(0, index=close.index, name='signal')
|
|
signal[composite > 0.3] = 1
|
|
signal[composite < -0.3] = -1
|
|
signal = signal.fillna(0).astype(int)''',
|
|
},
|
|
{
|
|
'name': 'LondonSessionScalp',
|
|
'factors': ['london_mom', 'daily_session_momentum_divergence_1d'],
|
|
'code': '''mom = factors['london_mom']
|
|
div = factors['daily_session_momentum_divergence_1d']
|
|
|
|
w = 15
|
|
mom_z = (mom - mom.rolling(w).mean()) / (mom.rolling(w).std() + 1e-8)
|
|
div_z = (div - div.rolling(w).mean()) / (div.rolling(w).std() + 1e-8)
|
|
|
|
composite = (mom_z - div_z).fillna(0)
|
|
signal = pd.Series(0, index=close.index, name='signal')
|
|
signal[composite > 0.25] = 1
|
|
signal[composite < -0.25] = -1
|
|
signal = signal.fillna(0).astype(int)''',
|
|
},
|
|
{
|
|
'name': 'TrendReversionScalp',
|
|
'factors': ['daily_ols_slope_96', 'daily_session_momentum_divergence_1d', 'DailyTrendStrength_Raw'],
|
|
'code': '''slope = factors['daily_ols_slope_96']
|
|
div = factors['daily_session_momentum_divergence_1d']
|
|
trend = factors['DailyTrendStrength_Raw']
|
|
|
|
w = 20
|
|
slope_z = (slope - slope.rolling(w).mean()) / (slope.rolling(w).std() + 1e-8)
|
|
div_z = (div - div.rolling(w).mean()) / (div.rolling(w).std() + 1e-8)
|
|
trend_z = (trend - trend.rolling(w).mean()) / (trend.rolling(w).std() + 1e-8)
|
|
|
|
composite = (0.5 * slope_z - 0.3 * div_z + 0.2 * trend_z).fillna(0)
|
|
signal = pd.Series(0, index=close.index, name='signal')
|
|
signal[composite > 0.3] = 1
|
|
signal[composite < -0.3] = -1
|
|
signal = signal.fillna(0).astype(int)''',
|
|
},
|
|
{
|
|
'name': 'VolAdjMomentum12',
|
|
'factors': ['daily_ret_vol_adj_1d', 'daily_session_momentum_divergence_1d', 'DCP'],
|
|
'code': '''vol = factors['daily_ret_vol_adj_1d']
|
|
div = factors['daily_session_momentum_divergence_1d']
|
|
dcp = factors['DCP']
|
|
|
|
w = 20
|
|
vol_z = (vol - vol.rolling(w).mean()) / (vol.rolling(w).std() + 1e-8)
|
|
div_z = (div - div.rolling(w).mean()) / (div.rolling(w).std() + 1e-8)
|
|
dcp_z = (dcp - dcp.rolling(w).mean()) / (dcp.rolling(w).std() + 1e-8)
|
|
|
|
composite = (0.5 * vol_z - 0.3 * div_z + 0.2 * dcp_z).fillna(0)
|
|
signal = pd.Series(0, index=close.index, name='signal')
|
|
signal[composite > 0.35] = 1
|
|
signal[composite < -0.35] = -1
|
|
signal = signal.fillna(0).astype(int)''',
|
|
},
|
|
{
|
|
'name': 'SessionMeanReversion',
|
|
'factors': ['session_momentum_diff', 'daily_norm_body', 'daily_c2c_return'],
|
|
'code': '''session = factors['session_momentum_diff']
|
|
body = factors['daily_norm_body']
|
|
c2c = factors['daily_c2c_return']
|
|
|
|
w = 15
|
|
sess_z = (session - session.rolling(w).mean()) / (session.rolling(w).std() + 1e-8)
|
|
body_z = (body - body.rolling(w).mean()) / (body.rolling(w).std() + 1e-8)
|
|
c2c_z = (c2c - c2c.rolling(w).mean()) / (c2c.rolling(w).std() + 1e-8)
|
|
|
|
composite = (0.5 * sess_z + 0.3 * body_z + 0.2 * c2c_z).fillna(0)
|
|
signal = pd.Series(0, index=close.index, name='signal')
|
|
signal[composite > 0.4] = 1
|
|
signal[composite < -0.4] = -1
|
|
signal = signal.fillna(0).astype(int)''',
|
|
},
|
|
{
|
|
'name': 'MomentumContinuation',
|
|
'factors': ['daily_mom', 'daily_ret_1d', 'momentum_1d'],
|
|
'code': '''mom = factors['daily_mom']
|
|
ret = factors['daily_ret_1d']
|
|
mom2 = factors['momentum_1d']
|
|
|
|
w = 12
|
|
mom_z = (mom - mom.rolling(w).mean()) / (mom.rolling(w).std() + 1e-8)
|
|
ret_z = (ret - ret.rolling(w).mean()) / (ret.rolling(w).std() + 1e-8)
|
|
mom2_z = (mom2 - mom2.rolling(w).mean()) / (mom2.rolling(w).std() + 1e-8)
|
|
|
|
composite = (0.4 * mom_z + 0.3 * ret_z + 0.3 * mom2_z).fillna(0)
|
|
signal = pd.Series(0, index=close.index, name='signal')
|
|
signal[composite > 0.2] = 1
|
|
signal[composite < -0.2] = -1
|
|
signal = signal.fillna(0).astype(int)''',
|
|
},
|
|
{
|
|
'name': 'HighFreqScalper',
|
|
'factors': ['daily_close_return_96', 'DCP', 'london_mom'],
|
|
'code': '''close_ret = factors['daily_close_return_96']
|
|
dcp = factors['DCP']
|
|
london = factors['london_mom']
|
|
|
|
w = 10
|
|
cr_z = (close_ret - close_ret.rolling(w).mean()) / (close_ret.rolling(w).std() + 1e-8)
|
|
dcp_z = (dcp - dcp.rolling(w).mean()) / (dcp.rolling(w).std() + 1e-8)
|
|
lon_z = (london - london.rolling(w).mean()) / (london.rolling(w).std() + 1e-8)
|
|
|
|
composite = (0.4 * cr_z + 0.3 * dcp_z + 0.3 * lon_z).fillna(0)
|
|
signal = pd.Series(0, index=close.index, name='signal')
|
|
signal[composite > 0.25] = 1
|
|
signal[composite < -0.25] = -1
|
|
signal = signal.fillna(0).astype(int)''',
|
|
},
|
|
{
|
|
'name': 'AdaptiveMomentumMR',
|
|
'factors': ['daily_close_return_96', 'daily_session_momentum_divergence_1d', 'daily_ols_slope_96'],
|
|
'code': '''mom = factors['daily_close_return_96']
|
|
div = factors['daily_session_momentum_divergence_1d']
|
|
slope = factors['daily_ols_slope_96']
|
|
|
|
w = 20
|
|
mom_z = (mom - mom.rolling(w).mean()) / (mom.rolling(w).std() + 1e-8)
|
|
div_z = (div - div.rolling(w).mean()) / (div.rolling(w).std() + 1e-8)
|
|
slope_z = (slope - slope.rolling(w).mean()) / (slope.rolling(w).std() + 1e-8)
|
|
|
|
# Regime detection: high momentum = trend, low = mean reversion
|
|
regime = (mom_z.abs() > 1.0).astype(float)
|
|
composite = (regime * mom_z + (1 - regime) * (-div_z) + 0.3 * slope_z).fillna(0)
|
|
signal = pd.Series(0, index=close.index, name='signal')
|
|
signal[composite > 0.4] = 1
|
|
signal[composite < -0.4] = -1
|
|
signal = signal.fillna(0).astype(int)''',
|
|
},
|
|
{
|
|
'name': 'TrendPullbackScalp',
|
|
'factors': ['daily_close_return_96', 'daily_session_momentum_divergence_1d', 'daily_norm_body'],
|
|
'code': '''mom = factors['daily_close_return_96']
|
|
div = factors['daily_session_momentum_divergence_1d']
|
|
body = factors['daily_norm_body']
|
|
|
|
w = 15
|
|
mom_z = (mom - mom.rolling(w).mean()) / (mom.rolling(w).std() + 1e-8)
|
|
div_z = (div - div.rolling(w).mean()) / (div.rolling(w).std() + 1e-8)
|
|
body_z = (body - body.rolling(w).mean()) / (body.rolling(w).std() + 1e-8)
|
|
|
|
# Enter on pullbacks (divergence against trend)
|
|
composite = (mom_z - 0.5 * div_z * mom_z.sign() + 0.2 * body_z).fillna(0)
|
|
signal = pd.Series(0, index=close.index, name='signal')
|
|
signal[composite > 0.35] = 1
|
|
signal[composite < -0.35] = -1
|
|
signal = signal.fillna(0).astype(int)''',
|
|
},
|
|
{
|
|
'name': 'IntradayMomentumBlend',
|
|
'factors': ['daily_close_return_96', 'london_mom', 'daily_session_momentum_divergence_1d', 'DCP'],
|
|
'code': '''mom = factors['daily_close_return_96']
|
|
lon = factors['london_mom']
|
|
div = factors['daily_session_momentum_divergence_1d']
|
|
dcp = factors['DCP']
|
|
|
|
w = 20
|
|
mom_z = (mom - mom.rolling(w).mean()) / (mom.rolling(w).std() + 1e-8)
|
|
lon_z = (lon - lon.rolling(w).mean()) / (lon.rolling(w).std() + 1e-8)
|
|
div_z = (div - div.rolling(w).mean()) / (div.rolling(w).std() + 1e-8)
|
|
dcp_z = (dcp - dcp.rolling(w).mean()) / (dcp.rolling(w).std() + 1e-8)
|
|
|
|
composite = (0.3 * mom_z + 0.3 * lon_z - 0.2 * div_z + 0.2 * dcp_z).fillna(0)
|
|
signal = pd.Series(0, index=close.index, name='signal')
|
|
signal[composite > 0.3] = 1
|
|
signal[composite < -0.3] = -1
|
|
signal = signal.fillna(0).astype(int)''',
|
|
},
|
|
]
|
|
|
|
def load_factor_series(name):
|
|
"""Load factor parquet and return as Series with correct index."""
|
|
safe = name.replace('/','_').replace('\\','_')[:150]
|
|
pf = VALUE_FILES / f"{safe}.parquet"
|
|
if not pf.exists():
|
|
return None
|
|
|
|
df = pd.read_parquet(str(pf))
|
|
|
|
# Extract EURUSD
|
|
if df.index.names == ['datetime', 'instrument']:
|
|
df_reset = df.reset_index()
|
|
if 'instrument' in df_reset.columns:
|
|
df_eur = df_reset[df_reset['instrument'] == 'EURUSD'].copy()
|
|
df_eur = df_eur.set_index('datetime')
|
|
series = df_eur.iloc[:, -1] # Last column is the factor value
|
|
series.name = name
|
|
return series
|
|
|
|
# If single index, just return first column
|
|
series = df.iloc[:, 0]
|
|
series.name = name
|
|
return series
|
|
|
|
def main(n_strategies=5):
|
|
console.print("[bold cyan]🎯 Daytrading Strategy Generator (Quick Mode)[/bold cyan]\n")
|
|
console.print(" Style: 12-minute forward returns")
|
|
console.print(" Target: FTMO compliant (IC>0.02, Sharpe>0.5, Trades>20, DD>-10%)\n")
|
|
|
|
# Load OHLCV data
|
|
if not OHLCV_PATH.exists():
|
|
console.print(f"[red]✗ OHLCV data not found: {OHLCV_PATH}[/red]")
|
|
return
|
|
|
|
ohlcv = pd.read_hdf(str(OHLCV_PATH), key='data')
|
|
|
|
# Extract close prices with datetime-only index (not MultiIndex)
|
|
if '$close' in ohlcv.columns:
|
|
close = ohlcv['$close'].dropna()
|
|
elif 'close' in ohlcv.columns:
|
|
close = ohlcv['close'].dropna()
|
|
else:
|
|
close = ohlcv.select_dtypes(include=[np.number]).iloc[:, 0].dropna()
|
|
|
|
# Extract datetime from MultiIndex if present
|
|
if isinstance(close.index, pd.MultiIndex):
|
|
close_dt_idx = close.index.get_level_values('datetime')
|
|
close_series = pd.Series(close.values, index=close_dt_idx, name='close')
|
|
else:
|
|
close_series = close
|
|
|
|
close_series = close_series.dropna()
|
|
console.print(f"[green]✓[/green] Loaded {len(close_series):,} OHLCV bars")
|
|
|
|
# Load all factor series and align to close index
|
|
all_factor_series = {}
|
|
for combo in DAYTRADING_COMBOS:
|
|
for factor_name in combo['factors']:
|
|
if factor_name in all_factor_series:
|
|
continue
|
|
|
|
series = load_factor_series(factor_name)
|
|
if series is not None:
|
|
# Forward fill to match close frequency
|
|
series_ff = series.reindex(close_series.index).ffill()
|
|
all_factor_series[factor_name] = series_ff
|
|
|
|
# Create factors DataFrame
|
|
df_factors = pd.DataFrame(all_factor_series)
|
|
df_factors = df_factors.dropna(how='all')
|
|
|
|
console.print(f"[green]✓[/green] Loaded {len(df_factors.columns)} factor series")
|
|
console.print(f"[green]✓[/green] Aligned to {len(df_factors):,} bars\n")
|
|
|
|
accepted = []
|
|
|
|
for i, combo in enumerate(DAYTRADING_COMBOS[:n_strategies]):
|
|
console.print(f"[{i+1}/{n_strategies}] Testing {combo['name']}...")
|
|
|
|
# Build factor dataframe
|
|
valid_factors = [f for f in combo['factors'] if f in df_factors.columns]
|
|
if len(valid_factors) < 2:
|
|
console.print(f" ✗ Not enough valid factors")
|
|
continue
|
|
|
|
strat_factors = df_factors[valid_factors].dropna()
|
|
|
|
if len(strat_factors) < 1000:
|
|
console.print(f" ✗ Not enough data: {len(strat_factors)} bars")
|
|
continue
|
|
|
|
# Build backtest script
|
|
forward_bars = 12
|
|
strategy_code = combo['code']
|
|
|
|
script = f"""
|
|
import pandas as pd
|
|
import numpy as np
|
|
import json
|
|
|
|
close = pd.read_pickle('close.pkl') # nosec
|
|
factors = pd.read_pickle('factors.pkl') # nosec
|
|
|
|
# Execute strategy
|
|
try:
|
|
{chr(10).join(' ' + l for l in strategy_code.split(chr(10)))}
|
|
except Exception as e:
|
|
print(f"ERROR: {{e}}")
|
|
exit(1)
|
|
|
|
if 'signal' not in dir():
|
|
print("ERROR: No signal generated")
|
|
exit(1)
|
|
|
|
signal = signal.fillna(0)
|
|
|
|
# Align
|
|
common_idx = close.index.intersection(signal.index)
|
|
close = close.loc[common_idx]
|
|
signal = signal.loc[common_idx]
|
|
|
|
# Forward returns (12-min horizon for daytrading)
|
|
FORWARD_BARS = {forward_bars}
|
|
returns_fwd = close.pct_change(FORWARD_BARS).shift(-FORWARD_BARS)
|
|
signal_aligned = signal.loc[returns_fwd.dropna().index]
|
|
fwd_returns = returns_fwd.loc[signal_aligned.index]
|
|
|
|
if len(signal_aligned) < 100 or len(fwd_returns) < 100:
|
|
print("ERROR: Not enough data")
|
|
exit(1)
|
|
|
|
# Metrics
|
|
ic = signal_aligned.corr(fwd_returns)
|
|
strategy_returns = signal_aligned * fwd_returns
|
|
sharpe = strategy_returns.mean() / strategy_returns.std() * np.sqrt(252 * 1440 / {forward_bars}) if strategy_returns.std() > 0 else 0
|
|
|
|
cum = (1 + strategy_returns).cumprod()
|
|
running_max = cum.expanding().max()
|
|
drawdown = (cum - running_max) / running_max.replace(0, np.nan)
|
|
max_dd = drawdown.min() if len(drawdown) > 0 else 0
|
|
|
|
win_rate = (strategy_returns > 0).sum() / len(strategy_returns) if len(strategy_returns) > 0 else 0
|
|
n_trades = int((signal_aligned != signal_aligned.shift(1)).sum())
|
|
total_return = cum.iloc[-1] - 1
|
|
n_bars = len(strategy_returns)
|
|
n_months = n_bars / (252 * 1440 / {forward_bars} / 12) if n_bars > 0 else 1
|
|
monthly_return = (1 + total_return) ** (1 / n_months) - 1 if n_months > 0 and (1 + total_return) > 0 else total_return
|
|
|
|
result = {{
|
|
"status": "success",
|
|
"sharpe": float(sharpe),
|
|
"max_drawdown": float(max_dd) if not np.isnan(max_dd) else -0.20,
|
|
"win_rate": float(win_rate),
|
|
"ic": float(ic) if not np.isnan(ic) else 0,
|
|
"n_trades": n_trades,
|
|
"total_return": float(total_return),
|
|
"monthly_return_pct": float(monthly_return * 100),
|
|
"n_bars": int(n_bars),
|
|
"n_months": float(n_months),
|
|
"signal_long": int((signal_aligned == 1).sum()),
|
|
"signal_short": int((signal_aligned == -1).sum()),
|
|
"signal_neutral": int((signal_aligned == 0).sum()),
|
|
}}
|
|
|
|
print(json.dumps(result))
|
|
"""
|
|
|
|
# Run backtest
|
|
import tempfile
|
|
with tempfile.TemporaryDirectory() as td:
|
|
tdp = Path(td)
|
|
strat_close = close_series.loc[strat_factors.index]
|
|
strat_close.to_pickle(str(tdp / 'close.pkl')) # nosec
|
|
strat_factors.to_pickle(str(tdp / 'factors.pkl')) # nosec
|
|
|
|
script_path = tdp / 'run.py'
|
|
script_path.write_text(script)
|
|
|
|
try:
|
|
result_proc = subprocess.run( # nosec B603
|
|
[sys.executable, str(script_path)],
|
|
capture_output=True, text=True, timeout=60,
|
|
cwd=str(tdp)
|
|
)
|
|
|
|
if result_proc.returncode != 0:
|
|
console.print(f" ✗ Failed: {result_proc.stderr[:200]}")
|
|
continue
|
|
|
|
result = None
|
|
for line in result_proc.stdout.strip().split('\n'):
|
|
try:
|
|
result = json.loads(line)
|
|
break
|
|
except:
|
|
continue
|
|
|
|
if not result or result.get('status') != 'success':
|
|
console.print(f" ✗ Invalid result")
|
|
continue
|
|
|
|
except subprocess.TimeoutExpired: # nosec
|
|
console.print(f" ✗ Timeout")
|
|
continue
|
|
except Exception as e:
|
|
console.print(f" ✗ Error: {e}")
|
|
continue
|
|
|
|
ic = result.get('ic', 0)
|
|
sharpe = result.get('sharpe', 0)
|
|
trades = result.get('n_trades', 0)
|
|
dd = result.get('max_drawdown', 0)
|
|
|
|
# FTMO criteria
|
|
if abs(ic) > 0.02 and sharpe > 0.5 and trades > 20 and dd > -0.10:
|
|
strategy = {
|
|
'strategy_name': combo['name'],
|
|
'factor_names': combo['factors'],
|
|
'description': f"Daytrading strategy combining {', '.join(combo['factors'])}",
|
|
'code': combo['code'],
|
|
'real_backtest': result,
|
|
'metrics': result,
|
|
'summary': {
|
|
'sharpe': sharpe,
|
|
'max_drawdown': dd,
|
|
'win_rate': result.get('win_rate', 0),
|
|
'monthly_return_pct': result.get('monthly_return_pct', 0),
|
|
'real_ic': ic,
|
|
'real_n_trades': trades,
|
|
'forward_bars': 12,
|
|
'trading_style': 'daytrading',
|
|
}
|
|
}
|
|
|
|
fname = f"{int(time.time())}_{combo['name']}.json"
|
|
with open(STRATEGIES_DIR / fname, 'w') as f:
|
|
json.dump(strategy, f, indent=2, ensure_ascii=False)
|
|
|
|
accepted.append(strategy)
|
|
console.print(f" ✓ [green]ACCEPT[/green]: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}")
|
|
else:
|
|
console.print(f" ✗ [red]REJECT[/red]: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}")
|
|
|
|
console.print(f"\n[bold green]✓ {len(accepted)}/{n_strategies} strategies accepted[/bold green]\n")
|
|
|
|
if accepted:
|
|
console.print("[bold]Results:[/bold]")
|
|
for s in accepted:
|
|
bt = s['real_backtest']
|
|
console.print(f" • {s['strategy_name']:30s} IC={bt['ic']:.4f} Sharpe={bt['sharpe']:.2f} "
|
|
f"Monthly={bt['monthly_return_pct']:.2f}% Trades={bt['n_trades']}")
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
n = int(sys.argv[1]) if len(sys.argv) > 1 else 5
|
|
main(n)
|