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:
harwinder
2026-07-08 21:26:30 -04:00
parent 998f5e8616
commit 6a96ed6006
7 changed files with 273 additions and 60 deletions
+49 -11
View File
@@ -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,