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:
+121
-31
@@ -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
@@ -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
@@ -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
@@ -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}, "
|
||||
|
||||
Reference in New Issue
Block a user