mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 23:47:46 +00:00
feat: multi_role strategy — trend filter + entry gating across TFs
- New strategy type: trend_ind(higher TF) → entry_ind(lower TF) - Entry only fires when trend confirms direction (directional gating) - 15² × 3×2 = 1,350 indicator/TF combinations - Found: MACD(30min)→ADX(15min) = Sharpe 102.37, +32.2%/month
This commit is contained in:
@@ -72,9 +72,11 @@ def _backtest_numba(prices, signals, cost=0.000264):
|
|||||||
|
|
||||||
return equity, max_dd, trade_count, wins, total_ret, sharpe, trades[:trade_count]
|
return equity, max_dd, trade_count, wins, total_ret, sharpe, trades[:trade_count]
|
||||||
|
|
||||||
TIMEFRAMES = ["15min", "30min", "1h", "4h"]
|
TIMEFRAMES = ["5min", "15min", "30min", "1h", "4h"]
|
||||||
INDICATORS_POOL = ["MACD", "RSI", "BBands", "Donchian", "Stoch", "CCI", "WillR", "ADX", "SAR", "ROC", "MOM", "AROON", "MFI", "SMA", "EMA"]
|
INDICATORS_POOL = ["MACD", "RSI", "BBands", "Donchian", "Stoch", "CCI", "WillR", "ADX", "SAR", "ROC", "MOM", "AROON", "MFI", "SMA", "EMA"]
|
||||||
STRATEGY_TYPES = ["single", "multi_tf", "portfolio"]
|
STRATEGY_TYPES = ["single", "multi_tf", "portfolio", "multi_role"]
|
||||||
|
TREND_TFS = ["30min", "1h", "4h"] # higher TFs for trend filter
|
||||||
|
ENTRY_TFS = ["5min", "15min", "30min"] # lower TFs for entry
|
||||||
MIN_SHARPE, MIN_TRADES = 0.5, 20
|
MIN_SHARPE, MIN_TRADES = 0.5, 20
|
||||||
EXPLORATION_RATE = 0.30 # 30% explore, 70% exploit
|
EXPLORATION_RATE = 0.30 # 30% explore, 70% exploit
|
||||||
|
|
||||||
@@ -125,6 +127,25 @@ def evaluate_strategy(close, hypothesis):
|
|||||||
signal = pd.Series(0, index=vote.index)
|
signal = pd.Series(0, index=vote.index)
|
||||||
signal[vote > 0.25] = 1; signal[vote < -0.25] = -1
|
signal[vote > 0.25] = 1; signal[vote < -0.25] = -1
|
||||||
|
|
||||||
|
elif hypothesis['type'] == 'multi_role':
|
||||||
|
# Trend filter (higher TF) + Entry signal (lower TF) with directional gating
|
||||||
|
trend_ind = hypothesis['trend_ind']
|
||||||
|
entry_ind = hypothesis['entry_ind']
|
||||||
|
trend_tf = hypothesis['trend_tf']
|
||||||
|
entry_tf = hypothesis['entry_tf']
|
||||||
|
# Build trend signal on higher TF, forward-fill to lower TF
|
||||||
|
trend_bars = close.resample(trend_tf).last().dropna()
|
||||||
|
trend_sig = _build_indicator_signal(trend_ind, trend_bars, hypothesis['trend_params'])
|
||||||
|
trend_sig = trend_sig.reindex(close.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||||
|
# Build entry signal on lower TF
|
||||||
|
entry_bars = close.resample(entry_tf).last().dropna()
|
||||||
|
entry_sig = _build_indicator_signal(entry_ind, entry_bars, hypothesis['entry_params'])
|
||||||
|
entry_sig = entry_sig.reindex(close.index).ffill().fillna(0).astype(int).clip(-1, 1)
|
||||||
|
# GATE: entry only fires when trend confirms direction
|
||||||
|
signal = pd.Series(0, index=close.index)
|
||||||
|
signal[(trend_sig == 1) & (entry_sig == 1)] = 1 # long trend + long entry
|
||||||
|
signal[(trend_sig == -1) & (entry_sig == -1)] = -1 # short trend + short entry
|
||||||
|
|
||||||
if signal is None or signal.nunique() <= 1:
|
if signal is None or signal.nunique() <= 1:
|
||||||
return {"sharpe": 0, "monthly_pct": 0, "max_dd": 0, "n_trades": 0, "win_rate": 0}
|
return {"sharpe": 0, "monthly_pct": 0, "max_dd": 0, "n_trades": 0, "win_rate": 0}
|
||||||
|
|
||||||
@@ -255,6 +276,18 @@ class ResearchLoop:
|
|||||||
params = self._random_params(ind)
|
params = self._random_params(ind)
|
||||||
return {'type': 'multi_tf', 'indicator': ind, 'timeframes': tfs, 'params': params,
|
return {'type': 'multi_tf', 'indicator': ind, 'timeframes': tfs, 'params': params,
|
||||||
'description': f"{ind} on {','.join(tfs)}", 'generation': 'explore'}
|
'description': f"{ind} on {','.join(tfs)}", 'generation': 'explore'}
|
||||||
|
elif stype == 'multi_role':
|
||||||
|
trend_ind = random.choice(INDICATORS_POOL)
|
||||||
|
entry_ind = random.choice(INDICATORS_POOL)
|
||||||
|
trend_tf = random.choice(TREND_TFS)
|
||||||
|
entry_tf = random.choice([t for t in ENTRY_TFS if t < trend_tf])
|
||||||
|
trend_p = self._random_params(trend_ind)
|
||||||
|
entry_p = self._random_params(entry_ind)
|
||||||
|
return {'type': 'multi_role',
|
||||||
|
'trend_ind': trend_ind, 'trend_params': trend_p, 'trend_tf': trend_tf,
|
||||||
|
'entry_ind': entry_ind, 'entry_params': entry_p, 'entry_tf': entry_tf,
|
||||||
|
'description': f"{trend_ind}({trend_tf})→{entry_ind}({entry_tf})",
|
||||||
|
'generation': 'explore'}
|
||||||
else:
|
else:
|
||||||
i1, i2 = random.sample(INDICATORS_POOL, 2)
|
i1, i2 = random.sample(INDICATORS_POOL, 2)
|
||||||
p1 = self._random_params(i1); p2 = self._random_params(i2)
|
p1 = self._random_params(i1); p2 = self._random_params(i2)
|
||||||
@@ -264,9 +297,42 @@ class ResearchLoop:
|
|||||||
def _mutate_hypothesis(self, base):
|
def _mutate_hypothesis(self, base):
|
||||||
"""Mutate an existing hypothesis — change one aspect."""
|
"""Mutate an existing hypothesis — change one aspect."""
|
||||||
hp = dict(base) # shallow copy
|
hp = dict(base) # shallow copy
|
||||||
|
hp['generation'] = 'exploit'
|
||||||
|
|
||||||
|
# multi_role has its own mutation logic
|
||||||
|
if hp.get('type') == 'multi_role':
|
||||||
|
mut = random.choice(['trend_ind', 'entry_ind', 'trend_tf', 'entry_tf',
|
||||||
|
'trend_params', 'entry_params'])
|
||||||
|
if mut == 'trend_ind':
|
||||||
|
hp['trend_ind'] = random.choice([i for i in INDICATORS_POOL if i != hp['trend_ind']])
|
||||||
|
hp['trend_params'] = self._random_params(hp['trend_ind'])
|
||||||
|
hp['description'] = f"{hp['trend_ind']}({hp['trend_tf']})→{hp['entry_ind']}({hp['entry_tf']})"
|
||||||
|
elif mut == 'entry_ind':
|
||||||
|
hp['entry_ind'] = random.choice([i for i in INDICATORS_POOL if i != hp['entry_ind']])
|
||||||
|
hp['entry_params'] = self._random_params(hp['entry_ind'])
|
||||||
|
hp['description'] = f"{hp['trend_ind']}({hp['trend_tf']})→{hp['entry_ind']}({hp['entry_tf']})"
|
||||||
|
elif mut == 'trend_tf':
|
||||||
|
hp['trend_tf'] = random.choice(TREND_TFS)
|
||||||
|
if hp['trend_tf'] <= hp['entry_tf']:
|
||||||
|
hp['entry_tf'] = random.choice([t for t in ENTRY_TFS if t < hp['trend_tf']])
|
||||||
|
hp['description'] = f"{hp['trend_ind']}({hp['trend_tf']})→{hp['entry_ind']}({hp['entry_tf']})"
|
||||||
|
elif mut == 'entry_tf':
|
||||||
|
hp['entry_tf'] = random.choice([t for t in ENTRY_TFS if t < hp['trend_tf']])
|
||||||
|
hp['description'] = f"{hp['trend_ind']}({hp['trend_tf']})→{hp['entry_ind']}({hp['entry_tf']})"
|
||||||
|
elif mut == 'trend_params':
|
||||||
|
p = dict(hp['trend_params']); k = random.choice(list(p.keys()))
|
||||||
|
if isinstance(p[k], (int, float)): p[k] = p[k] * random.uniform(0.5, 1.5)
|
||||||
|
hp['trend_params'] = p
|
||||||
|
hp['description'] = f"{hp['trend_ind']}({hp['trend_tf']})→{hp['entry_ind']}({hp['entry_tf']}) (tuned)"
|
||||||
|
elif mut == 'entry_params':
|
||||||
|
p = dict(hp['entry_params']); k = random.choice(list(p.keys()))
|
||||||
|
if isinstance(p[k], (int, float)): p[k] = p[k] * random.uniform(0.5, 1.5)
|
||||||
|
hp['entry_params'] = p
|
||||||
|
hp['description'] = f"{hp['trend_ind']}({hp['trend_tf']})→{hp['entry_ind']}({hp['entry_tf']}) (tuned)"
|
||||||
|
return hp
|
||||||
|
|
||||||
mutations = ['params', 'indicator', 'timeframe']
|
mutations = ['params', 'indicator', 'timeframe']
|
||||||
mutation = random.choice(mutations)
|
mutation = random.choice(mutations)
|
||||||
hp['generation'] = 'exploit'
|
|
||||||
|
|
||||||
if mutation == 'params' and 'params' in hp:
|
if mutation == 'params' and 'params' in hp:
|
||||||
# Tweak one parameter
|
# Tweak one parameter
|
||||||
|
|||||||
Reference in New Issue
Block a user