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>
958 lines
40 KiB
Python
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}")
|