fix: critical improvements to trading logic and ML pipeline

main_live.py:
- Switch main loop from time-based (1s) to candle-based (M15)
- Add position-only checks between candles (every 10s)
- Fix memory leak in signal persistence dict (cleanup stale entries)
- Raise auto-retrain rollback AUC threshold from 0.52 to 0.60

src/ml_model.py:
- Add 50-bar gap between train/test split to prevent temporal leakage

src/smart_risk_manager.py:
- Remove dangerous "Smart Hold" behavior (holding losers waiting for golden time)
- Replace with proper early cut logic (loss >30% + negative momentum)

src/smc_polars.py:
- Fix lookahead bias in FVG detection (remove shift(-1), use confirmed bars only)
- Fix lookahead bias in Swing Points (use center=False rolling window)
- Fix lookahead bias in Order Blocks (validate with current bar, not future)
- Enforce minimum 1:2 Risk:Reward ratio on all signals
- Always use current_close as entry price (no stale FVG/OB zone prices)
- Add ATR sanity check with realistic XAUUSD default ($12)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
GifariKemal
2026-02-06 09:33:43 +07:00
co-authored by Claude Opus 4.6
parent 07e12f5229
commit 7eff3f1a2b
4 changed files with 377 additions and 204 deletions
+154 -126
View File
@@ -102,59 +102,46 @@ class SMCAnalyzer:
- fvg_mid: Midpoint of FVG (50% retracement target)
"""
# Get shifted values using Polars expressions
# FIX: NO LOOKAHEAD - detect FVG on the THIRD candle (after it's confirmed)
# We only use PAST data (shift positive values)
df = df.with_columns([
# Previous candle values (t-1)
pl.col("high").shift(1).alias("_prev_high"),
pl.col("low").shift(1).alias("_prev_low"),
# Candle before previous (t-2)
# Candle before previous (t-2) - this is the FIRST candle of FVG pattern
pl.col("high").shift(2).alias("_prev2_high"),
pl.col("low").shift(2).alias("_prev2_low"),
# Next candle values (t+1) - for detecting FVG on middle candle
pl.col("high").shift(-1).alias("_next_high"),
pl.col("low").shift(-1).alias("_next_low"),
# Current candle is the THIRD candle - NO shift(-1) needed!
])
# Calculate FVG conditions - detected on THIRD candle (current)
# Bullish FVG: First candle high < Third candle low (gap up)
# Bearish FVG: First candle low > Third candle high (gap down)
# NO LOOKAHEAD: we detect AFTER the pattern is complete
df = df.with_columns([
# Bullish FVG: gap between candle 1's high and current candle's low
(pl.col("_prev2_high") < pl.col("low")).alias("is_fvg_bull"),
# Bearish FVG: gap between candle 1's low and current candle's high
(pl.col("_prev2_low") > pl.col("high")).alias("is_fvg_bear"),
])
# Calculate FVG conditions
# For the MIDDLE candle of a 3-candle pattern:
# Bullish FVG: prev2_high < next_low (gap between candle 1's high and candle 3's low)
# Bearish FVG: prev2_low > next_high (gap between candle 1's low and candle 3's high)
# Calculate FVG zones using CURRENT candle (no lookahead)
df = df.with_columns([
# Bullish FVG detection
(pl.col("_prev2_high") < pl.col("_next_low")).alias("is_fvg_bull"),
# Bearish FVG detection
(pl.col("_prev2_low") > pl.col("_next_high")).alias("is_fvg_bear"),
])
# Calculate FVG zones
df = df.with_columns([
# Bullish FVG zone: from prev2_high to next_low
# Bullish FVG zone: from prev2_high (bottom) to current_low (top)
pl.when(pl.col("is_fvg_bull"))
.then(pl.col("_next_low"))
.then(pl.col("low")) # Current candle low is FVG top
.when(pl.col("is_fvg_bear"))
.then(pl.col("_prev2_low")) # First candle low is FVG top for bearish
.otherwise(None)
.alias("fvg_top"),
pl.when(pl.col("is_fvg_bull"))
.then(pl.col("_prev2_high"))
.otherwise(
pl.when(pl.col("is_fvg_bear"))
.then(pl.col("_prev2_low"))
.otherwise(None)
)
.alias("fvg_bottom"),
])
# Update fvg_top for bearish FVG
df = df.with_columns([
pl.when(pl.col("is_fvg_bear"))
.then(pl.col("_prev2_low"))
.otherwise(pl.col("fvg_top"))
.alias("fvg_top"),
pl.when(pl.col("is_fvg_bear"))
.then(pl.col("_next_high"))
.otherwise(pl.col("fvg_bottom"))
.then(pl.col("_prev2_high")) # First candle high is FVG bottom for bullish
.when(pl.col("is_fvg_bear"))
.then(pl.col("high")) # Current candle high is FVG bottom
.otherwise(None)
.alias("fvg_bottom"),
])
@@ -173,10 +160,9 @@ class SMCAnalyzer:
.alias("fvg_signal"),
])
# Drop temporary columns
# Drop temporary columns (no _next columns since we removed lookahead)
df = df.drop([
"_prev_high", "_prev_low", "_prev2_high", "_prev2_low",
"_next_high", "_next_low"
"_prev_high", "_prev_low", "_prev2_high", "_prev2_low"
])
logger.debug(f"FVG calculation complete. Bullish: {df['is_fvg_bull'].sum()}, Bearish: {df['is_fvg_bear'].sum()}")
@@ -202,41 +188,53 @@ class SMCAnalyzer:
- swing_low_level: Price level of swing low
"""
window_size = 2 * self.swing_length + 1
# Calculate rolling max/min with centered window
# Calculate rolling max/min WITHOUT LOOKAHEAD
# FIX: We detect swing points AFTER they're confirmed (swing_length bars later)
# This means swing detection is delayed but NO FUTURE DATA is used
#
# Strategy: A swing high at bar [i] is confirmed at bar [i + swing_length]
# when we can verify bar [i] was the highest in window
# We use shift(swing_length) to look back at the confirmed swing point
df = df.with_columns([
# Look at past window_size bars only
pl.col("high")
.rolling_max(window_size=window_size, center=True)
.rolling_max(window_size=window_size, center=False)
.alias("_roll_max"),
pl.col("low")
.rolling_min(window_size=window_size, center=True)
.rolling_min(window_size=window_size, center=False)
.alias("_roll_min"),
# Get the high/low from swing_length bars ago (the "center" point)
pl.col("high").shift(self.swing_length).alias("_center_high"),
pl.col("low").shift(self.swing_length).alias("_center_low"),
])
# Detect swing points where current price equals rolling extreme
# Detect swing points: the CENTER point equals rolling extreme
# This detects swing points swing_length bars LATE (after confirmation)
# NO LOOKAHEAD: we only confirm after seeing bars on both sides
df = df.with_columns([
# Swing High: current high is the rolling max
pl.when(pl.col("high") == pl.col("_roll_max"))
# Swing High: center high equals rolling max (confirmed swing high)
pl.when(pl.col("_center_high") == pl.col("_roll_max"))
.then(1)
.otherwise(0)
.alias("swing_high"),
# Swing Low: current low is the rolling min
pl.when(pl.col("low") == pl.col("_roll_min"))
# Swing Low: center low equals rolling min (confirmed swing low)
pl.when(pl.col("_center_low") == pl.col("_roll_min"))
.then(-1)
.otherwise(0)
.alias("swing_low"),
])
# Store swing levels
# Store swing levels (use center values, not current values)
df = df.with_columns([
pl.when(pl.col("swing_high") == 1)
.then(pl.col("high"))
.then(pl.col("_center_high"))
.otherwise(None)
.alias("swing_high_level"),
pl.when(pl.col("swing_low") == -1)
.then(pl.col("low"))
.then(pl.col("_center_low"))
.otherwise(None)
.alias("swing_low_level"),
])
@@ -252,7 +250,7 @@ class SMCAnalyzer:
])
# Drop temporary columns
df = df.drop(["_roll_max", "_roll_min"])
df = df.drop(["_roll_max", "_roll_min", "_center_high", "_center_low"])
swing_highs = (df["swing_high"] == 1).sum()
swing_lows = (df["swing_low"] == -1).sum()
@@ -302,24 +300,27 @@ class SMCAnalyzer:
for i in range(self.ob_lookback, n):
# Check for swing low -> Bullish Order Block
# FIX: NO LOOKAHEAD - validate OB at CURRENT bar, not future bar
if swing_lows[i] == -1:
# Look for last bearish candle before swing low
for j in range(i - 1, max(0, i - self.ob_lookback), -1):
if closes[j] < opens[j]: # Bearish candle
# Check if this is a valid OB (price moved up significantly after)
if i + 1 < n and closes[i + 1] > highs[j]:
# FIX: Validate OB using CURRENT bar (closes[i]) not future bar
# OB is valid if current close is above OB high (structure broken)
if closes[i] > highs[j]:
ob[j] = 1 # Bullish OB
ob_top[j] = highs[j]
ob_bottom[j] = lows[j]
break
# Check for swing high -> Bearish Order Block
if swing_highs[i] == 1:
# Look for last bullish candle before swing high
for j in range(i - 1, max(0, i - self.ob_lookback), -1):
if closes[j] > opens[j]: # Bullish candle
# Check if this is a valid OB (price moved down significantly after)
if i + 1 < n and closes[i + 1] < lows[j]:
# FIX: Validate OB using CURRENT bar (closes[i]) not future bar
# OB is valid if current close is below OB low (structure broken)
if closes[i] < lows[j]:
ob[j] = -1 # Bearish OB
ob_top[j] = highs[j]
ob_bottom[j] = lows[j]
@@ -629,17 +630,29 @@ class SMCAnalyzer:
return None, None
# Get ATR for dynamic SL/TP calculation
atr = latest["atr"].item() if "atr" in df.columns else current_close * 0.01 # Fallback 1%
min_sl_distance = 1.5 * atr # Minimum 1.5 ATR untuk SL
max_tp_distance = 4.0 * atr # Maximum 4 ATR untuk TP
# FIX: Realistic ATR fallback for XAUUSD (~$12-15 typical)
if "atr" in df.columns:
atr = latest["atr"].item()
if atr is None or atr <= 0 or atr > current_close * 0.05: # Sanity check
atr = 12.0 # Default realistic ATR for XAUUSD
else:
atr = 12.0 # Default realistic ATR for XAUUSD
# BULLISH SIGNAL CONDITIONS (RELAXED)
# SL: 1.5-2 ATR distance (protects against noise)
min_sl_distance = 1.5 * atr
# TP: Must be at least 2x risk (RR 1:2 minimum)
# With 1.5 ATR SL, TP should be at least 3 ATR
min_rr_ratio = 2.0 # ENFORCED: Minimum Risk:Reward 1:2
# BULLISH SIGNAL CONDITIONS
# Need: bullish structure OR recent bullish break, AND (FVG OR OB)
if ((market_structure == 1 or has_bullish_break) and
(has_bullish_fvg or has_bullish_ob)):
entry_zone, zone_type = get_valid_bullish_zone()
entry = entry_zone if entry_zone else current_close
# FIX: ALWAYS use current_close as entry (no stale prices)
# FVG/OB zone is just for confirmation, not entry price
entry = current_close
# SL below swing low or ATR-based (use the FURTHER one to prevent whipsaw)
swing_sl = last_swing_low if last_swing_low and last_swing_low < entry else None
@@ -651,45 +664,53 @@ class SMCAnalyzer:
else:
sl = atr_sl
# TP at 2:1 RR minimum, capped at max distance
# Ensure SL is at least min_sl_distance away
if entry - sl < min_sl_distance:
sl = entry - min_sl_distance
# FIX: TP at EXACTLY min_rr_ratio (1:2) - ENFORCED
risk = entry - sl
tp = entry + (risk * 2)
# Cap TP at reasonable distance
if tp > entry + max_tp_distance:
tp = entry + max_tp_distance
tp = entry + (risk * min_rr_ratio)
# Confidence based on confirmations
conf = 0.55 # Base
if has_bullish_break:
conf += 0.1
if has_bullish_fvg:
conf += 0.1
if has_bullish_ob:
conf += 0.1
# VALIDATE RR before creating signal
actual_rr = (tp - entry) / risk if risk > 0 else 0
if actual_rr < min_rr_ratio:
logger.debug(f"Skipping BUY signal: RR {actual_rr:.2f} < {min_rr_ratio}")
signal = None
else:
# Confidence based on confirmations
conf = 0.55 # Base
if has_bullish_break:
conf += 0.1
if has_bullish_fvg:
conf += 0.1
if has_bullish_ob:
conf += 0.1
reason_parts = []
if has_bullish_break:
reason_parts.append("BOS/CHoCH")
if zone_type == "FVG":
reason_parts.append("FVG")
if zone_type == "OB":
reason_parts.append("OB")
reason_parts = []
if has_bullish_break:
reason_parts.append("BOS/CHoCH")
if zone_type == "FVG":
reason_parts.append("FVG")
if zone_type == "OB":
reason_parts.append("OB")
signal = SMCSignal(
signal_type="BUY",
entry_price=entry,
stop_loss=sl,
take_profit=tp,
confidence=min(conf, 0.85),
reason="Bullish " + " + ".join(reason_parts),
)
signal = SMCSignal(
signal_type="BUY",
entry_price=entry,
stop_loss=sl,
take_profit=tp,
confidence=min(conf, 0.85),
reason="Bullish " + " + ".join(reason_parts),
)
# BEARISH SIGNAL CONDITIONS (RELAXED)
# BEARISH SIGNAL CONDITIONS
elif ((market_structure == -1 or has_bearish_break) and
(has_bearish_fvg or has_bearish_ob)):
entry_zone, zone_type = get_valid_bearish_zone()
entry = entry_zone if entry_zone else current_close
# FIX: ALWAYS use current_close as entry (no stale prices)
entry = current_close
# SL above swing high or ATR-based (use the FURTHER one to prevent whipsaw)
swing_sl = last_swing_high if last_swing_high and last_swing_high > entry else None
@@ -701,38 +722,45 @@ class SMCAnalyzer:
else:
sl = atr_sl
# TP at 2:1 RR minimum, capped at max distance
# Ensure SL is at least min_sl_distance away
if sl - entry < min_sl_distance:
sl = entry + min_sl_distance
# FIX: TP at EXACTLY min_rr_ratio (1:2) - ENFORCED
risk = sl - entry
tp = entry - (risk * 2)
# Cap TP at reasonable distance
if tp < entry - max_tp_distance:
tp = entry - max_tp_distance
tp = entry - (risk * min_rr_ratio)
# Confidence based on confirmations
conf = 0.55 # Base
if has_bearish_break:
conf += 0.1
if has_bearish_fvg:
conf += 0.1
if has_bearish_ob:
conf += 0.1
# VALIDATE RR before creating signal
actual_rr = (entry - tp) / risk if risk > 0 else 0
if actual_rr < min_rr_ratio:
logger.debug(f"Skipping SELL signal: RR {actual_rr:.2f} < {min_rr_ratio}")
signal = None
else:
# Confidence based on confirmations
conf = 0.55 # Base
if has_bearish_break:
conf += 0.1
if has_bearish_fvg:
conf += 0.1
if has_bearish_ob:
conf += 0.1
reason_parts = []
if has_bearish_break:
reason_parts.append("BOS/CHoCH")
if zone_type == "FVG":
reason_parts.append("FVG")
if zone_type == "OB":
reason_parts.append("OB")
reason_parts = []
if has_bearish_break:
reason_parts.append("BOS/CHoCH")
if zone_type == "FVG":
reason_parts.append("FVG")
if zone_type == "OB":
reason_parts.append("OB")
signal = SMCSignal(
signal_type="SELL",
entry_price=entry,
stop_loss=sl,
take_profit=tp,
confidence=min(conf, 0.85),
reason="Bearish " + " + ".join(reason_parts),
)
signal = SMCSignal(
signal_type="SELL",
entry_price=entry,
stop_loss=sl,
take_profit=tp,
confidence=min(conf, 0.85),
reason="Bearish " + " + ".join(reason_parts),
)
if signal:
logger.info(f"SMC Signal: {signal.signal_type} @ {signal.entry_price:.5f}, "