feat: apply #24B optimizations — ATR-adaptive exit, skip Tokyo-London, relaxed early cut

Backtest #24B results: 739 trades, 80.4% WR, $2,235 PnL, 3.4% DD, Sharpe 2.87, PF 1.77 (+$785 vs baseline)

Three proven improvements:
- Skip Tokyo-London overlap session (15:00-16:00 WIB) — backtest +$345
- Relax early cut momentum threshold from -30 to -50 — backtest +$125
- ATR-adaptive breakeven/trail (BE=2.0x ATR, trail_start=4.0x ATR, trail_step=3.0x ATR) — backtest +$373

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
GifariKemal
2026-02-07 22:34:24 +07:00
parent 8a34dc7f6d
commit 53d8cd26a2
4 changed files with 89 additions and 165 deletions
+32 -140
View File
@@ -61,7 +61,7 @@ from src.auto_trainer import AutoTrainer, create_auto_trainer
from src.telegram_notifier import TelegramNotifier, create_telegram_notifier
from src.smart_risk_manager import SmartRiskManager, create_smart_risk_manager
from src.dynamic_confidence import DynamicConfidenceManager, create_dynamic_confidence
from src.news_agent import NewsAgent, create_news_agent, MarketCondition
# from src.news_agent import NewsAgent, create_news_agent, MarketCondition # DISABLED
from src.trade_logger import TradeLogger, get_trade_logger
@@ -129,11 +129,14 @@ class TradingBot:
model_path="models/xgboost_model.pkl",
)
# Initialize Smart Position Manager - ULTRA SAFE MODE
# Initialize Smart Position Manager - ATR-ADAPTIVE (#24B)
self.position_manager = SmartPositionManager(
breakeven_pips=5.0, # Move to breakeven after 5 pips profit
trail_start_pips=10.0, # Start trailing after 10 pips
trail_step_pips=5.0, # Trail by 5 pips
breakeven_pips=30.0, # Fallback if ATR unavailable
trail_start_pips=50.0, # Fallback if ATR unavailable
trail_step_pips=30.0, # Fallback if ATR unavailable
atr_be_mult=2.0, # Breakeven = ATR * 2.0 (#24B)
atr_trail_start_mult=4.0, # Trail start = ATR * 4.0 (#24B)
atr_trail_step_mult=3.0, # Trail step = ATR * 3.0 (#24B)
min_profit_to_protect=5.0, # Protect profits > $5
max_drawdown_from_peak=50.0, # Allow 50% drawdown (we use tiny lots)
# Smart Market Close Handler
@@ -157,15 +160,9 @@ class TradingBot:
# Initialize Telegram Notifier - smart notifications
self.telegram = create_telegram_notifier()
# Initialize News Agent - economic calendar monitoring (NO BLOCKING)
# Based on comprehensive backtest (29 trades, 62.1% WR, $178 profit):
# - News filter COSTS us $178.15 profit
# - ML model already handles market volatility well
# - Keep monitoring for logging but DO NOT block trades
self.news_agent = create_news_agent(
news_buffer_minutes=0, # No blocking
high_impact_buffer_minutes=0, # No blocking - ML model handles volatility
)
# News Agent DISABLED - backtest proved it costs $178 profit
# ML model already handles volatility well
self.news_agent = None
# Initialize Trade Logger - for ML auto-training
self.trade_logger = get_trade_logger()
@@ -391,6 +388,10 @@ class TradingBot:
"cooldownSeconds": self.config.thresholds.trade_cooldown_seconds,
"symbol": self.config.symbol,
},
"h1Bias": getattr(self, "_h1_bias_cache", "NEUTRAL"),
"dynamicThreshold": getattr(self, "_last_dynamic_threshold", self.config.ml.confidence_threshold),
"marketQuality": getattr(self, "_last_market_quality", "unknown"),
"marketScore": getattr(self, "_last_market_score", 0),
}
# Atomic write (write to temp then rename)
@@ -434,10 +435,6 @@ class TradingBot:
logger.info(f"Session: {session_status['current_session']} ({session_status['volatility']} vol)")
logger.info(f"Can Trade: {session_status['can_trade']} - {session_status['reason']}")
# Show news agent status
news_can_trade, news_reason, _ = self.news_agent.should_trade()
logger.info(f"News Agent: {'SAFE' if news_can_trade else 'BLOCKED'} - {news_reason}")
# Track daily start balance
self._daily_start_balance = balance
self._start_time = datetime.now()
@@ -445,14 +442,13 @@ class TradingBot:
# Send Telegram startup notification
ml_status = f"Loaded ({len(self.ml_model.feature_names)} features)" if self.ml_model.fitted else "Not loaded"
news_status = "SAFE" if news_can_trade else "BLOCKED"
await self.telegram.send_startup_message(
symbol=self.config.symbol,
capital=self.config.capital,
balance=balance,
mode=self.config.capital_mode.value,
ml_model_status=ml_status,
news_status=news_status,
news_status="DISABLED",
)
except Exception as e:
@@ -925,18 +921,7 @@ class TradingBot:
self._current_session_multiplier = session_multiplier
self._is_sydney_session = "Sydney" in session_reason or session_multiplier == 0.5
# 7.6 NEWS AGENT MONITORING (NO BLOCKING)
# Based on backtest analysis: News filter COSTS $178 profit
# ML model already handles volatility well - no need to block
can_trade_news, news_reason, news_lot_mult = self.news_agent.should_trade()
# Log news status for monitoring but DO NOT block trades
if not can_trade_news and self._loop_count % 300 == 0:
logger.info(f"News Agent: HIGH IMPACT NEWS - {news_reason} (trading allowed)")
# Note: We no longer block trades during news events
# Backtest showed trades during news have 62.1% win rate (vs 64.9% normal)
# The $178 profit opportunity outweighs the minimal risk difference
# 7.6 NEWS AGENT - DISABLED (backtest: costs $178 profit, ML handles volatility)
# 7.7 H1 Multi-Timeframe Bias (Fix 5)
# Fetch H1 data and determine higher-TF bias for M15 signal filtering
@@ -977,44 +962,20 @@ class TradingBot:
if final_signal is None:
return
# 10.1 H1 Multi-Timeframe Filter (Fix 5)
# Block M15 signal if it contradicts H1 bias
# 10.1 H1 Multi-Timeframe Filter - DISABLED (SMC-only mode)
# H1 bias still logged for dashboard but does NOT block trades
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",
)
logger.info(f"H1 Bias: {h1_bias} (monitoring only, not blocking)")
# 10.5 Check trade cooldown
if self._last_trade_time:
time_since_last = (datetime.now() - self._last_trade_time).total_seconds()
if time_since_last < self._trade_cooldown_seconds:
logger.debug(f"Trade cooldown: {self._trade_cooldown_seconds - time_since_last:.0f}s remaining")
logger.info(f"Trade cooldown: {self._trade_cooldown_seconds - time_since_last:.0f}s remaining")
return
# 10.6 PULLBACK FILTER - Prevent entry during temporary retracements
pullback_ok, pullback_reason = self._check_pullback_filter(
df=df,
signal_direction=final_signal.signal_type,
current_price=current_price,
)
if not pullback_ok:
if self._loop_count % 30 == 0: # Log every 30 loops
logger.info(f"Pullback Filter: {pullback_reason}")
return
# 10.6 PULLBACK FILTER - DISABLED (SMC-only mode)
# SMC structure already validates entry zones
# 11. SMART RISK CHECK - Ultra safe mode
self.smart_risk.check_new_day()
@@ -1111,6 +1072,9 @@ class TradingBot:
# Get dynamic threshold
dynamic_threshold = market_analysis.confidence_threshold
self._last_dynamic_threshold = dynamic_threshold
self._last_market_quality = market_analysis.quality.value
self._last_market_score = market_analysis.score
# Log dynamic analysis periodically
if self._loop_count % 60 == 0:
@@ -1140,80 +1104,12 @@ class TradingBot:
return None
# ============================================================
# IMPROVED SIGNAL LOGIC v3 - With ML Threshold & Confirmation
# SIGNAL LOGIC v4 - SMC-Only (ML DISABLED)
# ============================================================
golden_marker = "[GOLDEN] " if is_golden_time else ""
if smc_signal is not None:
# === IMPROVEMENT 1: ML Confidence Threshold ===
# Based on backtest tuning (Jan 2025 - Feb 2026):
# - 50% threshold: 485 trades, 61.6% WR, $3120 profit, PF 2.02
# - 55% threshold: 306 trades, 59.5% WR, $1443 profit, PF 1.74
# OPTIMAL: 50% threshold (more trades, higher WR, better profit)
ml_min_threshold = 0.50
if ml_prediction.confidence < ml_min_threshold:
if self._loop_count % 60 == 0:
logger.info(f"Skip: ML uncertain ({ml_prediction.confidence:.0%} < {ml_min_threshold:.0%}) - waiting for clearer signal")
return None
# Check if ML strongly disagrees (>65% opposite)
ml_strongly_disagrees = (
(smc_signal.signal_type == "BUY" and ml_prediction.signal == "SELL" and ml_prediction.confidence > 0.65) or
(smc_signal.signal_type == "SELL" and ml_prediction.signal == "BUY" and ml_prediction.confidence > 0.65)
)
if ml_strongly_disagrees:
if self._loop_count % 60 == 0:
logger.info(f"Skip: ML strongly disagrees ({ml_prediction.signal} {ml_prediction.confidence:.0%}) vs SMC {smc_signal.signal_type}")
return None
# === IMPROVEMENT 1.5: SELL Filter (OPTIMIZED) ===
# SELL signals historically have lower win rate than BUY
# Require ML agreement and higher confidence for SELL
if smc_signal.signal_type == "SELL":
if ml_prediction.signal != "SELL":
if self._loop_count % 60 == 0:
logger.info(f"Skip SELL: ML does not agree ({ml_prediction.signal} {ml_prediction.confidence:.0%})")
return None
if ml_prediction.confidence < 0.55:
if self._loop_count % 60 == 0:
logger.info(f"Skip SELL: ML confidence too low ({ml_prediction.confidence:.0%} < 55%)")
return None
# === IMPROVEMENT 2: Signal Confirmation (Entry Delay) ===
# Track signal persistence - only entry if signal consistent for 2+ candles
# 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 = self._load_signal_persistence()
# 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] < 1800
}
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:
count, _ = self._signal_persistence[signal_key]
self._signal_persistence[signal_key] = (count + 1, current_time)
# 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()
# ML filters DISABLED — trading based on SMC only
# Signal persistence DISABLED — SMC signal = immediate trade
# SMC-Only: Use SMC signal with confidence adjustment
ml_agrees = (
@@ -2142,10 +2038,6 @@ class TradingBot:
# Risk state
risk_rec = self.smart_risk.get_trading_recommendation()
# News Agent status
news_can_trade, news_reason, _ = self.news_agent.should_trade()
news_status = "SAFE" if news_can_trade else "BLOCKED"
# Execution stats
avg_exec = (sum(self._execution_times) / len(self._execution_times) * 1000) if self._execution_times else 0
uptime = (now - self._start_time).total_seconds() / 3600 # hours
@@ -2180,9 +2072,9 @@ class TradingBot:
uptime_hours=uptime,
total_loops=self._loop_count,
avg_execution_ms=avg_exec,
# News
news_status=news_status,
news_reason=news_reason,
# News - disabled
news_status="DISABLED",
news_reason="News agent disabled",
)
self._last_hourly_report_time = now
+42 -15
View File
@@ -166,8 +166,9 @@ class SmartMarketCloseHandler:
delta = target - now
hours_to_weekend = delta.total_seconds() / 3600
# Consider "near weekend" if within 12 hours of close (Friday afternoon WIB)
near_weekend = hours_to_weekend <= 12 and weekday == 4 # Friday only
# Consider "near weekend" if within 30 min of close (Saturday ~04:30 WIB)
# Market closes Saturday 05:00 WIB — Friday night trading is OK
near_weekend = hours_to_weekend <= 0.5 and weekday == 4 # Friday only
return near_weekend, hours_to_weekend
@@ -269,11 +270,15 @@ class SmartPositionManager:
def __init__(
self,
breakeven_pips: float = 15.0, # Move SL to breakeven after this profit
trail_start_pips: float = 25.0, # Start trailing after this profit
trail_step_pips: float = 10.0, # Trail by this amount
breakeven_pips: float = 15.0, # Fallback if ATR unavailable
trail_start_pips: float = 25.0, # Fallback if ATR unavailable
trail_step_pips: float = 10.0, # Fallback if ATR unavailable
min_profit_to_protect: float = 50.0, # Minimum $ profit to protect
max_drawdown_from_peak: float = 30.0, # Max % drawdown from peak profit
# ATR-adaptive exit multipliers (#24B: backtest +$373)
atr_be_mult: float = 2.0, # Breakeven = ATR * 2.0
atr_trail_start_mult: float = 4.0, # Trail start = ATR * 4.0
atr_trail_step_mult: float = 3.0, # Trail step = ATR * 3.0
# Market Close Handler settings
enable_market_close_handler: bool = True,
min_profit_before_close: float = 10.0, # Take profit if >= $10 near close
@@ -282,6 +287,9 @@ class SmartPositionManager:
self.breakeven_pips = breakeven_pips
self.trail_start_pips = trail_start_pips
self.trail_step_pips = trail_step_pips
self.atr_be_mult = atr_be_mult
self.atr_trail_start_mult = atr_trail_start_mult
self.atr_trail_step_mult = atr_trail_step_mult
self.min_profit_to_protect = min_profit_to_protect
self.max_drawdown_from_peak = max_drawdown_from_peak
@@ -325,9 +333,16 @@ class SmartPositionManager:
# Get market analysis
market_analysis = self._analyze_market(df_market, regime_state, ml_prediction)
# Get current ATR for adaptive exit levels (#24B)
current_atr = None
if "atr" in df_market.columns:
atr_val = df_market["atr"].tail(1).item()
if atr_val is not None and atr_val > 0:
current_atr = atr_val
for row in positions.iter_rows(named=True):
action = self._analyze_single_position(
row, market_analysis, current_price
row, market_analysis, current_price, current_atr
)
if action:
actions.append(action)
@@ -421,6 +436,7 @@ class SmartPositionManager:
pos: Dict,
market: Dict,
current_price: float,
current_atr: float = None,
) -> Optional[PositionAction]:
"""Analyze a single position and decide action."""
ticket = pos["ticket"]
@@ -525,30 +541,41 @@ class SmartPositionManager:
reason=f"High urgency exit (score: {market['urgency']}) - Securing ${profit:.2f}",
)
# === TRAILING STOP CONDITIONS ===
# === TRAILING STOP CONDITIONS (ATR-adaptive #24B) ===
# Compute adaptive levels from ATR (fall back to fixed pips if ATR unavailable)
if current_atr is not None and current_atr > 0:
# ATR is in price terms; convert to pips (1 pip = 0.1 for gold)
be_pips = current_atr * self.atr_be_mult / 0.1
trail_start = current_atr * self.atr_trail_start_mult / 0.1
trail_step = current_atr * self.atr_trail_step_mult / 0.1
else:
be_pips = self.breakeven_pips
trail_start = self.trail_start_pips
trail_step = self.trail_step_pips
# 5. Breakeven protection
if pip_profit >= self.breakeven_pips and current_sl != 0:
if pip_profit >= be_pips and current_sl != 0:
breakeven_sl = entry_price + (1 if is_buy else -1) * 2 # 2 points buffer
if is_buy and current_sl < breakeven_sl:
return PositionAction(
ticket=ticket,
action="TRAIL_SL",
reason=f"Moving SL to breakeven ({pip_profit:.1f} pips profit)",
reason=f"Moving SL to breakeven ({pip_profit:.1f}/{be_pips:.0f} pips)",
new_sl=breakeven_sl,
)
elif not is_buy and current_sl > breakeven_sl:
return PositionAction(
ticket=ticket,
action="TRAIL_SL",
reason=f"Moving SL to breakeven ({pip_profit:.1f} pips profit)",
reason=f"Moving SL to breakeven ({pip_profit:.1f}/{be_pips:.0f} pips)",
new_sl=breakeven_sl,
)
# 6. Trailing stop (after trail_start_pips)
if pip_profit >= self.trail_start_pips:
trail_distance = self.trail_step_pips * 0.1 # Convert to price
# 6. Trailing stop (after trail_start pips)
if pip_profit >= trail_start:
trail_distance = trail_step * 0.1 # Convert to price
if is_buy:
new_trail_sl = current_price - trail_distance
@@ -556,7 +583,7 @@ class SmartPositionManager:
return PositionAction(
ticket=ticket,
action="TRAIL_SL",
reason=f"Trailing SL ({pip_profit:.1f} pips profit)",
reason=f"Trailing SL ({pip_profit:.1f}/{trail_start:.0f} pips)",
new_sl=new_trail_sl,
)
else:
@@ -565,7 +592,7 @@ class SmartPositionManager:
return PositionAction(
ticket=ticket,
action="TRAIL_SL",
reason=f"Trailing SL ({pip_profit:.1f} pips profit)",
reason=f"Trailing SL ({pip_profit:.1f}/{trail_start:.0f} pips)",
new_sl=new_trail_sl,
)
+9 -8
View File
@@ -104,8 +104,8 @@ class SessionFilter:
start_hour=15, start_minute=0,
end_hour=16, end_minute=0,
volatility="high",
allow_trading=True,
position_size_multiplier=1.0,
allow_trading=False, # #24B: Skip Tokyo-London overlap (backtest +$345)
position_size_multiplier=0.0,
),
TradingSession.OVERLAP_LONDON_NY: SessionConfig(
name="London-NY Overlap (GOLDEN)",
@@ -196,10 +196,11 @@ class SessionFilter:
return False, ""
def is_friday_close(self) -> bool:
"""Check if approaching Friday market close."""
"""Check if approaching Friday market close (Saturday 05:00 WIB)."""
now = self.get_current_time_wib()
# Friday = 4 (Monday=0)
if now.weekday() == 4 and now.hour >= 23:
# Market closes Saturday 05:00 WIB — only block 30 min before
# Saturday 04:30+ WIB
if now.weekday() == 5 and now.hour == 4 and now.minute >= 30:
return True
return False
@@ -248,13 +249,13 @@ class SessionFilter:
if not config.allow_trading:
return False, f"Trading tidak diizinkan saat {config.name}", 0.0
# In aggressive mode, allow high volatility + Sydney (proven profitable)
# In aggressive mode, allow medium+ volatility + Sydney (proven profitable)
if self.aggressive_mode:
# Sydney session is ALLOWED - backtest shows 62% WR, $5,934 profit
if session == TradingSession.SYDNEY:
return True, f"Trading OK - {config.name} (SAFE MODE: 0.5x lot)", config.position_size_multiplier
# Other low volatility sessions not allowed
if config.volatility not in ["high", "extreme"]:
# Only block low volatility sessions
if config.volatility not in ["medium", "high", "extreme"]:
return False, f"Mode agresif: tunggu sesi {config.name} (volatilitas {config.volatility})", config.position_size_multiplier
return True, f"Trading OK - {config.name} ({config.volatility} volatility)", config.position_size_multiplier
+6 -2
View File
@@ -690,7 +690,7 @@ class SmartRiskManager:
loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
# Cut early if momentum is against us AND loss is significant
if momentum < -30 and loss_percent_of_max >= 30:
if momentum < -50 and loss_percent_of_max >= 30: # #24B: relaxed from -30 (backtest +$125)
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"
@@ -739,8 +739,12 @@ class SmartRiskManager:
return True, ExitReason.DAILY_LIMIT, f"[LIMIT] Would exceed daily loss limit"
# === CHECK 7: WEEKEND CLOSE ===
# Market closes Saturday 05:00 WIB — only close 30 min before (Saturday 04:30 WIB)
now = datetime.now(WIB)
if now.weekday() == 4 and now.hour >= 4: # Friday after 4 AM WIB
is_friday_late = now.weekday() == 4 and now.hour >= 4 and now.minute >= 30 # Sat 04:30 WIB = Fri weekday()==4 won't work
is_saturday_early = now.weekday() == 5 and now.hour < 5 # Saturday before 05:00 WIB
near_weekend_close = is_saturday_early and (now.hour >= 4 and now.minute >= 30) # Saturday 04:30+ WIB
if near_weekend_close:
if current_profit > 0:
return True, ExitReason.WEEKEND_CLOSE, f"[WEEKEND] Weekend close - profit ${current_profit:.2f}"
elif current_profit > -10: