Fix live/backtest parity, SELL-side losses, and indicator repairs (+42% total R)
Live pipeline: - Drop still-forming candle before analysis (signals no longer repaint) - Direction-aware MTF agreement + opposition gate (no trade against 2-of-3 TF consensus) - SELL volatility gate: block shorts in low/normal vol regimes (config sell_vol_regimes) - Divergence: 40-bar lookback, price/indicator swings must align within ±2 bars (fired on 0.5% of trades before, 29% after), trending weight 2.0→1.0 - RSI regime_aware by default; transitioning regime only signals at extremes - ADX ±DM tie no longer nudges bearish; TP2 clamped beyond TP1 Backtester realism: - Charge $0.30 round-trip spread (--spread), gap-through SL/TP fills at bar open - Resolve trades on RAW price path (cache stores raw bars, cleaning runs on load) - Weekend/session filters use bar-close decision time to match live wall clock Validated per-step on M15 2025-03-30→2026-05-31 under identical pricing: +46.9R → +66.8R, win rate 69.9% → 73.0%, SELL side -2.3R → +9.6R. Tested-and-reverted: ROC weight removal (-13R), BB fade suppression (-11.6R), candlestick de-weight (-2R) — composite thresholds are co-tuned with weight mass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -478,11 +478,14 @@ class TechnicalEngine:
|
||||
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
||||
|
||||
# Directional Movement
|
||||
plus_dm = high.diff()
|
||||
minus_dm = -low.diff()
|
||||
plus_dm_raw = high.diff()
|
||||
minus_dm_raw = -low.diff()
|
||||
|
||||
plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0.0)
|
||||
minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0.0)
|
||||
# Compare raw vs raw: sequential filtering would zero +DM on a tie
|
||||
# but keep -DM (compared against the already-zeroed +DM) — a
|
||||
# systematic bearish nudge. Wilder zeroes both on ties.
|
||||
plus_dm = plus_dm_raw.where((plus_dm_raw > minus_dm_raw) & (plus_dm_raw > 0), 0.0)
|
||||
minus_dm = minus_dm_raw.where((minus_dm_raw > plus_dm_raw) & (minus_dm_raw > 0), 0.0)
|
||||
|
||||
atr = tr.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean()
|
||||
plus_di = 100 * (plus_dm.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean() / atr)
|
||||
@@ -762,20 +765,34 @@ class TechnicalEngine:
|
||||
price_window = close.iloc[-lookback:]
|
||||
ind_window = indicator_series.iloc[-lookback:]
|
||||
|
||||
# A price swing and an indicator swing only form one divergence
|
||||
# observation if they mark the SAME market turn — require the
|
||||
# swing bars to line up within a small tolerance, otherwise
|
||||
# unrelated wiggles get compared and the signal is noise.
|
||||
def _aligned(price_pts, ind_pts, tol=2):
|
||||
pairs = []
|
||||
for p_idx, p_val in price_pts:
|
||||
best = min(ind_pts, key=lambda ip: abs(ip[0] - p_idx), default=None)
|
||||
if best is not None and abs(best[0] - p_idx) <= tol:
|
||||
pairs.append(((p_idx, p_val), best))
|
||||
return pairs
|
||||
|
||||
# Bullish divergence: lower price low but higher indicator low
|
||||
price_lows = self._find_swing_points(price_window, mode="low")
|
||||
ind_lows = self._find_swing_points(ind_window, mode="low")
|
||||
|
||||
if len(price_lows) >= 2 and len(ind_lows) >= 2:
|
||||
if price_lows[-1][1] < price_lows[-2][1] and ind_lows[-1][1] > ind_lows[-2][1]:
|
||||
low_pairs = _aligned(price_lows, ind_lows)
|
||||
if len(low_pairs) >= 2:
|
||||
(p1, i1), (p2, i2) = low_pairs[-2], low_pairs[-1]
|
||||
if p2[1] < p1[1] and i2[1] > i1[1]:
|
||||
divergences.append(f"Bullish {name} divergence (reversal warning)")
|
||||
|
||||
# Bearish divergence: higher price high but lower indicator high
|
||||
price_highs = self._find_swing_points(price_window, mode="high")
|
||||
ind_highs = self._find_swing_points(ind_window, mode="high")
|
||||
|
||||
if len(price_highs) >= 2 and len(ind_highs) >= 2:
|
||||
if price_highs[-1][1] > price_highs[-2][1] and ind_highs[-1][1] < ind_highs[-2][1]:
|
||||
high_pairs = _aligned(price_highs, ind_highs)
|
||||
if len(high_pairs) >= 2:
|
||||
(p1, i1), (p2, i2) = high_pairs[-2], high_pairs[-1]
|
||||
if p2[1] > p1[1] and i2[1] < i1[1]:
|
||||
divergences.append(f"Bearish {name} divergence (reversal warning)")
|
||||
|
||||
if divergences:
|
||||
@@ -976,6 +993,22 @@ class TechnicalEngine:
|
||||
desc = f"RSI {rsi_val:.1f} — Overbought in downtrend, possible reversal"
|
||||
else:
|
||||
return
|
||||
elif regime == "transitioning":
|
||||
# Trend is being born — RSI can't tell direction yet, and
|
||||
# range-fade rules would sell the newborn trend. Only extremes
|
||||
# carry information here.
|
||||
if rsi_val >= 70:
|
||||
signal = SignalStrength.SELL
|
||||
conf = 50
|
||||
desc = f"RSI {rsi_val:.1f} — Overbought while trend forming"
|
||||
elif rsi_val <= 30:
|
||||
signal = SignalStrength.BUY
|
||||
conf = 50
|
||||
desc = f"RSI {rsi_val:.1f} — Oversold while trend forming"
|
||||
else:
|
||||
signal = SignalStrength.NEUTRAL
|
||||
conf = 30
|
||||
desc = f"RSI {rsi_val:.1f} — No RSI edge while trend forms"
|
||||
else: # ranging
|
||||
if rsi_val >= 70:
|
||||
signal = SignalStrength.SELL
|
||||
@@ -1037,10 +1070,15 @@ class TechnicalEngine:
|
||||
if self.config.regime_weights_override:
|
||||
regime_weights = self.config.regime_weights_override
|
||||
else:
|
||||
# NOTE: ROC is directionally redundant with MACD/Keltner/EMA
|
||||
# (0.8-0.94 correlation), but zeroing its weight was backtested
|
||||
# 2026-07 and LOST 13R — the redundant vote effectively raises
|
||||
# the composite bar and the thresholds are calibrated with it.
|
||||
# Don't remove without retuning score thresholds.
|
||||
_trending = {
|
||||
"ema_trend": 2.0, "rsi": 1.0, "macd": 2.0,
|
||||
"bollinger": 0.5, "keltner": 1.5, "roc": 1.5,
|
||||
"candlestick": 0.8, "divergence": 2.0, "adx": 0.8,
|
||||
"candlestick": 0.8, "divergence": 1.0, "adx": 0.8,
|
||||
}
|
||||
regime_weights = {
|
||||
"trending_up": _trending,
|
||||
|
||||
Reference in New Issue
Block a user