feat: 5 critical improvements to trading bot

1. Activate SmartPositionManager (was dead code) - trailing SL, breakeven,
   market close handler, drawdown-from-peak protection now wired into live loop
2. Add flash crash detection between candles - _position_check_only() now
   checks for flash crashes every 5s instead of only on 15min candle close
3. Fix signal confirmation persistence - use direction-based key instead of
   exact price (which changed every candle), persist to file to survive restarts
4. Cache ML/features between candles - stop recalculating 37 features + XGBoost
   every 5 seconds when candle hasn't changed, reuse cached values
5. Add H1 multi-timeframe SMC bias filter - fetch H1 data, analyze BOS/CHoCH/OB,
   block M15 signals that contradict H1 bias, boost confidence when aligned

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
GifariKemal
2026-02-06 18:21:10 +07:00
parent 6e0db5274b
commit 8a34dc7f6d
+499 -53
View File
@@ -18,8 +18,12 @@ Target: < 0.05 seconds per loop
import asyncio
import time
import os
import json
from collections import deque
from datetime import datetime, date
from typing import Optional, Dict, Tuple
from zoneinfo import ZoneInfo
from pathlib import Path
import polars as pl
from loguru import logger
import sys
@@ -48,7 +52,7 @@ from src.config import TradingConfig, get_config
from src.mt5_connector import MT5Connector, MT5SimulationConnector
from src.smc_polars import SMCAnalyzer, SMCSignal
from src.feature_eng import FeatureEngineer
from src.regime_detector import MarketRegimeDetector, FlashCrashDetector, MarketRegime
from src.regime_detector import MarketRegimeDetector, FlashCrashDetector, MarketRegime, RegimeState
from src.risk_engine import RiskEngine
from src.ml_model import TradingModel, get_default_feature_columns
from src.position_manager import SmartPositionManager
@@ -188,6 +192,14 @@ class TradingBot:
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
# Dashboard status bridge (written to JSON for Docker API)
self._dash_price_history: deque = deque(maxlen=120)
self._dash_equity_history: deque = deque(maxlen=120)
self._dash_balance_history: deque = deque(maxlen=120)
self._dash_logs: deque = deque(maxlen=50)
self._dash_last_price: float = 0.0
self._dash_status_file = Path("data/bot_status.json")
def _load_models(self) -> bool:
"""Load pre-trained models."""
@@ -222,7 +234,173 @@ class TradingBot:
self._models_loaded = models_ok
return models_ok
def _dash_log(self, level: str, message: str):
"""Add log entry to dashboard buffer."""
now = datetime.now(ZoneInfo("Asia/Jakarta"))
self._dash_logs.append({
"time": now.strftime("%H:%M:%S"),
"level": level,
"message": message,
})
def _write_dashboard_status(self):
"""Write current bot state to JSON file for Docker dashboard API."""
try:
wib = ZoneInfo("Asia/Jakarta")
now = datetime.now(wib)
# Gather price data
tick = self.mt5.get_tick(self.config.symbol)
price = 0.0
spread = 0.0
price_change = 0.0
if tick:
price = (tick.bid + tick.ask) / 2
spread = (tick.ask - tick.bid) * 100
price_change = price - self._dash_last_price if self._dash_last_price > 0 else 0
self._dash_last_price = price
self._dash_price_history.append(price)
# Account data
balance = self.mt5.account_balance or 0
equity = self.mt5.account_equity or 0
profit = equity - balance
self._dash_equity_history.append(equity)
self._dash_balance_history.append(balance)
# Session
session_name = "Unknown"
can_trade = False
try:
session_info = self.session_filter.get_status_report()
if session_info:
session_name = session_info.get("current_session", "Unknown")
can_trade, _, _ = self.session_filter.can_trade()
except Exception:
pass
is_golden_time = 19 <= now.hour < 23
# Risk state
daily_loss = 0.0
daily_profit = 0.0
consecutive_losses = 0
risk_percent = 0.0
risk_file = Path("data/risk_state.txt")
if risk_file.exists():
try:
content = risk_file.read_text()
for line in content.strip().split("\n"):
if ":" in line:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if key == "daily_loss":
daily_loss = float(value)
elif key == "daily_profit":
daily_profit = float(value)
elif key == "consecutive_losses":
consecutive_losses = int(value)
except Exception:
pass
max_loss = self.config.capital * (self.config.risk.max_daily_loss / 100)
if max_loss > 0:
risk_percent = (daily_loss / max_loss) * 100
# Signals — use raw cached values (before filtering)
smc_data = {
"signal": getattr(self, "_last_raw_smc_signal", ""),
"confidence": getattr(self, "_last_raw_smc_confidence", 0.0),
"reason": getattr(self, "_last_raw_smc_reason", ""),
"updatedAt": getattr(self, "_last_raw_smc_updated", ""),
}
ml_signal = getattr(self, "_last_ml_signal", "")
ml_conf = getattr(self, "_last_ml_confidence", 0.0)
ml_prob = getattr(self, "_last_ml_probability", ml_conf)
ml_data = {
"signal": ml_signal,
"confidence": ml_conf,
"buyProb": ml_prob if ml_signal == "BUY" else (1.0 - ml_prob),
"sellProb": ml_prob if ml_signal == "SELL" else (1.0 - ml_prob),
"updatedAt": getattr(self, "_last_ml_updated", ""),
}
regime_data = {"name": "", "volatility": 0.0, "confidence": 0.0, "updatedAt": ""}
if hasattr(self, "_last_regime") and self._last_regime:
regime_data = {
"name": self._last_regime.value.replace("_", " ").title(),
"volatility": getattr(self, "_last_regime_volatility", 0.0),
"confidence": getattr(self, "_last_regime_confidence", 0.0),
"updatedAt": getattr(self, "_last_regime_updated", ""),
}
# Positions
positions_list = []
try:
positions = self.mt5.get_open_positions(self.config.symbol)
if positions is not None and not positions.is_empty():
for row in positions.iter_rows(named=True):
positions_list.append({
"ticket": row.get("ticket", 0),
"type": "BUY" if row.get("type", 0) == 0 else "SELL",
"volume": row.get("volume", 0),
"priceOpen": row.get("price_open", 0),
"profit": row.get("profit", 0),
})
except Exception:
pass
status = {
"timestamp": now.strftime("%H:%M:%S"),
"connected": True,
"price": price,
"spread": spread,
"priceChange": price_change,
"priceHistory": list(self._dash_price_history),
"balance": balance,
"equity": equity,
"profit": profit,
"equityHistory": list(self._dash_equity_history),
"balanceHistory": list(self._dash_balance_history),
"session": session_name,
"isGoldenTime": is_golden_time,
"canTrade": can_trade,
"dailyLoss": daily_loss,
"dailyProfit": daily_profit,
"consecutiveLosses": consecutive_losses,
"riskPercent": risk_percent,
"smc": smc_data,
"ml": ml_data,
"regime": regime_data,
"positions": positions_list,
"logs": list(self._dash_logs),
"settings": {
"capitalMode": self.config.capital_mode.value,
"capital": self.config.capital,
"riskPerTrade": self.config.risk.risk_per_trade,
"maxDailyLoss": self.config.risk.max_daily_loss,
"maxPositions": self.config.risk.max_positions,
"maxLotSize": self.config.risk.max_lot_size,
"leverage": self.config.risk.max_leverage,
"executionTF": self.config.execution_timeframe,
"trendTF": self.config.trend_timeframe,
"minRR": 1.5,
"mlConfidence": self.config.ml.confidence_threshold,
"cooldownSeconds": self.config.thresholds.trade_cooldown_seconds,
"symbol": self.config.symbol,
},
}
# Atomic write (write to temp then rename)
tmp_file = self._dash_status_file.with_suffix(".tmp")
tmp_file.write_text(json.dumps(status, default=str))
tmp_file.replace(self._dash_status_file)
except Exception as e:
logger.debug(f"Dashboard status write error: {e}")
async def start(self):
"""Start the trading bot."""
logger.info("=" * 60)
@@ -284,6 +462,7 @@ class TradingBot:
# Start main loop
self._running = True
self._dash_log("info", "Bot started - trading loop active")
logger.info("Starting main trading loop...")
await self._main_loop()
@@ -315,10 +494,140 @@ class TradingBot:
"""Get feature columns that exist in DataFrame."""
if self.ml_model.fitted and self.ml_model.feature_names:
return [f for f in self.ml_model.feature_names if f in df.columns]
default_features = get_default_feature_columns()
return [f for f in default_features if f in df.columns]
# --- Signal persistence file helpers (Fix 3) ---
_SIGNAL_PERSISTENCE_FILE = "data/signal_persistence.json"
def _load_signal_persistence(self) -> dict:
"""Load signal persistence state from file (survives restarts)."""
import json, os
try:
if os.path.exists(self._SIGNAL_PERSISTENCE_FILE):
with open(self._SIGNAL_PERSISTENCE_FILE, "r") as f:
raw = json.load(f)
# Convert lists back to tuples
result = {k: (v[0], v[1]) for k, v in raw.items()}
logger.info(f"Loaded signal persistence: {result}")
return result
except Exception as e:
logger.debug(f"Could not load signal persistence: {e}")
return {}
def _save_signal_persistence(self):
"""Save signal persistence state to file."""
import json, os
try:
os.makedirs(os.path.dirname(self._SIGNAL_PERSISTENCE_FILE), exist_ok=True)
with open(self._SIGNAL_PERSISTENCE_FILE, "w") as f:
json.dump(self._signal_persistence, f)
except Exception as e:
logger.debug(f"Could not save signal persistence: {e}")
# --- H1 Multi-Timeframe Bias (Fix 5) ---
def _get_h1_bias(self) -> str:
"""
Determine H1 higher-timeframe bias using SMC structure.
Returns: "BULLISH", "BEARISH", or "NEUTRAL"
Logic:
- Fetch H1 data (100 bars)
- Run SMC analysis (BOS, CHoCH, OB, FVG)
- Last BOS/CHoCH direction = H1 bias
- If H1 has bullish OB near price → BULLISH zone
- If H1 has bearish OB near price → BEARISH zone
"""
try:
# Cache H1 bias — only update every 4 candles (1 hour) since H1 changes slowly
if hasattr(self, '_h1_bias_cache') and hasattr(self, '_h1_bias_loop'):
if self._loop_count - self._h1_bias_loop < 4:
return self._h1_bias_cache
df_h1 = self.mt5.get_market_data(
symbol=self.config.symbol,
timeframe="H1",
count=100,
)
if len(df_h1) < 20:
return "NEUTRAL"
# Run SMC on H1 data
from src.smc_polars import SMCAnalyzer
h1_smc = SMCAnalyzer(swing_length=5, fvg_min_gap_pips=5.0, ob_lookback=10)
df_h1 = h1_smc.calculate_all(df_h1)
current_price = df_h1["close"].tail(1).item()
bias = "NEUTRAL"
# 1. Check last BOS direction on H1
bos_col = df_h1["bos"].to_list()
last_bos = 0
for v in reversed(bos_col[-20:]):
if v != 0:
last_bos = v
break
# 2. Check last CHoCH direction on H1
choch_col = df_h1["choch"].to_list()
last_choch = 0
for v in reversed(choch_col[-20:]):
if v != 0:
last_choch = v
break
# 3. Check if price is near H1 Order Block
ob_col = df_h1["ob"].to_list()
highs = df_h1["high"].to_list()
lows = df_h1["low"].to_list()
near_bullish_ob = False
near_bearish_ob = False
for i in range(-10, 0): # Last 10 H1 candles
idx = len(ob_col) + i
if idx < 0:
continue
ob_val = ob_col[idx]
if ob_val == 1: # Bullish OB
# Price within OB zone (low to high of that candle)
if lows[idx] <= current_price <= highs[idx] * 1.002:
near_bullish_ob = True
elif ob_val == -1: # Bearish OB
if lows[idx] * 0.998 <= current_price <= highs[idx]:
near_bearish_ob = True
# Determine bias: BOS > CHoCH > OB proximity
if last_bos == 1:
bias = "BULLISH"
elif last_bos == -1:
bias = "BEARISH"
elif last_choch == 1:
bias = "BULLISH"
elif last_choch == -1:
bias = "BEARISH"
# OB proximity can override if no clear structure
if bias == "NEUTRAL":
if near_bullish_ob:
bias = "BULLISH"
elif near_bearish_ob:
bias = "BEARISH"
# Cache result
self._h1_bias_cache = bias
self._h1_bias_loop = self._loop_count
if self._loop_count % 4 == 0:
logger.info(f"H1 Bias: {bias} (BOS={last_bos}, CHoCH={last_choch}, near_bull_OB={near_bullish_ob}, near_bear_OB={near_bearish_ob})")
return bias
except Exception as e:
logger.debug(f"H1 bias error: {e}")
return "NEUTRAL"
async def _main_loop(self):
"""Main trading loop - CANDLE-BASED (not time-based)."""
last_position_check = time.time()
@@ -386,43 +695,94 @@ class TradingBot:
execution_time = time.perf_counter() - loop_start
self._execution_times.append(execution_time)
# Write dashboard status file (for Docker API)
self._write_dashboard_status()
# Wait before next check (5 seconds between candle checks)
await asyncio.sleep(5)
async def _position_check_only(self):
"""Quick position check without full analysis (between candles)."""
"""Quick position check between candles — uses cached ML/features, adds flash crash detection."""
try:
# Get live tick price (cheap call)
tick = self.mt5.get_tick(self.config.symbol)
if not tick:
return
current_price = tick.bid
# --- FLASH CRASH DETECTION (Fix 2) ---
# Fetch minimal bars for flash crash check
df_mini = self.mt5.get_market_data(
symbol=self.config.symbol,
timeframe=self.config.execution_timeframe,
count=5,
)
if len(df_mini) > 0:
is_flash, move_pct = self.flash_crash.detect(df_mini)
if is_flash:
logger.warning(f"FLASH CRASH detected between candles: {move_pct:.2f}% move!")
try:
await self._emergency_close_all()
except Exception as e:
logger.critical(f"CRITICAL: Emergency close failed: {e}")
try:
await self.telegram.send_message(
f"CRITICAL: Flash crash {move_pct:.2f}% but emergency close FAILED!\n"
f"Error: {e}\nMANUAL INTERVENTION REQUIRED!"
)
except:
pass
return
# --- POSITION MANAGEMENT (uses cached data — Fix 4) ---
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
)
# Use cached ML prediction and DataFrame from last candle (Fix 4)
# No need to recalculate 37 features every 5 seconds
cached_ml = getattr(self, '_cached_ml_prediction', None)
cached_df = getattr(self, '_cached_df', None)
cached_regime = None
if hasattr(self, '_last_regime') and self._last_regime:
# Build a simple regime state from cached values
cached_regime = RegimeState(
regime=self._last_regime,
volatility=getattr(self, '_last_regime_volatility', 0.0),
confidence=getattr(self, '_last_regime_confidence', 0.0),
probabilities={},
recommendation="TRADE",
)
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,
)
if cached_ml and cached_df is not None and len(cached_df) > 0:
await self._smart_position_management(
open_positions=open_positions,
df=cached_df,
regime_state=cached_regime,
ml_prediction=cached_ml,
current_price=current_price,
)
else:
# Fallback: first iteration before any candle processed
df = self.mt5.get_market_data(
symbol=self.config.symbol,
timeframe=self.config.execution_timeframe,
count=50,
)
if len(df) == 0:
return
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)
await self._smart_position_management(
open_positions=open_positions,
df=df,
regime_state=cached_regime,
ml_prediction=ml_prediction,
current_price=current_price,
)
except Exception as e:
logger.debug(f"Position check error: {e}")
@@ -454,6 +814,9 @@ class TradingBot:
if hasattr(self, '_last_regime') and self._last_regime != regime_state.regime:
logger.info(f"Regime changed: {self._last_regime.value} -> {regime_state.regime.value}")
self._last_regime = regime_state.regime
self._last_regime_volatility = regime_state.volatility
self._last_regime_confidence = regime_state.confidence
self._last_regime_updated = datetime.now(ZoneInfo("Asia/Jakarta")).strftime("%H:%M:%S")
except Exception as e:
logger.debug(f"Regime detection error: {e}")
@@ -494,9 +857,15 @@ class TradingBot:
feature_cols = self._get_available_features(df)
ml_prediction = self.ml_model.predict(df, feature_cols)
# Store for trade logging
# Store for trade logging + dashboard
self._last_ml_signal = ml_prediction.signal
self._last_ml_confidence = ml_prediction.confidence
self._last_ml_probability = ml_prediction.probability
self._last_ml_updated = datetime.now(ZoneInfo("Asia/Jakarta")).strftime("%H:%M:%S")
# Cache ML prediction and DataFrame for inter-candle position checks (Fix 4)
self._cached_ml_prediction = ml_prediction
self._cached_df = df
# 6.5 SMART POSITION MANAGEMENT - NO HARD STOP LOSS
# Hanya close jika: TP tercapai, ML reversal kuat, atau max loss
@@ -569,15 +938,34 @@ class TradingBot:
# Backtest showed trades during news have 62.1% win rate (vs 64.9% normal)
# The $178 profit opportunity outweighs the minimal risk difference
# 7.7 H1 Multi-Timeframe Bias (Fix 5)
# Fetch H1 data and determine higher-TF bias for M15 signal filtering
h1_bias = self._get_h1_bias()
# 8. Get SMC signal
smc_signal = self.smc.generate_signal(df)
# Cache raw SMC for dashboard (before filtering)
_wib_now = datetime.now(ZoneInfo("Asia/Jakarta")).strftime("%H:%M:%S")
if smc_signal:
self._last_raw_smc_signal = smc_signal.signal_type
self._last_raw_smc_confidence = smc_signal.confidence
self._last_raw_smc_reason = smc_signal.reason
self._last_raw_smc_updated = _wib_now
self._dash_log("trade", f"SMC: {smc_signal.signal_type} ({smc_signal.confidence:.0%}) - {smc_signal.reason}")
else:
self._last_raw_smc_signal = ""
self._last_raw_smc_confidence = 0.0
self._last_raw_smc_reason = ""
self._last_raw_smc_updated = _wib_now
# 9. ML prediction already done above for position management
# Log signal status every 30 loops
if self._loop_count % 30 == 0:
# Log signal status every 4 loops (~1 hour on M15)
if self._loop_count % 4 == 0:
price = df["close"].tail(1).item()
logger.info(f"Price: {price:.2f} | Regime: {regime_state.regime.value if regime_state else 'N/A'} | SMC: {smc_signal.signal_type if smc_signal else 'NONE'} | ML: {ml_prediction.signal}({ml_prediction.confidence:.0%})")
h1_tag = f" | H1: {h1_bias}" if h1_bias != "NEUTRAL" else ""
logger.info(f"Price: {price:.2f} | Regime: {regime_state.regime.value if regime_state else 'N/A'} | SMC: {smc_signal.signal_type if smc_signal else 'NONE'} | ML: {ml_prediction.signal}({ml_prediction.confidence:.0%}){h1_tag}")
# Send market update to Telegram (every 30 minutes) - only after first loop
if self._loop_count > 0 and self._loop_count % 30 == 0:
@@ -588,7 +976,28 @@ class TradingBot:
if final_signal is None:
return
# 10.1 H1 Multi-Timeframe Filter (Fix 5)
# Block M15 signal if it contradicts H1 bias
if h1_bias != "NEUTRAL":
if final_signal.signal_type == "BUY" and h1_bias == "BEARISH":
logger.info(f"Skip BUY: H1 bias is BEARISH (counter-trend)")
return
if final_signal.signal_type == "SELL" and h1_bias == "BULLISH":
logger.info(f"Skip SELL: H1 bias is BULLISH (counter-trend)")
return
# Boost confidence when aligned
if (final_signal.signal_type == "BUY" and h1_bias == "BULLISH") or \
(final_signal.signal_type == "SELL" and h1_bias == "BEARISH"):
final_signal = SMCSignal(
signal_type=final_signal.signal_type,
entry_price=final_signal.entry_price,
stop_loss=final_signal.stop_loss,
take_profit=final_signal.take_profit,
confidence=min(final_signal.confidence * 1.1, 0.95),
reason=f"{final_signal.reason} | H1-ALIGNED",
)
# 10.5 Check trade cooldown
if self._last_trade_time:
time_since_last = (datetime.now() - self._last_trade_time).total_seconds()
@@ -772,28 +1181,22 @@ class TradingBot:
# === IMPROVEMENT 2: Signal Confirmation (Entry Delay) ===
# 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}"
# Fix 3: Use direction-only key (not exact price) and persist to file
signal_key = smc_signal.signal_type # "BUY" or "SELL" — direction matters, not exact price
current_time = time.time()
if not hasattr(self, '_signal_persistence'):
self._signal_persistence = {} # {key: (count, last_seen_timestamp)}
self._signal_persistence = self._load_signal_persistence()
# Cleanup: Remove entries older than 5 minutes (300 seconds)
# This prevents memory leak from accumulating stale signals
# Cleanup: Remove entries older than 30 minutes (1800 seconds)
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
if current_time - v[1] < 1800
}
# 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, current_time)
self._save_signal_persistence()
logger.debug(f"Signal confirmation: {signal_key} seen 1st time - waiting")
return None # Wait for confirmation
else:
@@ -803,12 +1206,14 @@ class TradingBot:
# Require at least 2 consecutive confirmations (2 candles)
count, _ = self._signal_persistence[signal_key]
if count < 2:
self._save_signal_persistence()
logger.debug(f"Signal confirmation: {signal_key} count={count} - waiting")
return None
# Signal confirmed! Reset counter
logger.info(f"Signal CONFIRMED: {signal_key} after {count} checks")
self._signal_persistence[signal_key] = (0, current_time)
self._save_signal_persistence()
# SMC-Only: Use SMC signal with confidence adjustment
ml_agrees = (
@@ -1262,14 +1667,43 @@ class TradingBot:
async def _smart_position_management(self, open_positions, df, regime_state, ml_prediction, current_price):
"""
Smart position management TANPA hard stop loss.
Hanya close jika:
1. Take Profit tercapai
2. ML signal reversal KUAT (75%+ confidence)
3. Maximum loss per trade ($50)
4. Daily loss limit
Smart position management with dual evaluation:
1. SmartRiskManager: TP, ML reversal, max loss, daily limit
2. SmartPositionManager: Trailing SL, breakeven, market close, drawdown protection
"""
# --- SmartPositionManager: trailing SL, breakeven, market close ---
if df is not None and len(df) > 0:
pm_actions = self.position_manager.analyze_positions(
positions=open_positions,
df_market=df,
regime_state=regime_state,
ml_prediction=ml_prediction,
current_price=current_price,
)
for action in pm_actions:
if action.action == "TRAIL_SL":
result = self.position_manager._modify_sl(action.ticket, action.new_sl)
if result["success"]:
logger.info(f"Trailing SL #{action.ticket} -> {action.new_sl:.2f}: {action.reason}")
else:
logger.debug(f"Trail SL failed #{action.ticket}: {result['message']}")
elif action.action == "CLOSE":
logger.info(f"PositionManager Close #{action.ticket}: {action.reason}")
result = self.mt5.close_position(action.ticket)
if result.success:
profit = 0
for row in open_positions.iter_rows(named=True):
if row["ticket"] == action.ticket:
profit = row.get("profit", 0)
break
risk_result = self.smart_risk.record_trade_result(profit)
self.smart_risk.unregister_position(action.ticket)
self.position_manager._peak_profits.pop(action.ticket, None)
await self._notify_trade_close_smart(action.ticket, profit, current_price, action.reason)
logger.info(f"CLOSED #{action.ticket}: {action.reason}")
continue # Skip SmartRiskManager eval for this ticket
# --- SmartRiskManager: TP, ML reversal, max loss, daily limit ---
for row in open_positions.iter_rows(named=True):
ticket = row["ticket"]
profit = row.get("profit", 0)
@@ -1278,6 +1712,18 @@ class TradingBot:
position_type = row.get("type", 0) # 0=BUY, 1=SELL
direction = "BUY" if position_type == 0 else "SELL"
# Skip if already closed by PositionManager above
current_positions = self.mt5.get_open_positions(
symbol=self.config.symbol,
magic=self.config.magic_number,
)
still_open = any(
r["ticket"] == ticket
for r in current_positions.iter_rows(named=True)
) if len(current_positions) > 0 else False
if not still_open:
continue
# AUTO-REGISTER posisi yang belum terdaftar (dari sebelum bot start)
if not self.smart_risk.is_position_registered(ticket):
self.smart_risk.auto_register_existing_position(