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
parent 07e12f5229
commit 7eff3f1a2b
4 changed files with 377 additions and 204 deletions
+121 -31
View File
@@ -186,6 +186,8 @@ class TradingBot:
self._last_news_alert_reason: Optional[str] = None # Track news alert to avoid duplicates
self._current_session_multiplier: float = 1.0 # Session lot multiplier
self._is_sydney_session: bool = False # Sydney session flag (needs higher confidence)
self._last_candle_time: Optional[datetime] = None # Track last processed candle
self._position_check_interval: int = 10 # Check positions every N seconds between candles
def _load_models(self) -> bool:
"""Load pre-trained models."""
@@ -318,39 +320,111 @@ class TradingBot:
return [f for f in default_features if f in df.columns]
async def _main_loop(self):
"""Main trading loop."""
"""Main trading loop - CANDLE-BASED (not time-based)."""
last_position_check = time.time()
while self._running:
loop_start = time.perf_counter()
try:
# Check for new day
if date.today() != self._current_date:
self._on_new_day()
# Execute one loop iteration
await self._trading_iteration()
# Ensure MT5 connection is alive (auto-reconnect if needed)
if not self.mt5.ensure_connected():
logger.warning("MT5 disconnected, attempting reconnection...")
await asyncio.sleep(10) # Wait before retrying
continue
# Get current candle time to check if new candle formed
df_check = self.mt5.get_market_data(
symbol=self.config.symbol,
timeframe=self.config.execution_timeframe,
count=2,
)
if len(df_check) == 0:
logger.warning("No data received from MT5")
await asyncio.sleep(5)
continue
current_candle_time = df_check["time"].tail(1).item()
# Check if new candle formed
is_new_candle = (
self._last_candle_time is None or
current_candle_time > self._last_candle_time
)
if is_new_candle:
# NEW CANDLE: Run full analysis
self._last_candle_time = current_candle_time
await self._trading_iteration()
self._loop_count += 1
# Log on new candle
if self._loop_count % 4 == 0: # Every 4 candles (1 hour on M15)
avg_time = sum(self._execution_times[-4:]) / min(4, len(self._execution_times)) if self._execution_times else 0
logger.info(f"Candle #{self._loop_count} | Avg execution: {avg_time*1000:.1f}ms")
# AUTO-RETRAINING CHECK - every 20 candles (5 hours on M15)
if self._loop_count % 20 == 0:
await self._check_auto_retrain()
else:
# SAME CANDLE: Only check positions (every 10 seconds)
if time.time() - last_position_check >= self._position_check_interval:
await self._position_check_only()
last_position_check = time.time()
except Exception as e:
logger.error(f"Loop error: {e}")
import traceback
logger.debug(traceback.format_exc())
# Track execution time
execution_time = time.perf_counter() - loop_start
self._execution_times.append(execution_time)
# Log performance periodically
self._loop_count += 1
if self._loop_count % 60 == 0:
avg_time = sum(self._execution_times[-60:]) / min(60, len(self._execution_times))
logger.info(f"Loop #{self._loop_count} | Avg execution: {avg_time*1000:.1f}ms")
# AUTO-RETRAINING CHECK - every 5 minutes (300 loops)
if self._loop_count % 300 == 0:
await self._check_auto_retrain()
# Wait before next check (5 seconds between candle checks)
await asyncio.sleep(5)
# Wait for next iteration
await asyncio.sleep(1)
async def _position_check_only(self):
"""Quick position check without full analysis (between candles)."""
try:
open_positions = self.mt5.get_open_positions(
symbol=self.config.symbol,
magic=self.config.magic_number,
)
if len(open_positions) > 0 and not self.simulation:
# Get minimal data for position management
df = self.mt5.get_market_data(
symbol=self.config.symbol,
timeframe=self.config.execution_timeframe,
count=50, # Less data needed
)
if len(df) == 0:
return
# Calculate features for ML check
df = self.features.calculate_all(df, include_ml_features=True)
feature_cols = self._get_available_features(df)
ml_prediction = self.ml_model.predict(df, feature_cols)
tick = self.mt5.get_tick(self.config.symbol)
current_price = tick.bid if tick else df["close"].tail(1).item()
await self._smart_position_management(
open_positions=open_positions,
df=df,
regime_state=None,
ml_prediction=ml_prediction,
current_price=current_price,
)
except Exception as e:
logger.debug(f"Position check error: {e}")
async def _trading_iteration(self):
"""Single trading iteration."""
@@ -684,29 +758,44 @@ class TradingBot:
return None
# === IMPROVEMENT 2: Signal Confirmation (Entry Delay) ===
# Track signal persistence - only entry if signal consistent for 2+ loops
# Track signal persistence - only entry if signal consistent for 2+ candles
# FIX: Proper memory management to prevent leak
signal_key = f"{smc_signal.signal_type}_{smc_signal.entry_price:.0f}"
current_time = time.time()
if not hasattr(self, '_signal_persistence'):
self._signal_persistence = {}
self._signal_persistence = {} # {key: (count, last_seen_timestamp)}
# Cleanup: Remove entries older than 5 minutes (300 seconds)
# This prevents memory leak from accumulating stale signals
self._signal_persistence = {
k: v for k, v in self._signal_persistence.items()
if current_time - v[1] < 300 # Keep only signals seen in last 5 min
}
# Also limit to max 50 entries as safety
if len(self._signal_persistence) > 50:
# Keep only 20 most recent
sorted_signals = sorted(self._signal_persistence.items(), key=lambda x: x[1][1], reverse=True)
self._signal_persistence = dict(sorted_signals[:20])
if signal_key not in self._signal_persistence:
self._signal_persistence[signal_key] = 1
self._signal_persistence[signal_key] = (1, current_time)
logger.debug(f"Signal confirmation: {signal_key} seen 1st time - waiting")
# Clean old signals
self._signal_persistence = {k: v for k, v in self._signal_persistence.items()
if v < 10} # Keep only recent
return None # Wait for confirmation
else:
self._signal_persistence[signal_key] += 1
count, _ = self._signal_persistence[signal_key]
self._signal_persistence[signal_key] = (count + 1, current_time)
# Require at least 2 consecutive confirmations
if self._signal_persistence[signal_key] < 2:
logger.debug(f"Signal confirmation: {signal_key} count={self._signal_persistence[signal_key]} - waiting")
# Require at least 2 consecutive confirmations (2 candles)
count, _ = self._signal_persistence[signal_key]
if count < 2:
logger.debug(f"Signal confirmation: {signal_key} count={count} - waiting")
return None
# Signal confirmed! Reset counter
logger.info(f"Signal CONFIRMED: {signal_key} after {self._signal_persistence[signal_key]} checks")
self._signal_persistence[signal_key] = 0
logger.info(f"Signal CONFIRMED: {signal_key} after {count} checks")
self._signal_persistence[signal_key] = (0, current_time)
# SMC-Only: Use SMC signal with confidence adjustment
ml_agrees = (
@@ -1696,7 +1785,8 @@ class TradingBot:
logger.info(f" Test AUC: {results.get('xgb_test_auc', 0):.4f}")
# Check if new model is worse - rollback if needed
if results.get("xgb_test_auc", 0) < 0.52:
# FIX: Increased minimum AUC from 0.52 to 0.60 (0.52 is barely better than random)
if results.get("xgb_test_auc", 0) < 0.60:
logger.warning("New model AUC too low - rolling back!")
self.auto_trainer.rollback_models()
self.regime_detector.load()
+15 -2
View File
@@ -138,9 +138,22 @@ class TradingModel:
X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
# Train/test split (time-series aware - no shuffle)
# FIX: Add GAP between train and test to prevent temporal leakage
# Gap of 50 bars (~12.5 hours on M15) breaks autocorrelation
gap_size = 50
split_idx = int(len(X) * train_ratio)
X_train, X_test = X[:split_idx], X[split_idx:]
y_train, y_test = y[:split_idx], y[split_idx:]
X_train = X[:split_idx]
y_train = y[:split_idx]
# Skip 'gap_size' bars between train and test
test_start_idx = split_idx + gap_size
if test_start_idx >= len(X):
# Not enough data for gap, use smaller gap
test_start_idx = min(split_idx + 10, len(X) - 1)
X_test = X[test_start_idx:]
y_test = y[test_start_idx:]
logger.info(f"Train/Test gap: {test_start_idx - split_idx} bars to prevent temporal leakage")
logger.info(f"Training with {len(X_train)} samples, testing with {len(X_test)} samples")
+87 -45
View File
@@ -238,43 +238,93 @@ class SmartRiskManager:
def _load_daily_state(self):
"""Load daily state from file."""
state_file = "data/risk_state.txt"
try:
if os.path.exists(state_file):
with open(state_file, "r") as f:
lines = f.readlines()
saved_date = None
for line in lines:
if line.startswith("date:"):
saved_date = line.split(":")[1].strip()
# Always load total_loss (persists across days)
if line.startswith("total_loss:"):
self._total_loss = float(line.split(":")[1].strip())
backup_file = "data/risk_state.bak"
if saved_date == str(date.today()):
# Load today's state
for l in lines:
if l.startswith("daily_loss:"):
self._state.daily_loss = float(l.split(":")[1].strip())
elif l.startswith("daily_profit:"):
self._state.daily_profit = float(l.split(":")[1].strip())
elif l.startswith("consecutive_losses:"):
self._state.consecutive_losses = int(l.split(":")[1].strip())
def load_from_file(filepath):
"""Load state from a specific file."""
with open(filepath, "r") as f:
lines = f.readlines()
saved_date = None
for line in lines:
if line.startswith("date:"):
saved_date = line.split(":")[1].strip()
# Always load total_loss (persists across days)
if line.startswith("total_loss:"):
self._total_loss = float(line.split(":")[1].strip())
logger.info(f"Loaded total loss: ${self._total_loss:.2f}")
if saved_date == str(date.today()):
# Load today's state
for l in lines:
if l.startswith("daily_loss:"):
self._state.daily_loss = float(l.split(":")[1].strip())
elif l.startswith("daily_profit:"):
self._state.daily_profit = float(l.split(":")[1].strip())
elif l.startswith("consecutive_losses:"):
self._state.consecutive_losses = int(l.split(":")[1].strip())
logger.info(f"Loaded today's state: loss=${self._state.daily_loss:.2f}, profit=${self._state.daily_profit:.2f}")
return True
try:
# Try main state file first
if os.path.exists(state_file):
load_from_file(state_file)
# If main file missing/corrupt, try backup
elif os.path.exists(backup_file):
logger.warning("Main state file missing, loading from backup...")
load_from_file(backup_file)
except Exception as e:
logger.warning(f"Could not load risk state: {e}")
# Try backup if main file failed
try:
if os.path.exists(backup_file):
load_from_file(backup_file)
except:
logger.error("Could not load risk state from backup either")
def _save_daily_state(self):
"""Save daily state to file."""
"""Save daily state to file with atomic write (crash-safe)."""
os.makedirs("data", exist_ok=True)
state_file = "data/risk_state.txt"
temp_file = "data/risk_state.tmp"
backup_file = "data/risk_state.bak"
try:
with open(state_file, "w") as f:
f.write(f"date:{date.today()}\n")
f.write(f"daily_loss:{self._state.daily_loss}\n")
f.write(f"daily_profit:{self._state.daily_profit}\n")
f.write(f"consecutive_losses:{self._state.consecutive_losses}\n")
f.write(f"total_loss:{self._total_loss}\n")
# Write to temp file first (atomic write pattern)
content = (
f"date:{date.today()}\n"
f"daily_loss:{self._state.daily_loss}\n"
f"daily_profit:{self._state.daily_profit}\n"
f"consecutive_losses:{self._state.consecutive_losses}\n"
f"total_loss:{self._total_loss}\n"
f"saved_at:{datetime.now(WIB).isoformat()}\n"
)
with open(temp_file, "w") as f:
f.write(content)
f.flush()
os.fsync(f.fileno()) # Force write to disk
# Backup existing file
if os.path.exists(state_file):
try:
import shutil
shutil.copy2(state_file, backup_file)
except:
pass
# Atomic rename (crash-safe)
os.replace(temp_file, state_file)
except Exception as e:
logger.warning(f"Could not save risk state: {e}")
# Try to restore from backup if main file corrupted
if os.path.exists(backup_file) and not os.path.exists(state_file):
try:
import shutil
shutil.copy2(backup_file, state_file)
except:
pass
def check_new_day(self):
"""Check if it's a new day and reset state."""
@@ -627,33 +677,25 @@ class SmartRiskManager:
return True, ExitReason.TAKE_PROFIT, f"[WARN] Early exit ${current_profit:.2f} (reversal signal: {ml_signal} {ml_confidence:.0%})"
# === CHECK 3: SMART HOLD FOR GOLDEN TIME (TIGHTENED v2) ===
# Jika trade di luar golden time dan loss masih kecil, tunggu golden time
# TAPI hanya jika momentum tidak terlalu negatif
# FIX: REMOVED SMART HOLD MARTINGALE BEHAVIOR
# Holding losing positions waiting for "golden time" is DANGEROUS
# It encourages holding losers hoping they'll recover
# PROPER RISK MANAGEMENT: Follow SL rules, don't hope for recovery
now = datetime.now(WIB)
current_hour = now.hour
# Golden time adalah 19:00 - 23:00 WIB (London-NY Overlap)
is_golden_time = 19 <= current_hour <= 23
hours_to_golden = (19 - current_hour) if current_hour < 19 else 0
# Smart Hold Logic: LEBIH KETAT - cek momentum dulu
if current_profit < 0 and not is_golden_time:
# Early cut: If loss > 30% of max and momentum negative, cut early
if current_profit < 0:
loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
# BARU: Jika momentum sangat negatif (< -30), JANGAN hold terlalu lama
# Cut early if momentum is against us AND loss is significant
if momentum < -30 and loss_percent_of_max >= 30:
logger.info(f"[EARLY CUT] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}%) + weak momentum ({momentum:.0f}) - CUTTING EARLY")
return True, ExitReason.TREND_REVERSAL, f"[EARLY CUT] Loss ${abs(current_profit):.2f} + momentum {momentum:.0f} - cutting to preserve daily limit"
# Jika loss < 30% dari max dan golden time dalam 3 jam DAN momentum tidak terlalu buruk, HOLD
if loss_percent_of_max < 30 and hours_to_golden <= 3 and hours_to_golden > 0 and momentum > -50:
logger.info(f"[SMART HOLD] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}% of max), Golden time in {hours_to_golden}h - HOLDING")
return False, None, f"SMART HOLD: Loss ${abs(current_profit):.2f} | Golden in {hours_to_golden}h | ML: {ml_signal}({ml_confidence:.0%})"
# Jika loss < 20% dari max dan masih dalam session aktif (London), HOLD
if loss_percent_of_max < 20 and 15 <= current_hour < 19 and momentum > -40:
logger.info(f"[SMART HOLD] Small loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}% of max) in London session - HOLDING")
return False, None, f"SMART HOLD: Small loss ${abs(current_profit):.2f} | London session | ML: {ml_signal}({ml_confidence:.0%})"
# NOTE: Smart Hold REMOVED - no more holding losers hoping for golden time
# If SL is hit, close the trade immediately
# === CHECK 4: TREND REVERSAL (LEBIH SENSITIF) ===
# Close lebih cepat jika ada reversal signal - tidak perlu tunggu loss besar
+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}, "