diff --git a/main_live.py b/main_live.py index be428b0..eedadee 100644 --- a/main_live.py +++ b/main_live.py @@ -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() diff --git a/src/ml_model.py b/src/ml_model.py index de2d99b..461cbf5 100644 --- a/src/ml_model.py +++ b/src/ml_model.py @@ -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") diff --git a/src/smart_risk_manager.py b/src/smart_risk_manager.py index c456c33..1865433 100644 --- a/src/smart_risk_manager.py +++ b/src/smart_risk_manager.py @@ -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 diff --git a/src/smc_polars.py b/src/smc_polars.py index 700eaa2..78b315e 100644 --- a/src/smc_polars.py +++ b/src/smc_polars.py @@ -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}, "