Files
xau-ai-trading-bot/src/smart_risk_manager.py
T
buckybonezandClaude Opus 4.6 a8d01995ab 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>
2026-02-07 22:34:24 +07:00

958 lines
40 KiB
Python

"""
Smart Risk Manager v2.0
========================
Sistem risk management cerdas untuk mencegah kerugian besar.
FILOSOFI: "Slow but Steady - Mental Health First"
- Lot size SANGAT KECIL (0.01-0.03)
- TANPA hard stop loss (menggunakan soft management)
- Hanya close jika trend BENAR-BENAR berbalik
- Recovery mode setelah loss
- Maximum loss per hari dibatasi ketat
Author: AI Assistant
"""
import os
from datetime import datetime, date, timedelta
from typing import Optional, Dict, Tuple, List
from dataclasses import dataclass, field
from enum import Enum
from zoneinfo import ZoneInfo
from loguru import logger
import polars as pl
WIB = ZoneInfo("Asia/Jakarta")
class TradingMode(Enum):
"""Mode trading berdasarkan kondisi."""
NORMAL = "normal" # Trading normal dengan lot kecil
RECOVERY = "recovery" # Setelah loss, lot lebih kecil lagi
PROTECTED = "protected" # Mendekati daily loss limit
STOPPED = "stopped" # Stop trading hari ini
class ExitReason(Enum):
"""Alasan untuk exit position."""
TAKE_PROFIT = "take_profit"
TREND_REVERSAL = "trend_reversal" # ML signal berbalik KUAT
DAILY_LIMIT = "daily_limit" # Mencapai daily loss limit
POSITION_LIMIT = "position_limit" # Mencapai max loss per trade (S/L)
TOTAL_LIMIT = "total_limit" # Mencapai total loss limit
WEEKEND_CLOSE = "weekend_close" # Menjelang weekend
MANUAL = "manual"
@dataclass
class RiskState:
"""Current risk state."""
mode: TradingMode = TradingMode.NORMAL
daily_profit: float = 0
daily_loss: float = 0
daily_trades: int = 0
consecutive_losses: int = 0
last_loss_amount: float = 0
can_trade: bool = True
reason: str = ""
recommended_lot: float = 0.01
max_allowed_lot: float = 0.03
@dataclass
class PositionGuard:
"""Guard untuk setiap position - menentukan kapan harus close."""
ticket: int
entry_price: float
entry_time: datetime
lot_size: float
direction: str # BUY or SELL
# Soft stops (hanya warning, tidak auto close)
soft_stop_price: float = 0
soft_stop_triggered: bool = False
# Hard protection (hanya close jika ini tercapai)
max_loss_usd: float = 50.0 # Maximum loss $50 per position
# Profit tracking
peak_profit: float = 0
current_profit: float = 0
# Exit conditions met
should_close: bool = False
close_reason: Optional[ExitReason] = None
# === SMART DYNAMIC TP TRACKING ===
# Target tracking
target_tp_price: float = 0 # Original TP target
target_tp_profit: float = 0 # Expected profit at TP
# Momentum tracking (untuk prediksi)
price_history: List[float] = field(default_factory=list) # Last N prices
profit_history: List[float] = field(default_factory=list) # Last N profits
ml_confidence_history: List[float] = field(default_factory=list) # ML confidence trend
# Smart analysis
momentum_score: float = 0 # -100 to +100, positive = moving towards TP
stall_count: int = 0 # Berapa kali harga stall/sideways
reversal_warnings: int = 0 # Jumlah warning ML reversal
def update_history(self, price: float, profit: float, ml_confidence: float, max_history: int = 20):
"""Update price/profit history untuk analisis momentum."""
self.price_history.append(price)
self.profit_history.append(profit)
self.ml_confidence_history.append(ml_confidence)
# Keep only last N entries
if len(self.price_history) > max_history:
self.price_history = self.price_history[-max_history:]
self.profit_history = self.profit_history[-max_history:]
self.ml_confidence_history = self.ml_confidence_history[-max_history:]
def calculate_momentum(self) -> float:
"""
Hitung momentum score -100 to +100.
Positive = bergerak ke arah TP (bagus)
Negative = bergerak menjauhi TP (bahaya)
"""
if len(self.profit_history) < 3:
return 0
# Recent profit change
recent = self.profit_history[-5:] if len(self.profit_history) >= 5 else self.profit_history
profit_change = recent[-1] - recent[0]
# Normalize: $10 change = 50 points
momentum = (profit_change / 10) * 50
momentum = max(-100, min(100, momentum))
self.momentum_score = momentum
return momentum
def get_tp_probability(self) -> float:
"""
Estimasi probabilitas mencapai TP (0-100%).
Faktor:
1. Jarak ke TP vs jarak sudah ditempuh
2. Momentum saat ini
3. ML confidence trend
4. Waktu sudah berjalan
"""
if self.target_tp_profit <= 0:
return 50 # Unknown TP
# Factor 1: Progress to TP (0-40 points)
progress = (self.current_profit / self.target_tp_profit) * 100 if self.target_tp_profit > 0 else 0
progress_score = min(40, max(0, progress * 0.4))
# Factor 2: Momentum (0-30 points)
momentum = self.calculate_momentum()
momentum_score = ((momentum + 100) / 200) * 30 # Convert -100..100 to 0..30
# Factor 3: ML confidence trend (0-20 points)
if len(self.ml_confidence_history) >= 3:
recent_conf = self.ml_confidence_history[-3:]
conf_trend = recent_conf[-1] - recent_conf[0]
conf_score = ((conf_trend + 0.3) / 0.6) * 20 # -0.3 to +0.3 → 0 to 20
conf_score = max(0, min(20, conf_score))
else:
conf_score = 10
# Factor 4: Time penalty (0-10 points lost)
time_elapsed = (datetime.now(WIB) - self.entry_time).total_seconds() / 3600 # hours
time_penalty = min(10, time_elapsed * 2) # Lose 2 points per hour
probability = progress_score + momentum_score + conf_score - time_penalty
return max(0, min(100, probability))
class SmartRiskManager:
"""
Smart Risk Manager - Sistem manajemen risiko cerdas.
PRINSIP UTAMA:
1. Lot size SANGAT KECIL (0.01-0.03 max)
2. TIDAK menggunakan hard stop loss
3. Hanya close jika trend BENAR-BENAR berbalik (ML confidence tinggi)
4. Maximum loss per hari: 5% of capital
5. Maximum total loss: 10% of capital (stop trading)
6. S/L 1% per trade
7. Recovery mode setelah loss besar
"""
def __init__(
self,
capital: float = 5000.0,
max_daily_loss_percent: float = 5.0, # Max 5% daily loss
max_total_loss_percent: float = 10.0, # Max 10% total loss (stop trading)
max_loss_per_trade_percent: float = 1.0, # Max 1% per trade (software S/L)
emergency_sl_percent: float = 2.0, # Emergency broker S/L 2% per trade
base_lot_size: float = 0.01, # Lot dasar sangat kecil
max_lot_size: float = 0.03, # Maximum lot
recovery_lot_size: float = 0.01, # Lot saat recovery
trend_reversal_threshold: float = 0.75, # ML confidence untuk close
max_concurrent_positions: int = 2, # Max posisi bersamaan
):
self.capital = capital
self.max_daily_loss_percent = max_daily_loss_percent
self.max_daily_loss_usd = capital * (max_daily_loss_percent / 100)
self.max_total_loss_percent = max_total_loss_percent
self.max_total_loss_usd = capital * (max_total_loss_percent / 100)
self.max_loss_per_trade_percent = max_loss_per_trade_percent
self.max_loss_per_trade = capital * (max_loss_per_trade_percent / 100) # Software S/L in USD
self.emergency_sl_percent = emergency_sl_percent
self.emergency_sl_usd = capital * (emergency_sl_percent / 100) # Broker S/L in USD
self.base_lot_size = base_lot_size
self.max_lot_size = max_lot_size
self.recovery_lot_size = recovery_lot_size
self.trend_reversal_threshold = trend_reversal_threshold
self.max_concurrent_positions = max_concurrent_positions
# Total loss tracking (across all days)
self._total_loss: float = 0.0
# State tracking
self._state = RiskState()
self._position_guards: Dict[int, PositionGuard] = {}
self._daily_pnl: List[float] = []
self._current_date = date.today()
# Load state
self._load_daily_state()
logger.info("=" * 50)
logger.info("SMART RISK MANAGER v2.2 INITIALIZED")
logger.info(f" Capital: ${capital:,.2f}")
logger.info(f" Max Daily Loss: {max_daily_loss_percent}% (${self.max_daily_loss_usd:.2f})")
logger.info(f" Max Total Loss: {max_total_loss_percent}% (${self.max_total_loss_usd:.2f})")
logger.info(f" Software S/L: {max_loss_per_trade_percent}% (${self.max_loss_per_trade:.2f})")
logger.info(f" Emergency Broker S/L: {emergency_sl_percent}% (${self.emergency_sl_usd:.2f})")
logger.info(f" Max Positions: {max_concurrent_positions}")
logger.info(f" Base Lot: {base_lot_size}")
logger.info(f" Max Lot: {max_lot_size}")
logger.info(" Mode: SMART S/L (software + broker safety net)")
logger.info("=" * 50)
def _load_daily_state(self):
"""Load daily state from file."""
state_file = "data/risk_state.txt"
backup_file = "data/risk_state.bak"
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 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:
# 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."""
if date.today() != self._current_date:
logger.info("=" * 40)
logger.info(f"NEW DAY - Resetting risk state")
logger.info(f"Yesterday P/L: ${self._state.daily_profit - self._state.daily_loss:.2f}")
logger.info("=" * 40)
self._current_date = date.today()
self._state = RiskState()
self._state.mode = TradingMode.NORMAL
self._daily_pnl = []
def update_capital(self, new_capital: float):
"""Update capital and recalculate ALL limits."""
self.capital = new_capital
self.max_daily_loss_usd = new_capital * (self.max_daily_loss_percent / 100)
self.max_total_loss_usd = new_capital * (self.max_total_loss_percent / 100)
self.max_loss_per_trade = new_capital * (self.max_loss_per_trade_percent / 100)
self.emergency_sl_usd = new_capital * (self.emergency_sl_percent / 100)
logger.info(f"Capital updated: ${new_capital:.2f}")
logger.info(f" Daily loss limit: {self.max_daily_loss_percent}% = ${self.max_daily_loss_usd:.2f}")
logger.info(f" Total loss limit: {self.max_total_loss_percent}% = ${self.max_total_loss_usd:.2f}")
logger.info(f" Software S/L: {self.max_loss_per_trade_percent}% = ${self.max_loss_per_trade:.2f}")
logger.info(f" Emergency Broker S/L: {self.emergency_sl_percent}% = ${self.emergency_sl_usd:.2f}")
def calculate_emergency_sl(
self,
entry_price: float,
direction: str,
lot_size: float,
symbol: str = "XAUUSD",
) -> float:
"""
Calculate emergency stop loss price (broker level).
This is the LAST LINE OF DEFENSE if software fails.
Set at 2% of capital (~$100) as max loss per trade.
Args:
entry_price: Entry price of the trade
direction: "BUY" or "SELL"
lot_size: Position size
symbol: Trading symbol
Returns:
Emergency SL price
"""
# For XAUUSD: 1 lot = $1 per 0.01 price movement (1 pip = $0.10 for 0.01 lot)
# pip_value = lot_size * 10 (for XAUUSD)
pip_value = lot_size * 10 # $1 per pip for 0.1 lot, $0.10 per pip for 0.01 lot
# Calculate how many pips = emergency_sl_usd
if pip_value > 0:
emergency_pips = self.emergency_sl_usd / pip_value
else:
emergency_pips = 1000 # Default fallback
# Convert pips to price movement (XAUUSD: 1 pip = 0.01)
price_distance = emergency_pips * 0.01
if direction.upper() == "BUY":
sl_price = entry_price - price_distance
else:
sl_price = entry_price + price_distance
logger.info(f"Emergency SL calculated: {sl_price:.2f} (${self.emergency_sl_usd:.2f} max loss)")
return round(sl_price, 2)
def can_open_position(self) -> Tuple[bool, str]:
"""
Check if we can open a new position.
Returns:
(can_open, reason)
"""
self._update_state()
# Check if trading is allowed
if not self._state.can_trade:
return False, f"Trading stopped: {self._state.reason}"
# Check max concurrent positions
active_positions = len(self._position_guards)
if active_positions >= self.max_concurrent_positions:
return False, f"Max positions reached ({active_positions}/{self.max_concurrent_positions})"
return True, f"Can open ({active_positions}/{self.max_concurrent_positions} positions)"
def get_state(self) -> RiskState:
"""Get current risk state."""
self._update_state()
return self._state
def _update_state(self):
"""Update risk state based on daily and total performance."""
net_pnl = self._state.daily_profit - self._state.daily_loss
# Check TOTAL loss limit (10%) - highest priority
if self._total_loss >= self.max_total_loss_usd:
self._state.mode = TradingMode.STOPPED
self._state.can_trade = False
self._state.reason = f"TOTAL LOSS LIMIT reached ({self.max_total_loss_percent}% = ${self._total_loss:.2f}) - TRADING STOPPED"
return
# Check daily loss limit (5%)
if self._state.daily_loss >= self.max_daily_loss_usd:
self._state.mode = TradingMode.STOPPED
self._state.can_trade = False
self._state.reason = f"Daily loss limit reached ({self.max_daily_loss_percent}% = ${self._state.daily_loss:.2f})"
return
# Check if approaching TOTAL limit (80%)
if self._total_loss >= self.max_total_loss_usd * 0.8:
self._state.mode = TradingMode.PROTECTED
self._state.recommended_lot = self.recovery_lot_size
self._state.max_allowed_lot = self.recovery_lot_size
self._state.reason = f"Approaching TOTAL loss limit ({self._total_loss:.2f}/${self.max_total_loss_usd:.2f}) - protected mode"
self._state.can_trade = True
return
# Check if approaching daily limit (80%)
if self._state.daily_loss >= self.max_daily_loss_usd * 0.8:
self._state.mode = TradingMode.PROTECTED
self._state.recommended_lot = self.recovery_lot_size
self._state.max_allowed_lot = self.recovery_lot_size
self._state.reason = "Approaching daily loss limit - protected mode"
self._state.can_trade = True
return
# Check consecutive losses
if self._state.consecutive_losses >= 3:
self._state.mode = TradingMode.RECOVERY
self._state.recommended_lot = self.recovery_lot_size
self._state.max_allowed_lot = self.base_lot_size
self._state.reason = f"{self._state.consecutive_losses} consecutive losses - recovery mode"
self._state.can_trade = True
return
# Normal mode
self._state.mode = TradingMode.NORMAL
self._state.recommended_lot = self.base_lot_size
self._state.max_allowed_lot = self.max_lot_size
self._state.can_trade = True
self._state.reason = "Normal trading mode"
def calculate_lot_size(
self,
entry_price: float,
confidence: float = 0.5,
regime: str = "normal",
ml_confidence: float = 0.5, # NEW: ML-specific confidence
) -> float:
"""
Calculate safe lot size with ML confidence adjustment.
PRINSIP: Lot size SANGAT KECIL
- Base: 0.01
- Max: 0.02 (reduced from 0.03)
IMPROVEMENT 3: ML Confidence-based sizing
- ML 50-55%: 0.01 lot (minimum) - uncertain
- ML 55-65%: 0.01 lot (base)
- ML >65%: 0.02 lot (max) - high confidence
"""
self._update_state()
if not self._state.can_trade:
return 0
# Start with base lot
lot = self.base_lot_size
# Adjust based on mode
if self._state.mode == TradingMode.RECOVERY:
lot = self.recovery_lot_size
elif self._state.mode == TradingMode.PROTECTED:
lot = self.recovery_lot_size
# === IMPROVEMENT 3: ML Confidence-based lot sizing ===
# Use the more conservative of confidence or ml_confidence
effective_confidence = min(confidence, ml_confidence)
if effective_confidence >= 0.65:
# High confidence: allow max lot
lot = self.max_lot_size
confidence_tier = "HIGH"
elif effective_confidence >= 0.55:
# Medium confidence: base lot
lot = self.base_lot_size
confidence_tier = "MEDIUM"
else:
# Low confidence: minimum lot
lot = self.recovery_lot_size
confidence_tier = "LOW"
# Adjust based on regime (override if risky)
if regime.lower() in ["high_volatility", "crisis"]:
lot = self.recovery_lot_size
confidence_tier = "VOLATILE"
# Cap at maximum
lot = min(lot, self._state.max_allowed_lot)
# Round to 0.01
lot = round(lot, 2)
logger.info(f"Calculated lot: {lot} (mode={self._state.mode.value}, ML={ml_confidence:.0%}, tier={confidence_tier})")
return lot
def register_position(
self,
ticket: int,
entry_price: float,
lot_size: float,
direction: str,
) -> PositionGuard:
"""
Register a new position for monitoring.
TIDAK menggunakan hard stop loss.
Menggunakan soft management berdasarkan:
- Maximum loss per position ($30-50)
- Trend reversal (ML confidence tinggi berlawanan)
"""
guard = PositionGuard(
ticket=ticket,
entry_price=entry_price,
entry_time=datetime.now(WIB),
lot_size=lot_size,
direction=direction,
max_loss_usd=self.max_loss_per_trade,
)
self._position_guards[ticket] = guard
logger.info(f"Position #{ticket} registered - NO HARD SL, max loss ${self.max_loss_per_trade}")
return guard
def auto_register_existing_position(
self,
ticket: int,
entry_price: float,
lot_size: float,
direction: str,
current_profit: float = 0,
) -> PositionGuard:
"""
Auto-register posisi yang sudah ada (dari sebelum bot start).
Penting untuk memastikan SEMUA posisi terlindungi oleh:
- Max loss $50 per trade
- ML reversal detection
- Daily loss tracking
"""
# Skip jika sudah registered
if ticket in self._position_guards:
return self._position_guards[ticket]
guard = PositionGuard(
ticket=ticket,
entry_price=entry_price,
entry_time=datetime.now(WIB), # Approximate, tidak tahu exact time
lot_size=lot_size,
direction=direction,
max_loss_usd=self.max_loss_per_trade,
current_profit=current_profit,
peak_profit=max(0, current_profit), # Track peak dari sekarang
)
self._position_guards[ticket] = guard
logger.info(f"Position #{ticket} AUTO-REGISTERED (existing) - Protected with max loss ${self.max_loss_per_trade}")
return guard
def is_position_registered(self, ticket: int) -> bool:
"""Check if position is registered."""
return ticket in self._position_guards
def evaluate_position(
self,
ticket: int,
current_price: float,
current_profit: float,
ml_signal: str,
ml_confidence: float,
regime: str = "normal",
) -> Tuple[bool, Optional[ExitReason], str]:
"""
SMART DYNAMIC TP - Evaluate if position should be closed.
TIDAK hanya menunggu TP tercapai, tapi juga:
1. Analisis momentum - apakah harga bergerak ke arah TP?
2. Probabilitas TP - masih mungkin tercapai?
3. ML confidence trend - apakah trend masih kuat?
4. Early exit jika probabilitas TP rendah
Returns: (should_close, reason, message)
"""
guard = self._position_guards.get(ticket)
if not guard:
return False, None, "Position not registered"
# === UPDATE TRACKING DATA ===
guard.current_profit = current_profit
if current_profit > guard.peak_profit:
guard.peak_profit = current_profit
# Update history untuk analisis momentum
guard.update_history(current_price, current_profit, ml_confidence)
# Calculate momentum dan TP probability
momentum = guard.calculate_momentum()
tp_probability = guard.get_tp_probability()
# === CHECK 1: SMART TAKE PROFIT ===
if current_profit >= 15: # Profit $15+
# A. Hard TP - profit sangat bagus
if current_profit >= 40:
return True, ExitReason.TAKE_PROFIT, f"[TP] Target profit reached: ${current_profit:.2f}"
# B. Momentum-based TP - profit bagus tapi momentum turun
if current_profit >= 25 and momentum < -30:
return True, ExitReason.TAKE_PROFIT, f"[SECURE] Securing ${current_profit:.2f} (momentum dropping: {momentum:.0f})"
# C. Peak protection - profit turun dari peak
if guard.peak_profit > 30 and current_profit < guard.peak_profit * 0.6:
return True, ExitReason.TAKE_PROFIT, f"[LOCK] Securing ${current_profit:.2f} (was ${guard.peak_profit:.2f} peak)"
# D. Low TP probability - kemungkinan TP rendah
if tp_probability < 25 and current_profit >= 20:
return True, ExitReason.TAKE_PROFIT, f"[PROB] Taking profit ${current_profit:.2f} (TP prob: {tp_probability:.0f}%)"
# E. Masih bagus, let it run
if momentum >= 0:
return False, None, f"Profit ${current_profit:.2f} [GOOD] (momentum: {momentum:+.0f}, TP prob: {tp_probability:.0f}%)"
# === CHECK 2: SMART EARLY EXIT (small profit) ===
if 5 <= current_profit < 15:
# Ambil profit kecil jika momentum sangat negatif
if momentum < -50 and ml_confidence >= 0.65:
# ML yakin trend berbalik
is_reversal = (
(guard.direction == "BUY" and ml_signal == "SELL") or
(guard.direction == "SELL" and ml_signal == "BUY")
)
if is_reversal:
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) ===
# 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
# 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
# Cut early if momentum is against us AND loss is significant
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"
# 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
is_reversal = False
if guard.direction == "BUY" and ml_signal == "SELL" and ml_confidence >= self.trend_reversal_threshold:
is_reversal = True
guard.reversal_warnings += 1
elif guard.direction == "SELL" and ml_signal == "BUY" and ml_confidence >= self.trend_reversal_threshold:
is_reversal = True
guard.reversal_warnings += 1
# LEBIH KETAT: Close pada reversal jika loss > 40% dari max (sebelumnya 60%)
loss_moderate = abs(current_profit) > (self.max_loss_per_trade * 0.4)
if is_reversal and current_profit < -8 and loss_moderate:
return True, ExitReason.TREND_REVERSAL, f"[REVERSAL] Reversal signal ({ml_signal} {ml_confidence:.0%}) - Loss: ${current_profit:.2f}"
# Close jika sudah 3x warning reversal (sebelumnya 5x)
if guard.reversal_warnings >= 3 and current_profit < -10:
return True, ExitReason.TREND_REVERSAL, f"[WARN] Multiple reversal warnings ({guard.reversal_warnings}x) - Loss: ${current_profit:.2f}"
# === CHECK 5: MAXIMUM LOSS PER TRADE (LEBIH KETAT) ===
# Close jika loss sudah 50%+ dari max (sebelumnya 80%)
if current_profit <= -(self.max_loss_per_trade * 0.50):
# Hanya hold jika golden time SANGAT dekat (1 jam) dan momentum tidak terlalu buruk
if hours_to_golden <= 1 and hours_to_golden > 0 and momentum > -40:
return False, None, f"LAST CHANCE HOLD: Loss ${abs(current_profit):.2f} | Golden in {hours_to_golden}h - waiting for recovery"
return True, ExitReason.POSITION_LIMIT, f"[S/L] Position loss limit: ${current_profit:.2f} (50% of ${self.max_loss_per_trade:.2f})"
# === CHECK 5: STALL DETECTION ===
# Jika harga tidak bergerak (stall) terlalu lama dengan loss
if len(guard.profit_history) >= 10:
recent_range = max(guard.profit_history[-10:]) - min(guard.profit_history[-10:])
if recent_range < 3 and current_profit < -15: # Stall dengan loss
guard.stall_count += 1
if guard.stall_count >= 5:
return True, ExitReason.TREND_REVERSAL, f"[STALL] Stalled with loss ${current_profit:.2f} - cutting"
# === CHECK 6: DAILY LOSS LIMIT ===
potential_daily_loss = self._state.daily_loss + abs(min(0, current_profit))
if potential_daily_loss >= self.max_daily_loss_usd:
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)
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:
return True, ExitReason.WEEKEND_CLOSE, f"[WEEKEND] Weekend close - small loss ${current_profit:.2f}"
# === CHECK 8: SMART TIME-BASED EXIT ===
# Don't cut winners short - check profit growth and trend
trade_duration_hours = (now - guard.entry_time).total_seconds() / 3600
# Check if profit is growing (positive momentum = don't exit early)
profit_growing = momentum > 0
ml_agrees = (
(guard.direction == "BUY" and ml_signal == "BUY") or
(guard.direction == "SELL" and ml_signal == "SELL")
)
# 4+ hours: Only exit if stuck (no profit growth)
if trade_duration_hours >= 4:
if current_profit < 5 and not profit_growing:
# Stuck with no growth - exit
if current_profit >= 0:
return True, ExitReason.TAKE_PROFIT, f"[TIMEOUT] Breakeven + no growth after {trade_duration_hours:.1f}h"
elif current_profit > -15:
return True, ExitReason.TREND_REVERSAL, f"[TIMEOUT] Small loss ${current_profit:.2f} + no growth after {trade_duration_hours:.1f}h"
elif current_profit >= 5 and profit_growing and ml_agrees:
# Profitable and growing - extend time (log only)
logger.debug(f"[TIME OK] Profit growing +${current_profit:.2f}, extending time (was {trade_duration_hours:.1f}h)")
# 6+ hours: Exit unless significantly profitable AND still growing
if trade_duration_hours >= 6:
if current_profit < 10 or not profit_growing:
return True, ExitReason.TREND_REVERSAL, f"[MAX TIME] {trade_duration_hours:.1f}h - profit ${current_profit:.2f}"
# If profit > $10 and growing, allow up to 8 hours
elif trade_duration_hours >= 8:
return True, ExitReason.TAKE_PROFIT, f"[MAX TIME] Taking profit ${current_profit:.2f} after {trade_duration_hours:.1f}h"
# === DEFAULT: HOLD ===
status = f"+${current_profit:.2f}" if current_profit > 0 else f"-${abs(current_profit):.2f}"
return False, None, f"HOLD {status} | Mom: {momentum:+.0f} | TP%: {tp_probability:.0f} | ML: {ml_signal}({ml_confidence:.0%})"
def record_trade_result(self, profit: float) -> Dict:
"""
Record trade result for daily and total tracking.
Returns:
Dict with status info including any limit violations
"""
self._daily_pnl.append(profit)
result = {
"profit": profit,
"daily_loss": 0,
"total_loss": 0,
"daily_limit_hit": False,
"total_limit_hit": False,
"can_trade": True,
}
if profit >= 0:
self._state.daily_profit += profit
self._state.consecutive_losses = 0
# Reduce total loss with profit (recovery)
self._total_loss = max(0, self._total_loss - profit)
logger.info(f"PROFIT recorded: +${profit:.2f} | Daily: +${self._state.daily_profit:.2f} | Total Loss: ${self._total_loss:.2f}")
else:
loss_amount = abs(profit)
self._state.daily_loss += loss_amount
self._total_loss += loss_amount # Add to total loss
self._state.consecutive_losses += 1
self._state.last_loss_amount = loss_amount
logger.warning(f"LOSS recorded: -${loss_amount:.2f} | Daily loss: ${self._state.daily_loss:.2f} | Total Loss: ${self._total_loss:.2f}")
# Check if we should stop - TOTAL loss limit
if self._total_loss >= self.max_total_loss_usd:
self._state.mode = TradingMode.STOPPED
self._state.can_trade = False
result["total_limit_hit"] = True
result["can_trade"] = False
logger.error(f"TOTAL LOSS LIMIT REACHED ({self.max_total_loss_percent}%) - TRADING STOPPED PERMANENTLY")
# Check if we should stop - daily loss limit
elif self._state.daily_loss >= self.max_daily_loss_usd:
self._state.mode = TradingMode.STOPPED
self._state.can_trade = False
result["daily_limit_hit"] = True
result["can_trade"] = False
logger.error(f"DAILY LOSS LIMIT REACHED ({self.max_daily_loss_percent}%) - STOPPING TRADING TODAY")
result["daily_loss"] = self._state.daily_loss
result["total_loss"] = self._total_loss
self._save_daily_state()
self._update_state()
return result
def unregister_position(self, ticket: int):
"""Remove position from monitoring."""
if ticket in self._position_guards:
del self._position_guards[ticket]
def get_trading_recommendation(self) -> Dict:
"""Get trading recommendation based on current state."""
self._update_state()
return {
"can_trade": self._state.can_trade,
"mode": self._state.mode.value,
"reason": self._state.reason,
"recommended_lot": self._state.recommended_lot,
"max_lot": self._state.max_allowed_lot,
"daily_profit": self._state.daily_profit,
"daily_loss": self._state.daily_loss,
"daily_net": self._state.daily_profit - self._state.daily_loss,
"remaining_daily_risk": max(0, self.max_daily_loss_usd - self._state.daily_loss),
"total_loss": self._total_loss,
"remaining_total_risk": max(0, self.max_total_loss_usd - self._total_loss),
"max_loss_per_trade": self.max_loss_per_trade,
"consecutive_losses": self._state.consecutive_losses,
}
def should_use_stop_loss(self) -> Tuple[bool, str]:
"""
Determine if we should use stop loss.
REKOMENDASI: TIDAK menggunakan hard stop loss.
Alasan:
1. Market sering "sweep" stop loss sebelum reversal
2. Dengan lot kecil, bisa hold lebih lama
3. ML akan mendeteksi trend reversal yang sebenarnya
"""
return False, "Smart management tanpa hard SL - lot kecil, hold through volatility"
def reset_total_loss(self):
"""Reset total loss counter (admin function - use with caution)."""
old_total = self._total_loss
self._total_loss = 0.0
self._save_daily_state()
logger.warning(f"TOTAL LOSS RESET: ${old_total:.2f} -> $0.00")
self._update_state()
def get_risk_summary(self) -> str:
"""Get human-readable risk summary."""
self._update_state()
lines = [
"=" * 40,
"RISK MANAGEMENT SUMMARY",
"=" * 40,
f"Capital: ${self.capital:.2f}",
f"",
f"Daily Loss: ${self._state.daily_loss:.2f} / ${self.max_daily_loss_usd:.2f} ({self.max_daily_loss_percent}%)",
f"Total Loss: ${self._total_loss:.2f} / ${self.max_total_loss_usd:.2f} ({self.max_total_loss_percent}%)",
f"S/L Per Trade: ${self.max_loss_per_trade:.2f} ({self.max_loss_per_trade_percent}%)",
f"",
f"Mode: {self._state.mode.value}",
f"Can Trade: {self._state.can_trade}",
f"Reason: {self._state.reason}",
"=" * 40,
]
return "\n".join(lines)
def create_smart_risk_manager(capital: float = 5000.0) -> SmartRiskManager:
"""Create smart risk manager instance with NEW settings."""
return SmartRiskManager(
capital=capital,
max_daily_loss_percent=5.0, # Max 5% daily loss
max_total_loss_percent=10.0, # Max 10% total loss (stop trading)
max_loss_per_trade_percent=1.0, # S/L 1% per trade (software)
emergency_sl_percent=2.0, # Emergency broker SL 2% per trade
base_lot_size=0.01, # Base lot 0.01 (minimum)
max_lot_size=0.02, # Maximum 0.02 (sangat kecil)
recovery_lot_size=0.01, # Saat recovery tetap 0.01
trend_reversal_threshold=0.65, # Close jika ML 65%+ yakin (lebih sensitif)
max_concurrent_positions=2, # Max 2 posisi bersamaan
)
if __name__ == "__main__":
# Test dengan modal $50
print("=" * 50)
print("TESTING DENGAN MODAL $50")
print("=" * 50)
manager = create_smart_risk_manager(50)
print("\n=== Risk Settings ===")
print(f"Capital: ${manager.capital:.2f}")
print(f"Daily Loss Limit: {manager.max_daily_loss_percent}% = ${manager.max_daily_loss_usd:.2f}")
print(f"Total Loss Limit: {manager.max_total_loss_percent}% = ${manager.max_total_loss_usd:.2f}")
print(f"S/L Per Trade: {manager.max_loss_per_trade_percent}% = ${manager.max_loss_per_trade:.2f}")
print("\n=== Risk State ===")
state = manager.get_state()
print(f"Mode: {state.mode.value}")
print(f"Can Trade: {state.can_trade}")
print(f"Recommended Lot: {state.recommended_lot}")
print("\n=== Lot Calculation ===")
lot = manager.calculate_lot_size(4950, confidence=0.70)
print(f"Calculated Lot: {lot}")
print("\n=== Trading Recommendation ===")
rec = manager.get_trading_recommendation()
for k, v in rec.items():
print(f" {k}: {v}")
print("\n=== Stop Loss Recommendation ===")
use_sl, reason = manager.should_use_stop_loss()
print(f"Use Stop Loss: {use_sl}")
print(f"Reason: {reason}")