Implement industrial-standard semantic versioning (SemVer 2.0.0) with automated feature detection and comprehensive changelog management. New Features: - VERSION file: Single source of truth for base version (0.0.0) - src/version.py: Centralized version manager with auto-detection - CHANGELOG.md: Keep a Changelog format for all changes - Auto-versioning: Features increment MINOR version automatically - Version display: Shows in startup banner and logs Predictive Intelligence (v6.3) Complete: - src/trajectory_predictor.py: Forecast profit 1-5 minutes ahead - src/momentum_persistence.py: Detect momentum continuation (0-1 score) - src/recovery_detector.py: Analyze recovery strength from losses - src/fuzzy_exit_logic.py: Fuzzy logic exit confidence (0-1) - src/kalman_filter.py: Kalman filter for velocity smoothing - src/kelly_position_scaler.py: Kelly criterion position scaling Version Calculation: Base 0.0.0 + Kalman(0.1) + Fuzzy(0.1) + Kelly(0.1) + Trajectory(0.1) + Momentum(0.1) + Recovery(0.1) = v0.6.0 Modified: - CLAUDE.md: Added comprehensive versioning documentation - main_live.py: Display version in startup banner - src/smart_risk_manager.py: Use centralized versioning Documentation: - CLAUDE.md: Full versioning guidelines (SemVer, workflows, examples) - CHANGELOG.md: Initial release documentation with feature tracking - VERSION: Base version 0.0.0 Benefits: - Professional version management (industry standard) - Automatic feature tracking and version updates - Complete change history with Keep a Changelog format - Clear upgrade paths (MAJOR.MINOR.PATCH) Version: v0.6.0 (Kalman + Fuzzy + Kelly + Predictive) Exit Strategy: v6.3 Predictive Intelligence Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1978 lines
93 KiB
Python
1978 lines
93 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
|
||
import time
|
||
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")
|
||
|
||
# Feature flags
|
||
_KALMAN_ENABLED = os.environ.get("KALMAN_ENABLED", "1") == "1"
|
||
_ADVANCED_EXITS_ENABLED = os.environ.get("ADVANCED_EXITS_ENABLED", "1") == "1"
|
||
_PREDICTIVE_ENABLED = os.environ.get("PREDICTIVE_ENABLED", "1") == "1" # v6.3 Predictive features
|
||
|
||
|
||
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
|
||
profit_capture_count: int = 0 # Consecutive intervals with profit >= tp_min + velocity <= 0
|
||
|
||
# === VELOCITY & ACCELERATION TRACKING ===
|
||
profit_timestamps: List[float] = field(default_factory=list) # time.time() per entry
|
||
velocity: float = 0.0 # $/second (profit change rate)
|
||
acceleration: float = 0.0 # $/s² (velocity change rate)
|
||
prev_velocity: float = 0.0 # previous velocity for acceleration calc
|
||
stagnation_seconds: float = 0.0 # how long velocity near zero
|
||
last_significant_move_time: float = 0.0 # last time velocity exceeded threshold
|
||
last_momentum_log_time: float = 0.0 # throttle logging per ticket
|
||
|
||
# === RECOVERY TRACKING (v4) ===
|
||
min_profit_seen: float = 0.0 # Lowest profit ever seen for this trade
|
||
recovery_count: int = 0 # How many times trade bounced from loss to profit
|
||
has_recovered: bool = False # True if trade recovered from significant loss to positive
|
||
was_positive: bool = False # True if trade was ever meaningfully positive
|
||
|
||
# === SMART PROFIT DETECTION (v5b) ===
|
||
peak_update_time: float = 0.0 # time.time() when peak was last updated
|
||
failed_peak_attempts: int = 0 # Times price approached but failed to exceed peak
|
||
velocity_was_positive: bool = False # Velocity was positive in recent past
|
||
velocity_sign_flips: int = 0 # Consecutive vel positive→negative transitions
|
||
decel_at_profit_count: int = 0 # Consecutive readings with negative accel while in profit
|
||
profit_stall_start_time: float = 0.0 # time.time() when profit stall began
|
||
profit_stall_anchor: float = 0.0 # Profit level when stall started
|
||
rsi_extreme_count: int = 0 # Consecutive readings at RSI/Stoch extreme
|
||
|
||
# === KALMAN FILTER (v6) ===
|
||
kalman: object = None # ProfitKalmanFilter instance (lazy init)
|
||
kalman_velocity: float = 0.0 # Kalman-filtered velocity ($/s)
|
||
kalman_acceleration: float = 0.0 # Kalman-filtered acceleration ($/s^2)
|
||
|
||
# === ADVANCED EXIT SYSTEMS (v7) ===
|
||
ekf: object = None # ExtendedKalmanFilter instance (lazy init)
|
||
ekf_velocity: float = 0.0 # EKF velocity (3D state)
|
||
ekf_acceleration: float = 0.0 # EKF acceleration (3D state)
|
||
pid_controller: object = None # PIDExitController instance
|
||
|
||
# === v6.3 PREDICTIVE INTELLIGENCE ===
|
||
velocity_history: List[float] = field(default_factory=list) # Historical velocity values
|
||
acceleration_history: List[float] = field(default_factory=list) # Historical acceleration values
|
||
peak_loss: float = 0.0 # Most negative profit ever reached (for recovery detection)
|
||
last_profit_for_derivative: float = 0.0 # For velocity derivative calculation
|
||
|
||
def update_history(self, price: float, profit: float, ml_confidence: float, max_history: int = 20):
|
||
"""Update price/profit history untuk analisis momentum."""
|
||
now = time.time()
|
||
self.price_history.append(price)
|
||
self.profit_history.append(profit)
|
||
self.ml_confidence_history.append(ml_confidence)
|
||
self.profit_timestamps.append(now)
|
||
|
||
# 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:]
|
||
self.profit_timestamps = self.profit_timestamps[-max_history:]
|
||
|
||
# Update velocity, acceleration, and stagnation
|
||
self._calculate_velocity_acceleration()
|
||
self._update_stagnation(now)
|
||
|
||
# === Kalman filter update (v6) ===
|
||
# V7: Use EKF if advanced exits enabled, otherwise use basic Kalman
|
||
if _KALMAN_ENABLED:
|
||
# Use basic 2D Kalman filter
|
||
if self.kalman is None:
|
||
try:
|
||
from src.kalman_filter import ProfitKalmanFilter
|
||
self.kalman = ProfitKalmanFilter()
|
||
except ImportError:
|
||
pass
|
||
if self.kalman is not None:
|
||
_, self.kalman_velocity, self.kalman_acceleration = (
|
||
self.kalman.update(profit, now)
|
||
)
|
||
|
||
# === v6.3 PREDICTIVE: Track velocity/acceleration history ===
|
||
if _PREDICTIVE_ENABLED:
|
||
self.velocity_history.append(self.velocity)
|
||
self.acceleration_history.append(self.acceleration)
|
||
# Keep last N samples only
|
||
if len(self.velocity_history) > max_history:
|
||
self.velocity_history = self.velocity_history[-max_history:]
|
||
if len(self.acceleration_history) > max_history:
|
||
self.acceleration_history = self.acceleration_history[-max_history:]
|
||
|
||
# === Recovery tracking (v4) ===
|
||
if profit < self.min_profit_seen:
|
||
self.min_profit_seen = profit
|
||
# v6.3: Track peak loss for recovery detection
|
||
if profit < self.peak_loss:
|
||
self.peak_loss = profit
|
||
if profit >= 1.0:
|
||
self.was_positive = True
|
||
# Detect recovery: trade was at significant loss (<-$2) and now positive
|
||
if self.min_profit_seen < -2.0 and profit > 0 and not self.has_recovered:
|
||
self.has_recovered = True
|
||
self.recovery_count += 1
|
||
|
||
# === Smart Profit Detection tracking (v5b) ===
|
||
# Track peak freshness
|
||
if profit >= self.peak_profit and profit > 0:
|
||
self.peak_update_time = now
|
||
self.failed_peak_attempts = 0 # Reset: new peak achieved
|
||
elif profit > 0 and self.peak_profit > 0 and profit >= self.peak_profit * 0.85:
|
||
# Approached peak (within 85%) but didn't break it
|
||
self.failed_peak_attempts += 1
|
||
|
||
# Track velocity sign transitions (positive → negative)
|
||
if self.velocity < -0.01 and self.velocity_was_positive:
|
||
self.velocity_sign_flips += 1
|
||
elif self.velocity > 0.01:
|
||
self.velocity_was_positive = True
|
||
self.velocity_sign_flips = 0 # Reset: back to positive
|
||
|
||
# Track deceleration while in profit
|
||
if profit > 0 and self.acceleration < -0.001 and self.velocity < self.prev_velocity:
|
||
self.decel_at_profit_count += 1
|
||
elif profit <= 0 or self.acceleration >= 0:
|
||
self.decel_at_profit_count = 0
|
||
|
||
# Track profit stall (profit in narrow range)
|
||
if profit > 0 and len(self.profit_history) >= 3:
|
||
recent_3 = self.profit_history[-3:]
|
||
stall_range = max(recent_3) - min(recent_3)
|
||
if stall_range < 1.0: # Profit barely moving ($1 range)
|
||
if self.profit_stall_start_time == 0:
|
||
self.profit_stall_start_time = now
|
||
self.profit_stall_anchor = profit
|
||
else:
|
||
self.profit_stall_start_time = 0 # Reset: profit is moving
|
||
|
||
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))
|
||
|
||
def _calculate_velocity_acceleration(self):
|
||
"""Calculate velocity ($/s) from last 5 samples and acceleration ($/s²) from split-half."""
|
||
if len(self.profit_timestamps) < 2:
|
||
return
|
||
|
||
# Velocity from last 5 samples (or all if < 5)
|
||
n = min(5, len(self.profit_timestamps))
|
||
recent_times = self.profit_timestamps[-n:]
|
||
recent_profits = self.profit_history[-n:]
|
||
dt = recent_times[-1] - recent_times[0]
|
||
if dt > 0:
|
||
self.prev_velocity = self.velocity
|
||
self.velocity = (recent_profits[-1] - recent_profits[0]) / dt
|
||
else:
|
||
self.velocity = 0.0
|
||
|
||
# Acceleration from split-half comparison (need >= 6 samples)
|
||
if len(self.profit_timestamps) >= 6:
|
||
mid = len(self.profit_timestamps) // 2
|
||
|
||
t1 = self.profit_timestamps[:mid]
|
||
p1 = self.profit_history[:mid]
|
||
dt1 = t1[-1] - t1[0]
|
||
v1 = (p1[-1] - p1[0]) / dt1 if dt1 > 0 else 0.0
|
||
|
||
t2 = self.profit_timestamps[mid:]
|
||
p2 = self.profit_history[mid:]
|
||
dt2 = t2[-1] - t2[0]
|
||
v2 = (p2[-1] - p2[0]) / dt2 if dt2 > 0 else 0.0
|
||
|
||
dt_total = self.profit_timestamps[-1] - self.profit_timestamps[0]
|
||
self.acceleration = (v2 - v1) / dt_total if dt_total > 0 else 0.0
|
||
|
||
def _update_stagnation(self, now: float):
|
||
"""Track how long velocity stays near zero (< 0.05 $/s)."""
|
||
if abs(self.velocity) < 0.05:
|
||
# Stagnating — accumulate time since last update
|
||
if len(self.profit_timestamps) >= 2:
|
||
dt = self.profit_timestamps[-1] - self.profit_timestamps[-2]
|
||
self.stagnation_seconds += dt
|
||
else:
|
||
# Moving — reset stagnation and record significant move
|
||
self.stagnation_seconds = 0.0
|
||
self.last_significant_move_time = now
|
||
|
||
def get_velocity_summary(self) -> Dict:
|
||
"""Return dict with velocity metrics for logging."""
|
||
return {
|
||
"velocity": round(self.velocity, 4),
|
||
"acceleration": round(self.acceleration, 4),
|
||
"stagnation_s": round(self.stagnation_seconds, 1),
|
||
"samples": len(self.profit_timestamps),
|
||
}
|
||
|
||
|
||
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()
|
||
|
||
# === Advanced Exit Systems (v7) ===
|
||
self._init_advanced_exit_systems()
|
||
|
||
logger.info("=" * 50)
|
||
# Get version from centralized version manager
|
||
try:
|
||
from src.version import get_version, __exit_strategy__
|
||
version_str = f"v{get_version()} ({__exit_strategy__})"
|
||
except ImportError:
|
||
if _PREDICTIVE_ENABLED and _ADVANCED_EXITS_ENABLED:
|
||
version_str = "v0.6.0 (Exit v6.3 Predictive Intelligence)"
|
||
elif _ADVANCED_EXITS_ENABLED:
|
||
version_str = "v0.3.0 (Exit v6.2 Advanced)"
|
||
else:
|
||
version_str = "v0.1.0 (Exit v6.0 Kalman)"
|
||
logger.info(f"SMART RISK MANAGER {version_str} 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)")
|
||
if _ADVANCED_EXITS_ENABLED:
|
||
if _PREDICTIVE_ENABLED:
|
||
logger.info(" Advanced Exits: ENABLED (Kalman + Fuzzy + Kelly + Predictive)")
|
||
else:
|
||
logger.info(" Advanced Exits: ENABLED (Kalman + Fuzzy + Kelly)")
|
||
logger.info("=" * 50)
|
||
|
||
def _init_advanced_exit_systems(self):
|
||
"""Initialize advanced exit systems (v7) - Kalman + Fuzzy + Kelly + Predictive (v6.3)."""
|
||
if not _ADVANCED_EXITS_ENABLED:
|
||
self.fuzzy_controller = None
|
||
self.kelly_scaler = None
|
||
self.trajectory_predictor = None
|
||
self.momentum_persistence = None
|
||
self.recovery_detector = None
|
||
return
|
||
|
||
try:
|
||
# Fuzzy Logic Controller
|
||
from src.fuzzy_exit_logic import FuzzyExitController
|
||
self.fuzzy_controller = FuzzyExitController()
|
||
logger.info(" [OK] Fuzzy Exit Controller initialized")
|
||
except Exception as e:
|
||
logger.warning(f"Could not initialize FuzzyExitController: {e}")
|
||
self.fuzzy_controller = None
|
||
|
||
try:
|
||
# Kelly Position Scaler
|
||
from src.kelly_position_scaler import KellyPositionScaler
|
||
self.kelly_scaler = KellyPositionScaler(
|
||
base_win_rate=0.55,
|
||
avg_win=8.0,
|
||
avg_loss=4.0,
|
||
kelly_fraction=0.5,
|
||
)
|
||
logger.info(" [OK] Kelly Position Scaler initialized")
|
||
except Exception as e:
|
||
logger.warning(f"Could not initialize KellyPositionScaler: {e}")
|
||
self.kelly_scaler = None
|
||
|
||
# === v6.3 PREDICTIVE INTELLIGENCE ===
|
||
if _PREDICTIVE_ENABLED:
|
||
try:
|
||
# Trajectory Predictor - Forecast profit 1-5 minutes ahead
|
||
from src.trajectory_predictor import TrajectoryPredictor
|
||
self.trajectory_predictor = TrajectoryPredictor()
|
||
logger.info(" [OK] Trajectory Predictor initialized")
|
||
except Exception as e:
|
||
logger.warning(f"Could not initialize TrajectoryPredictor: {e}")
|
||
self.trajectory_predictor = None
|
||
|
||
try:
|
||
# Momentum Persistence - Detect if momentum will continue
|
||
from src.momentum_persistence import MomentumPersistence
|
||
self.momentum_persistence = MomentumPersistence(lookback_periods=5)
|
||
logger.info(" [OK] Momentum Persistence initialized")
|
||
except Exception as e:
|
||
logger.warning(f"Could not initialize MomentumPersistence: {e}")
|
||
self.momentum_persistence = None
|
||
|
||
try:
|
||
# Recovery Detector - Analyze recovery strength from losses
|
||
from src.recovery_detector import RecoveryDetector
|
||
self.recovery_detector = RecoveryDetector()
|
||
logger.info(" [OK] Recovery Detector initialized")
|
||
except Exception as e:
|
||
logger.warning(f"Could not initialize RecoveryDetector: {e}")
|
||
self.recovery_detector = None
|
||
else:
|
||
self.trajectory_predictor = None
|
||
self.momentum_persistence = None
|
||
self.recovery_detector = None
|
||
|
||
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
|
||
|
||
# Baseline ATR for XAUUSD M15 (long-term average, updated periodically)
|
||
_BASELINE_ATR: float = 18.0 # Conservative default
|
||
|
||
def _classify_trade_state(self, guard) -> str:
|
||
"""
|
||
Classify the trade's velocity pattern into a state.
|
||
Used for dynamic threshold adjustments.
|
||
"""
|
||
vel = guard.velocity
|
||
accel = guard.acceleration
|
||
if vel > 0.05 and accel > 0:
|
||
return "accelerating_profit" # Best case: profit growing faster
|
||
elif vel > 0.02:
|
||
return "steady_profit" # Profit still growing
|
||
elif abs(vel) <= 0.02:
|
||
return "stalling" # Not moving much
|
||
elif vel < -0.05 and accel < -0.001:
|
||
return "crashing" # Fast loss, getting worse
|
||
elif vel < -0.02:
|
||
return "declining" # Losing but may stabilize
|
||
return "neutral"
|
||
|
||
def _calculate_dynamic_multipliers(
|
||
self, guard, regime: str, ml_signal: str, ml_confidence: float,
|
||
market_context: Optional[Dict] = None,
|
||
) -> Tuple[float, float]:
|
||
"""
|
||
Calculate dynamic multipliers for profit targets and loss tolerance.
|
||
|
||
Returns: (profit_mult, loss_mult)
|
||
- profit_mult > 1 = let profits run further
|
||
- loss_mult > 1 = give more room before cutting
|
||
"""
|
||
profit_mult = 1.0
|
||
loss_mult = 1.0
|
||
|
||
# === 1. REGIME ADJUSTMENT ===
|
||
if regime == "trending":
|
||
profit_mult *= 1.5 # Trending: big moves expected, let profit run
|
||
loss_mult *= 0.7 # Trending: if against us, cut faster
|
||
elif regime in ("ranging", "mean_reverting"):
|
||
profit_mult *= 0.6 # Ranging: take what you can, price will bounce
|
||
loss_mult *= 1.3 # Ranging: give room, will likely bounce back
|
||
elif regime in ("high_volatility", "volatile", "crisis"):
|
||
profit_mult *= 1.3 # Volatile: big moves possible
|
||
loss_mult *= 1.5 # Volatile: swings are normal, give room
|
||
|
||
# === 2. ML AGREEMENT ===
|
||
ml_agrees = (
|
||
(guard.direction == "BUY" and ml_signal == "BUY") or
|
||
(guard.direction == "SELL" and ml_signal == "SELL")
|
||
)
|
||
ml_disagrees = (
|
||
(guard.direction == "BUY" and ml_signal == "SELL") or
|
||
(guard.direction == "SELL" and ml_signal == "BUY")
|
||
)
|
||
if ml_agrees and ml_confidence >= 0.60:
|
||
conf_bonus = min(0.3, (ml_confidence - 0.60) * 1.5) # 0-0.3 bonus
|
||
profit_mult *= (1.2 + conf_bonus) # ML agrees: let it run
|
||
loss_mult *= (1.2 + conf_bonus) # ML agrees: give room
|
||
elif ml_disagrees and ml_confidence >= 0.65:
|
||
conf_penalty = min(0.3, (ml_confidence - 0.65) * 1.5)
|
||
profit_mult *= (0.7 - conf_penalty) # ML disagrees: take profit sooner
|
||
loss_mult *= (1.0 + conf_penalty * 0.5) # v6 FIX: WIDEN loss tolerance (ML 56% accuracy)
|
||
|
||
# === 3. VELOCITY PATTERN ===
|
||
trade_state = self._classify_trade_state(guard)
|
||
if trade_state == "accelerating_profit":
|
||
profit_mult *= 1.3 # Momentum strong: let it run
|
||
elif trade_state == "crashing":
|
||
profit_mult *= 0.5 # Crashing: take any profit you can
|
||
loss_mult *= 0.7 # Crashing: cut losses faster
|
||
elif trade_state == "declining":
|
||
profit_mult *= 0.8
|
||
loss_mult *= 0.9
|
||
|
||
# === 4. RECOVERY BONUS ===
|
||
if guard.has_recovered:
|
||
loss_mult *= 1.5 # Trade proved it can bounce back
|
||
|
||
# === 5. MARKET CONTEXT (RSI, ADX, Stochastic) ===
|
||
if market_context:
|
||
rsi = market_context.get("rsi", 50)
|
||
adx = market_context.get("adx", 25)
|
||
stoch_k = market_context.get("stoch_k", 50)
|
||
|
||
# ADX: trend strength
|
||
if adx > 30:
|
||
profit_mult *= 1.2 # Strong trend: let profits run
|
||
loss_mult *= 1.1 # Strong trend: slightly more room
|
||
elif adx < 15:
|
||
profit_mult *= 0.7 # No trend: take profits sooner
|
||
loss_mult *= 1.2 # No trend: ranging = give room
|
||
|
||
# RSI extremes: reversal likely
|
||
if guard.direction == "BUY" and rsi > 75:
|
||
profit_mult *= 0.7 # Overbought: take profits for BUY
|
||
elif guard.direction == "SELL" and rsi < 25:
|
||
profit_mult *= 0.7 # Oversold: take profits for SELL
|
||
elif guard.direction == "BUY" and rsi < 30:
|
||
loss_mult *= 1.3 # Oversold: BUY should recover
|
||
elif guard.direction == "SELL" and rsi > 70:
|
||
loss_mult *= 1.3 # Overbought: SELL should recover
|
||
|
||
# Stochastic extreme crossover
|
||
if guard.direction == "SELL" and stoch_k < 20:
|
||
profit_mult *= 0.8 # Oversold: SELL may reverse
|
||
elif guard.direction == "BUY" and stoch_k > 80:
|
||
profit_mult *= 0.8 # Overbought: BUY may reverse
|
||
|
||
# Clamp multipliers to reasonable ranges
|
||
# v5c: loss_mult minimum raised 0.3→0.5 (give trades more breathing room)
|
||
profit_mult = max(0.3, min(2.5, profit_mult))
|
||
loss_mult = max(0.5, min(2.5, loss_mult))
|
||
|
||
return profit_mult, loss_mult
|
||
|
||
def evaluate_position(
|
||
self,
|
||
ticket: int,
|
||
current_price: float,
|
||
current_profit: float,
|
||
ml_signal: str,
|
||
ml_confidence: float,
|
||
regime: str = "normal",
|
||
current_atr: float = 0,
|
||
baseline_atr: float = 0,
|
||
market_context: Optional[Dict] = None,
|
||
) -> Tuple[bool, Optional[ExitReason], str]:
|
||
"""
|
||
SMART DYNAMIC TP v5 - Evaluate if position should be closed.
|
||
|
||
Uses ATR-based dynamic scaling + regime/ML/velocity multipliers:
|
||
- current_atr: ATR(14) in price points from latest M15 data
|
||
- baseline_atr: 24h average ATR for normalization
|
||
- All dollar thresholds scale with atr_ratio AND dynamic multipliers
|
||
- market_context: dict with rsi, stoch_k, adx, macd_hist for smart exits
|
||
- Low ATR (quiet market) = tighter exits, smaller losses
|
||
- High ATR (volatile market) = wider thresholds
|
||
|
||
Returns: (should_close, reason, message)
|
||
"""
|
||
guard = self._position_guards.get(ticket)
|
||
if not guard:
|
||
return False, None, "Position not registered"
|
||
|
||
# === ATR-BASED DYNAMIC SCALING ===
|
||
# ATR ratio: data-driven volatility multiplier (replaces fixed session multiplier)
|
||
base = baseline_atr if baseline_atr > 0 else self._BASELINE_ATR
|
||
if current_atr > 0:
|
||
sm = max(0.3, min(current_atr / base, 1.5)) # Clamp 0.3-1.5
|
||
else:
|
||
sm = 1.0 # Fallback: no scaling if ATR unavailable
|
||
|
||
# ATR in dollars for this position (XAUUSD: 1 point = $1 per 0.01 lot)
|
||
atr_dollars = current_atr * guard.lot_size * 100 if current_atr > 0 else 0
|
||
|
||
effective_max_loss = self.max_loss_per_trade * sm
|
||
|
||
# === ATR-BASED THRESHOLDS — "Detak Jantung Market" ===
|
||
# All thresholds use ATR as the base unit, making them SYMMETRIC and adaptive:
|
||
# - London (high vol) → wider stops, bigger targets
|
||
# - Sydney (low vol) → tighter stops, smaller targets
|
||
# - Big lot → wider in dollars, same in ATR terms
|
||
# atr_unit = how many $ of P/L per 1 ATR move for THIS position
|
||
atr_unit = atr_dollars if atr_dollars > 0 else 10 * sm # Fallback if ATR unavailable
|
||
|
||
# === EXIT STRATEGY v5 — "Dynamic Intelligence" ===
|
||
# Philosophy: EVERY threshold adapts to regime, ML, velocity, RSI/ADX.
|
||
# No more fixed numbers — the market tells us when to hold and when to cut.
|
||
|
||
# Calculate dynamic multipliers based on ALL available signals
|
||
profit_mult, loss_mult = self._calculate_dynamic_multipliers(
|
||
guard, regime, ml_signal, ml_confidence, market_context
|
||
)
|
||
trade_state = self._classify_trade_state(guard)
|
||
|
||
# BASE thresholds (ATR multiples) — these get MULTIPLIED by dynamic factors
|
||
# Profit thresholds: base * profit_mult * atr_unit
|
||
tp_min = 0.35 * profit_mult * atr_unit # Dynamic min TP
|
||
tp_secure = 0.60 * profit_mult * atr_unit # Dynamic secure TP
|
||
tp_hard = 1.20 * profit_mult * atr_unit # Dynamic hard TP
|
||
tp_peak_trigger = 0.60 * profit_mult * atr_unit # Dynamic peak trigger
|
||
tp_prob = 0.50 * profit_mult * atr_unit # Dynamic TP probability
|
||
tp_decel = 0.50 * profit_mult * atr_unit # Dynamic decel check
|
||
tp_small_min = 0.20 * profit_mult * atr_unit # Dynamic small min
|
||
tp_small_max = 0.30 * profit_mult * atr_unit # Dynamic small max
|
||
tp_early_min = 0.15 * profit_mult * atr_unit # Dynamic early exit min
|
||
|
||
# Loss thresholds: base * loss_mult * atr_unit
|
||
max_atr_loss = 0.60 * loss_mult * atr_unit # Dynamic hard stop
|
||
stall_loss = -0.35 * loss_mult * atr_unit # Dynamic stall
|
||
reversal_loss = -0.20 * loss_mult * atr_unit # Dynamic reversal
|
||
warn_loss = -0.30 * loss_mult * atr_unit # Dynamic warning
|
||
timeout_loss = -0.35 * loss_mult * atr_unit # Dynamic timeout
|
||
stagnant_loss = 0.25 * loss_mult * atr_unit # Dynamic stagnation
|
||
|
||
# === v6: KALMAN VELOCITY ALIASES (moved here for dynamic grace) ===
|
||
# Use Kalman-filtered velocity/acceleration for exit decisions (smoother).
|
||
# Raw velocity still used for counter logic (sign flips, was_positive).
|
||
_vel = guard.kalman_velocity if guard.kalman else (guard.velocity if hasattr(guard, 'velocity') else 0.0)
|
||
_accel = guard.kalman_acceleration if guard.kalman else (guard.acceleration if hasattr(guard, 'acceleration') else 0.0)
|
||
|
||
# === DYNAMIC GRACE PERIOD (3-12 minutes based on loss velocity) ===
|
||
# v6.1: Grace adapts to how fast the trade is losing money
|
||
# Fast crash → short grace (3-4 min)
|
||
# Slow loss/recovery → long grace (10-12 min)
|
||
|
||
if current_profit >= 0:
|
||
# In profit: full grace (regime-based)
|
||
if regime in ("ranging", "mean_reverting"):
|
||
grace_minutes = 12
|
||
elif regime in ("high_volatility", "volatile", "crisis"):
|
||
grace_minutes = 10
|
||
elif regime == "trending":
|
||
grace_minutes = 6
|
||
else:
|
||
grace_minutes = 8
|
||
else:
|
||
# In loss: dynamic grace based on velocity
|
||
loss_velocity = abs(_vel) if _vel < 0 else 0 # Only count negative velocity
|
||
|
||
if loss_velocity >= 0.30:
|
||
# VERY FAST crash (>$0.30/sec = $18/min)
|
||
grace_minutes = 3 # Cut fast!
|
||
elif loss_velocity >= 0.15:
|
||
# Fast loss ($0.15/sec = $9/min)
|
||
grace_minutes = 4
|
||
elif loss_velocity >= 0.08:
|
||
# Moderate loss ($0.08/sec = $4.80/min)
|
||
grace_minutes = 5
|
||
elif loss_velocity >= 0.03:
|
||
# Slow loss ($0.03/sec = $1.80/min)
|
||
grace_minutes = 7
|
||
else:
|
||
# Very slow loss or recovering (velocity positive/near zero)
|
||
# Use regime-based grace but reduced 50%
|
||
if regime in ("ranging", "mean_reverting"):
|
||
grace_minutes = 8 # 12 → 8
|
||
elif regime in ("high_volatility", "volatile", "crisis"):
|
||
grace_minutes = 6 # 10 → 6
|
||
else:
|
||
grace_minutes = 5 # 8 → 5
|
||
|
||
# Log dynamic multipliers periodically (every 60s)
|
||
if len(guard.profit_timestamps) > 0:
|
||
now_ts = time.time()
|
||
if not hasattr(guard, '_last_dynamic_log') or now_ts - guard._last_dynamic_log >= 60:
|
||
guard._last_dynamic_log = now_ts
|
||
logger.info(
|
||
f"[DYNAMIC] #{ticket} regime={regime} state={trade_state} "
|
||
f"P×{profit_mult:.2f} L×{loss_mult:.2f} | "
|
||
f"tp_min=${tp_min:.1f} max_loss=${max_atr_loss:.1f} "
|
||
f"grace={grace_minutes}m"
|
||
)
|
||
|
||
# === 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()
|
||
|
||
# Pre-calculate trade age (used by multiple checks)
|
||
now = datetime.now(WIB)
|
||
current_hour = now.hour
|
||
trade_age_seconds = (now - guard.entry_time).total_seconds()
|
||
trade_age_minutes = trade_age_seconds / 60
|
||
|
||
# === v6: ADVANCED EXIT SYSTEMS (Fuzzy + Kelly) ===
|
||
if _ADVANCED_EXITS_ENABLED:
|
||
# === FUZZY LOGIC EXIT CONFIDENCE ===
|
||
if self.fuzzy_controller is not None:
|
||
# Calculate profit retention
|
||
profit_retention = current_profit / guard.peak_profit if guard.peak_profit > 0 else 1.0
|
||
|
||
# Calculate profit level (vs target)
|
||
profit_level = current_profit / tp_hard if tp_hard > 0 else 0.5
|
||
|
||
# Get RSI from market context
|
||
rsi = market_context.get('rsi', 50) if market_context else 50
|
||
|
||
# Evaluate fuzzy exit confidence
|
||
exit_confidence = self.fuzzy_controller.evaluate(
|
||
velocity=_vel,
|
||
acceleration=_accel,
|
||
profit_retention=profit_retention,
|
||
rsi=rsi,
|
||
time_in_trade=trade_age_minutes,
|
||
profit_level=profit_level,
|
||
)
|
||
|
||
# === v6.3 PREDICTIVE INTELLIGENCE ===
|
||
# Override or adjust exits based on future predictions
|
||
|
||
if _PREDICTIVE_ENABLED:
|
||
# 1. TRAJECTORY PREDICTION: Check if future profit exceeds targets
|
||
if self.trajectory_predictor is not None and len(guard.velocity_history) >= 3:
|
||
should_hold, pred_reason, predictions = self.trajectory_predictor.should_hold_position(
|
||
current_profit=current_profit,
|
||
velocity=_vel,
|
||
acceleration=_accel,
|
||
min_target=tp_min,
|
||
velocity_history=guard.velocity_history,
|
||
acceleration_history=guard.acceleration_history
|
||
)
|
||
|
||
if should_hold:
|
||
# Predicted high profit - DON'T EXIT yet
|
||
logger.info(
|
||
f"[TRAJECTORY HOLD] {pred_reason} | "
|
||
f"Predictions: 1m=${predictions['pred_1m']:.2f}, "
|
||
f"3m=${predictions['pred_3m']:.2f} (conf={predictions['confidence']:.0%})"
|
||
)
|
||
# Skip fuzzy exit check - continue holding
|
||
# (will still be subject to other safety checks below)
|
||
pass # Don't return yet, continue to other checks
|
||
|
||
# 2. MOMENTUM PERSISTENCE: Adjust fuzzy threshold based on momentum strength
|
||
if self.momentum_persistence is not None and len(guard.velocity_history) >= 3:
|
||
should_raise, new_threshold, momentum_reason = self.momentum_persistence.should_raise_exit_threshold(
|
||
velocity_history=guard.velocity_history,
|
||
acceleration_history=guard.acceleration_history,
|
||
current_profit=current_profit,
|
||
base_threshold=0.85 # Base threshold (will be adjusted by profit tier below)
|
||
)
|
||
|
||
if should_raise:
|
||
logger.info(f"[MOMENTUM PERSIST] {momentum_reason}")
|
||
# We'll apply this threshold adjustment below in profit-tier logic
|
||
|
||
# 3. RECOVERY STRENGTH: Special handling for recovering positions
|
||
if self.recovery_detector is not None and guard.peak_loss < -3.0:
|
||
# Position had significant loss (< -$3), check recovery strength
|
||
is_strong_recovery, recovery_metrics = self.recovery_detector.analyze_recovery_strength(
|
||
profit_history=guard.profit_history,
|
||
peak_loss=guard.peak_loss,
|
||
velocity_history=guard.velocity_history
|
||
)
|
||
|
||
if is_strong_recovery:
|
||
recovery_action, recovery_threshold, recovery_reason = self.recovery_detector.get_recovery_recommendation(
|
||
profit_history=guard.profit_history,
|
||
peak_loss=guard.peak_loss,
|
||
velocity_history=guard.velocity_history,
|
||
current_exit_threshold=0.85
|
||
)
|
||
|
||
if recovery_action in ["HOLD_STRONG", "HOLD_WEAK"]:
|
||
logger.info(
|
||
f"[RECOVERY {recovery_action}] {recovery_reason} | "
|
||
f"Recovery: {recovery_metrics['recovery_pct']:.0%} from ${guard.peak_loss:.2f}, "
|
||
f"vel={recovery_metrics['avg_recovery_vel']:.4f}$/s"
|
||
)
|
||
# Apply recovery-adjusted threshold below
|
||
|
||
# === PROFIT-AWARE EXIT STRATEGY (v6.2 improvement) ===
|
||
# Different thresholds for profit vs loss to prevent early profit exits
|
||
|
||
if current_profit > 0:
|
||
# === PROFIT TRADES: Hold longer for better gains ===
|
||
|
||
# Base profit tiers determine exit threshold
|
||
if current_profit < 3.0:
|
||
# Small profit (<$3): Hold until very high confidence (90%)
|
||
fuzzy_threshold = 0.90
|
||
tier = "SMALL"
|
||
elif current_profit < 8.0:
|
||
# Medium profit ($3-8): Hold until high confidence (85%)
|
||
fuzzy_threshold = 0.85
|
||
tier = "MEDIUM"
|
||
else:
|
||
# Large profit (>$8): Can exit at 80% (protect gains)
|
||
fuzzy_threshold = 0.80
|
||
tier = "LARGE"
|
||
|
||
# === v6.3 PREDICTIVE ADJUSTMENTS ===
|
||
adjustments = []
|
||
|
||
# Apply momentum persistence adjustment
|
||
if (_PREDICTIVE_ENABLED and self.momentum_persistence is not None and
|
||
len(guard.velocity_history) >= 3):
|
||
should_raise, adjusted_threshold, momentum_reason = (
|
||
self.momentum_persistence.should_raise_exit_threshold(
|
||
velocity_history=guard.velocity_history,
|
||
acceleration_history=guard.acceleration_history,
|
||
current_profit=current_profit,
|
||
base_threshold=fuzzy_threshold
|
||
)
|
||
)
|
||
if should_raise and adjusted_threshold > fuzzy_threshold:
|
||
delta = adjusted_threshold - fuzzy_threshold
|
||
fuzzy_threshold = adjusted_threshold
|
||
adjustments.append(f"momentum+{delta:.0%}")
|
||
|
||
# Apply recovery strength adjustment (if recovering from loss)
|
||
if (_PREDICTIVE_ENABLED and self.recovery_detector is not None and
|
||
guard.peak_loss < -3.0 and len(guard.profit_history) >= 5):
|
||
is_strong, metrics = self.recovery_detector.analyze_recovery_strength(
|
||
guard.profit_history, guard.peak_loss, guard.velocity_history
|
||
)
|
||
if is_strong and metrics.get('recovery_pct', 0) > 0.8:
|
||
# Strong recovery - raise threshold by 10%
|
||
old_threshold = fuzzy_threshold
|
||
fuzzy_threshold = min(fuzzy_threshold + 0.10, 0.98)
|
||
if fuzzy_threshold > old_threshold:
|
||
adjustments.append(f"recovery+{fuzzy_threshold-old_threshold:.0%}")
|
||
|
||
# Check trajectory prediction to prevent premature exit
|
||
trajectory_override = False
|
||
if (_PREDICTIVE_ENABLED and self.trajectory_predictor is not None and
|
||
len(guard.velocity_history) >= 3):
|
||
should_hold, pred_reason, predictions = (
|
||
self.trajectory_predictor.should_hold_position(
|
||
current_profit, _vel, _accel, tp_min,
|
||
guard.velocity_history, guard.acceleration_history
|
||
)
|
||
)
|
||
if should_hold and predictions.get('pred_1m', 0) > current_profit * 2:
|
||
# Predicted profit 2x higher in 1 minute - strong hold signal
|
||
trajectory_override = True
|
||
logger.warning(
|
||
f"⏳ [TRAJECTORY OVERRIDE] Predicted ${predictions['pred_1m']:.2f} in 1min "
|
||
f"(current: ${current_profit:.2f}, conf={predictions['confidence']:.0%})"
|
||
)
|
||
|
||
# Build adjustment string for logging
|
||
adj_str = f" [{'+'.join(adjustments)}]" if adjustments else ""
|
||
|
||
# High confidence exit (unless trajectory override)
|
||
if exit_confidence > fuzzy_threshold and not trajectory_override:
|
||
return True, ExitReason.TAKE_PROFIT, (
|
||
f"[FUZZY HIGH] Exit confidence: {exit_confidence:.2%} "
|
||
f"(profit=${current_profit:.2f}, tier={tier}, threshold={fuzzy_threshold:.0%}{adj_str})"
|
||
)
|
||
elif trajectory_override:
|
||
# Log but don't exit - trajectory prediction says hold
|
||
logger.info(
|
||
f"[FUZZY SUPPRESSED] Exit confidence {exit_confidence:.2%} > {fuzzy_threshold:.0%} "
|
||
f"but trajectory override active (pred 1m=${predictions['pred_1m']:.2f})"
|
||
)
|
||
|
||
# Kelly only for large profits (>$8) with very high fuzzy (>80%)
|
||
if self.kelly_scaler is not None and current_profit >= 8.0 and exit_confidence > 0.80:
|
||
should_exit, close_fraction, kelly_msg = self.kelly_scaler.get_exit_action(
|
||
exit_confidence, current_profit, tp_hard
|
||
)
|
||
if should_exit and close_fraction > 0.5:
|
||
return True, ExitReason.TAKE_PROFIT, (
|
||
f"[KELLY PROFIT] {kelly_msg} (fuzzy={exit_confidence:.2%})"
|
||
)
|
||
|
||
else:
|
||
# === LOSS TRADES: Exit faster to minimize damage ===
|
||
|
||
# Lower threshold for losses (75%)
|
||
if exit_confidence > 0.75:
|
||
return True, ExitReason.POSITION_LIMIT, (
|
||
f"[FUZZY HIGH LOSS] Exit confidence: {exit_confidence:.2%} "
|
||
f"(loss=${current_profit:.2f}, cut early)"
|
||
)
|
||
|
||
# Kelly active for losses (help cut faster)
|
||
if self.kelly_scaler is not None and exit_confidence > 0.60:
|
||
should_exit, close_fraction, kelly_msg = self.kelly_scaler.get_exit_action(
|
||
exit_confidence, current_profit, tp_hard
|
||
)
|
||
if should_exit and close_fraction > 0.3:
|
||
return True, ExitReason.POSITION_LIMIT, (
|
||
f"[KELLY LOSS] {kelly_msg} (fuzzy={exit_confidence:.2%})"
|
||
)
|
||
|
||
# === PRIORITY 0: EMERGENCY SAFETY CHECKS ===
|
||
|
||
# CHECK -1: NO RECOVERY ZONE ($15 threshold)
|
||
# If loss >= $15, exit immediately - no point waiting for recovery
|
||
NO_RECOVERY_THRESHOLD = 1500 # $15.00 per 0.01 lot
|
||
if current_profit <= -NO_RECOVERY_THRESHOLD:
|
||
return True, ExitReason.POSITION_LIMIT, (
|
||
f"[NO RECOVERY] Loss ${abs(current_profit):.2f} too deep "
|
||
f"(threshold ${NO_RECOVERY_THRESHOLD/100:.2f}) - cut immediately"
|
||
)
|
||
|
||
# CHECK 0: EMERGENCY CAP ($20 per 0.01 lot)
|
||
# Absolute maximum loss cap - last resort protection
|
||
EMERGENCY_MAX_LOSS = 2000 # $20.00 per 0.01 lot
|
||
if current_profit <= -EMERGENCY_MAX_LOSS:
|
||
return True, ExitReason.POSITION_LIMIT, (
|
||
f"[EMERGENCY CAP] Max loss ${abs(current_profit):.2f} exceeded "
|
||
f"${EMERGENCY_MAX_LOSS/100:.2f} limit - emergency exit!"
|
||
)
|
||
|
||
# === CHECK 0A: BREAKEVEN SHIELD (percentage-based, dynamic) ===
|
||
# v5: Protect ANY meaningful profit from becoming a loss.
|
||
# Uses percentage drawdown from peak (not fixed ATR threshold).
|
||
# Peak $3+ → protect if drops below $1.50
|
||
# Peak $6+ → protect if drops 70%+ from peak
|
||
# Peak $10+ → protect if drops 60%+ from peak
|
||
# v5c: min peak raised $3→$5, min age raised 5→8 min (patient protection)
|
||
if atr_unit > 0 and trade_age_minutes >= 8 and guard.peak_profit >= 5.0:
|
||
if guard.peak_profit >= 10.0:
|
||
max_drawdown_pct = 0.60 # Peak $10+: protect at 60% drawdown
|
||
elif guard.peak_profit >= 6.0:
|
||
max_drawdown_pct = 0.70 # Peak $6+: protect at 70% drawdown
|
||
else:
|
||
max_drawdown_pct = 0.80 # Peak $5+: protect at 80% drawdown
|
||
|
||
profit_floor = guard.peak_profit * (1 - max_drawdown_pct)
|
||
# Floor must be at least $1.50 to avoid micro-profit exits
|
||
profit_floor = max(profit_floor, 1.50)
|
||
|
||
if current_profit <= profit_floor and guard.peak_profit > profit_floor:
|
||
return True, ExitReason.TAKE_PROFIT, (
|
||
f"[BE-SHIELD] Was +${guard.peak_profit:.2f}, now ${current_profit:+.2f} "
|
||
f"— {max_drawdown_pct:.0%} drawdown protection (floor=${profit_floor:.1f})"
|
||
)
|
||
|
||
# === CHECK 0A.5: DEAD ZONE PROTECTION (v6) ===
|
||
# Protect trades with peak $3-5 that have no other protection.
|
||
# BE-SHIELD kicks in at $5+, so this covers the gap below.
|
||
if trade_age_minutes >= 5 and guard.peak_profit >= 3.0 and guard.peak_profit < 5.0:
|
||
deadzone_floor = max(0.50, guard.peak_profit * 0.33)
|
||
if current_profit <= deadzone_floor:
|
||
return True, ExitReason.TAKE_PROFIT, (
|
||
f"[DEADZONE] Securing ${current_profit:.2f} — "
|
||
f"peak ${guard.peak_profit:.2f} floor ${deadzone_floor:.2f} "
|
||
f"(age {trade_age_minutes:.1f}m)"
|
||
)
|
||
|
||
# === CHECK 0B: ATR TRAILING (v6 multi-factor + stochastic floor) ===
|
||
# Trail distance = BASE × REGIME × PROFIT_LEVEL × VELOCITY_QUALITY
|
||
# Stochastic floor: profit_floor = max(atr_floor, alpha × peak_profit)
|
||
if atr_unit > 0 and trade_age_minutes >= 8:
|
||
trail_trigger = 0.60 * profit_mult * atr_unit # Dynamic trigger
|
||
if guard.peak_profit >= trail_trigger:
|
||
# BASE factor (trade state)
|
||
if trade_state == "accelerating_profit":
|
||
trail_base = 0.40
|
||
elif trade_state in ("steady_profit", "neutral"):
|
||
trail_base = 0.28
|
||
else:
|
||
trail_base = 0.18
|
||
|
||
# REGIME factor
|
||
if regime == "trending":
|
||
regime_factor = 1.2
|
||
elif regime in ("ranging", "mean_reverting"):
|
||
regime_factor = 0.85
|
||
elif regime in ("high_volatility", "volatile", "crisis"):
|
||
regime_factor = 1.3
|
||
else:
|
||
regime_factor = 1.0
|
||
|
||
# PROFIT LEVEL factor (how close to target)
|
||
if tp_hard > 0 and guard.peak_profit >= tp_hard * 0.75:
|
||
profit_level_factor = 0.75 # Near target: tighten
|
||
elif tp_hard > 0 and guard.peak_profit >= tp_hard * 0.50:
|
||
profit_level_factor = 0.90 # Mid range
|
||
else:
|
||
profit_level_factor = 1.15 # Early: wider trail
|
||
|
||
# VELOCITY QUALITY factor (using Kalman-filtered velocity)
|
||
if _vel > 0.05:
|
||
vel_factor = 1.1 # Positive velocity: wider trail
|
||
elif _vel < -0.03:
|
||
vel_factor = 0.85 # Negative velocity: tighter trail
|
||
else:
|
||
vel_factor = 1.0
|
||
|
||
trail_atr = trail_base * regime_factor * profit_level_factor * vel_factor
|
||
trail_atr = max(0.12, min(0.50, trail_atr)) # Clamp [0.12, 0.50]
|
||
|
||
atr_floor = guard.peak_profit - trail_atr * atr_unit
|
||
|
||
# Stochastic floor: alpha × peak_profit (Gemini research)
|
||
if guard.peak_profit >= tp_secure:
|
||
alpha = 0.60
|
||
elif guard.peak_profit >= tp_min:
|
||
alpha = 0.50
|
||
else:
|
||
alpha = 0.40
|
||
stoch_floor = alpha * guard.peak_profit
|
||
|
||
profit_floor = max(atr_floor, stoch_floor)
|
||
floor_type = "STOCH" if stoch_floor > atr_floor else "ATR"
|
||
|
||
if current_profit < profit_floor and profit_floor > 0:
|
||
return True, ExitReason.TAKE_PROFIT, (
|
||
f"[ATR-TRAIL] Profit ${current_profit:.2f} < floor ${profit_floor:.2f} "
|
||
f"(peak ${guard.peak_profit:.2f}, trail {trail_atr:.2f}*ATR=${trail_atr*atr_unit:.1f}, "
|
||
f"floor={floor_type}, state={trade_state})"
|
||
)
|
||
|
||
# === CHECK 0C: PROFIT MOMENTUM FADE ===
|
||
# Detect when profit velocity transitions from positive to negative.
|
||
# This catches the exact moment momentum fades — before big drawdown.
|
||
# Example: Trade peaked $7.58, velocity was +0.05, now -0.03 → fading
|
||
if current_profit >= tp_min and trade_age_minutes >= 3:
|
||
# Velocity was positive and now turned negative (momentum fading)
|
||
# v6: uses Kalman-filtered velocity for trigger, raw for counter tracking
|
||
if guard.velocity_was_positive and _vel < -0.01:
|
||
# Require multiple deceleration readings to avoid false triggers
|
||
if guard.decel_at_profit_count >= 3 or guard.velocity_sign_flips >= 2:
|
||
fade_strength = "strong" if _vel < -0.05 else "moderate"
|
||
return True, ExitReason.TAKE_PROFIT, (
|
||
f"[MOM-FADE] Securing ${current_profit:.2f} — momentum fading ({fade_strength}) "
|
||
f"vel={_vel:.3f} decel={guard.decel_at_profit_count}x "
|
||
f"flips={guard.velocity_sign_flips} peak=${guard.peak_profit:.2f}"
|
||
)
|
||
|
||
# === CHECK 0D: CAN'T MAKE NEW HIGHS ===
|
||
# Detect when trade has profit but can't push to new peaks.
|
||
# Pattern: price approaches peak multiple times but fails → resistance.
|
||
# Example: Peak $6.35, tried 4x to break, profit now $5.20 → take it
|
||
if current_profit >= tp_min and trade_age_minutes >= 5 and guard.peak_update_time > 0:
|
||
peak_age = time.time() - guard.peak_update_time
|
||
if peak_age >= 60 and guard.failed_peak_attempts >= 3:
|
||
# Peak is stale (60s+) and multiple failed attempts
|
||
peak_retention = current_profit / guard.peak_profit if guard.peak_profit > 0 else 1
|
||
if peak_retention < 0.90: # Lost 10%+ from peak
|
||
return True, ExitReason.TAKE_PROFIT, (
|
||
f"[NO-NEW-HIGH] Securing ${current_profit:.2f} — "
|
||
f"peak ${guard.peak_profit:.2f} stale {peak_age:.0f}s, "
|
||
f"{guard.failed_peak_attempts} failed attempts, "
|
||
f"retention {peak_retention:.0%}"
|
||
)
|
||
|
||
# === CHECK 0E: RSI/STOCH REVERSAL AT PROFIT ===
|
||
# Use market indicators to detect imminent reversal while in profit.
|
||
# When RSI/Stoch reaches extreme, mean reversion is likely.
|
||
# SELL + oversold → price will bounce up (against us)
|
||
# BUY + overbought → price will drop (against us)
|
||
if current_profit >= tp_min and market_context and trade_age_minutes >= 3:
|
||
rsi = market_context.get("rsi")
|
||
stoch_k = market_context.get("stoch_k")
|
||
|
||
reversal_signal = False
|
||
reversal_detail = ""
|
||
|
||
if rsi is not None and stoch_k is not None:
|
||
if guard.direction == "SELL":
|
||
# Oversold = price about to bounce UP (bad for SELL)
|
||
if rsi < 25 and stoch_k < 20:
|
||
reversal_signal = True
|
||
reversal_detail = f"RSI={rsi:.0f} Stoch={stoch_k:.0f} (double oversold)"
|
||
elif rsi < 20 or stoch_k < 10:
|
||
reversal_signal = True
|
||
reversal_detail = f"RSI={rsi:.0f} Stoch={stoch_k:.0f} (extreme oversold)"
|
||
elif guard.direction == "BUY":
|
||
# Overbought = price about to drop (bad for BUY)
|
||
if rsi > 75 and stoch_k > 80:
|
||
reversal_signal = True
|
||
reversal_detail = f"RSI={rsi:.0f} Stoch={stoch_k:.0f} (double overbought)"
|
||
elif rsi > 80 or stoch_k > 90:
|
||
reversal_signal = True
|
||
reversal_detail = f"RSI={rsi:.0f} Stoch={stoch_k:.0f} (extreme overbought)"
|
||
|
||
if reversal_signal:
|
||
guard.rsi_extreme_count += 1
|
||
# Require 2+ consecutive extreme readings to avoid whipsaw
|
||
if guard.rsi_extreme_count >= 2:
|
||
return True, ExitReason.TAKE_PROFIT, (
|
||
f"[RSI-EXIT] Securing ${current_profit:.2f} — "
|
||
f"{reversal_detail} for {guard.rsi_extreme_count} readings "
|
||
f"(peak=${guard.peak_profit:.2f})"
|
||
)
|
||
else:
|
||
guard.rsi_extreme_count = 0 # Reset: not at extreme
|
||
|
||
# === CHECK 0F: TIME-WEIGHTED PROFIT STALL ===
|
||
# Detect profit stuck at the same level for too long.
|
||
# If profitable but not growing, market lost momentum — take it.
|
||
# Higher profit = more patience, lower profit = exit sooner.
|
||
if current_profit >= tp_min and trade_age_minutes >= 5 and guard.profit_stall_start_time > 0:
|
||
stall_duration = time.time() - guard.profit_stall_start_time
|
||
# Dynamic stall patience based on profit level
|
||
if current_profit >= tp_secure:
|
||
stall_patience = 120 # $6+ profit: wait 120s before declaring stall
|
||
elif current_profit >= tp_min:
|
||
stall_patience = 90 # $3+ profit: wait 90s
|
||
else:
|
||
stall_patience = 60 # Small profit: exit at 60s stall
|
||
|
||
if stall_duration >= stall_patience:
|
||
drift = current_profit - guard.profit_stall_anchor
|
||
return True, ExitReason.TAKE_PROFIT, (
|
||
f"[PROFIT-STALL] Securing ${current_profit:.2f} — "
|
||
f"stalled {stall_duration:.0f}s (patience={stall_patience}s) "
|
||
f"drift=${drift:+.2f} peak=${guard.peak_profit:.2f}"
|
||
)
|
||
|
||
# === CHECK 1: SMART TAKE PROFIT ===
|
||
if current_profit >= tp_min: # Profit >= scaled threshold
|
||
# A. Hard TP - profit sangat bagus
|
||
if current_profit >= tp_hard:
|
||
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 >= tp_secure 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
|
||
# v5d: only LOCK at substantial peaks (tp_secure, ~$6+) not small ones (~$4)
|
||
# Small peaks ($3-5) are noise — let trade develop to full potential
|
||
if guard.peak_profit > tp_secure 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 >= tp_prob:
|
||
return True, ExitReason.TAKE_PROFIT, f"[PROB] Taking profit ${current_profit:.2f} (TP prob: {tp_probability:.0f}%)"
|
||
|
||
# F. Velocity reversal — profit >= tp_min but velocity turning strongly negative
|
||
# v4: only at substantial profit AND strong reversal
|
||
# v6: uses Kalman-filtered velocity
|
||
if _vel < -0.25 and trade_age_minutes >= 5 and current_profit >= tp_secure:
|
||
return True, ExitReason.TAKE_PROFIT, f"[VEL-EXIT] Securing ${current_profit:.2f} (velocity: {_vel:.3f} $/s, momentum: {momentum:+.0f})"
|
||
|
||
# G. Deceleration — profit >= tp_decel, growth slowing significantly
|
||
# v6: uses Kalman-filtered velocity/acceleration
|
||
if current_profit >= tp_decel and _accel < -0.05 and _vel < 0.1:
|
||
return True, ExitReason.TAKE_PROFIT, f"[DECEL] Securing ${current_profit:.2f} (accel: {_accel:.4f}, vel: {_vel:.3f})"
|
||
|
||
# H. PROFIT CAPTURE — velocity stall/reversal at good profit level
|
||
# Fills the gap: profit is good (>= tp_min) but below tp_secure/tp_hard,
|
||
# and the move is stalling. Captures profit BEFORE big drawdown happens.
|
||
# v6: uses Kalman-filtered velocity
|
||
if _vel <= 0:
|
||
guard.profit_capture_count += 1
|
||
# Immediate capture: velocity clearly negative at GOOD profit (>= tp_secure)
|
||
if _vel < -0.25 and guard.profit_capture_count >= 3 and current_profit >= tp_secure:
|
||
return True, ExitReason.TAKE_PROFIT, (
|
||
f"[CAPTURE] Securing ${current_profit:.2f} — velocity reversing "
|
||
f"(vel={_vel:.3f}, peak=${guard.peak_profit:.2f}, "
|
||
f"stall={guard.profit_capture_count}x)"
|
||
)
|
||
# Stall capture: velocity near zero for many intervals at good profit
|
||
if guard.profit_capture_count >= 6 and current_profit >= tp_secure:
|
||
return True, ExitReason.TAKE_PROFIT, (
|
||
f"[CAPTURE] Securing ${current_profit:.2f} — profit stalling "
|
||
f"(vel={_vel:.3f}, peak=${guard.peak_profit:.2f}, "
|
||
f"stall={guard.profit_capture_count}x)"
|
||
)
|
||
else:
|
||
guard.profit_capture_count = 0 # Reset: velocity positive, profit growing
|
||
|
||
# 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 1.5: FAST REVERSAL (small profit, ATR-scaled) ===
|
||
# v4: DISABLED — small profit exits killed winning trades in v3/v3b
|
||
# Let trades run through small-profit zone without panic exits
|
||
# The BE-SHIELD and ATR-TRAIL handle protection at higher profit levels
|
||
|
||
# === CHECK 2: SMART EARLY EXIT (small profit, scaled) ===
|
||
# v4: DISABLED — taking small profits prevents reaching $10+ targets
|
||
# Only the ML reversal + high confidence check remains, with higher bar
|
||
if tp_early_min <= current_profit < tp_small_max:
|
||
# Only exit small profit if ML is VERY confident about reversal AND momentum very negative
|
||
if momentum < -70 and ml_confidence >= 0.75 and trade_age_minutes >= 10:
|
||
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
|
||
|
||
# === ATR HARD STOP — dynamic min age based on regime ===
|
||
# v5c: Max loss is DYNAMIC (0.60 ATR * loss_mult).
|
||
# Min age for hard stop = grace_minutes * 0.75 (at least 5 min).
|
||
# Raised from max(3, grace/2) → max(5, grace*0.75) for more breathing room.
|
||
hard_stop_min_age = max(5.0, grace_minutes * 0.75)
|
||
if current_profit < 0 and abs(current_profit) >= max_atr_loss and trade_age_minutes >= hard_stop_min_age:
|
||
return True, ExitReason.POSITION_LIMIT, (
|
||
f"[ATR-STOP] Loss ${abs(current_profit):.2f} >= ${max_atr_loss:.2f} "
|
||
f"(0.60×{loss_mult:.1f}×ATR) after {trade_age_minutes:.1f}m "
|
||
f"[{regime}|{trade_state}]"
|
||
)
|
||
|
||
if current_profit < 0:
|
||
loss_in_atr = abs(current_profit) / atr_unit if atr_unit > 0 else 0
|
||
|
||
# v5: Early cut thresholds ADAPT to trade state
|
||
# In crashing state: cut sooner. In recovering state: give more room.
|
||
mom_threshold = -60 if trade_state != "crashing" else -40
|
||
loss_threshold = 0.30 * loss_mult if trade_state != "crashing" else 0.20 * loss_mult
|
||
|
||
momentum_trigger = momentum < mom_threshold and loss_in_atr >= loss_threshold
|
||
velocity_trigger = _vel < -0.30 and loss_in_atr >= 0.20 * loss_mult # v6: Kalman
|
||
|
||
# VELOCITY EMERGENCY EXIT — only bypass grace for EXTREME drops
|
||
# v6: uses Kalman-filtered velocity/acceleration
|
||
velocity_emergency = (
|
||
_vel < -0.40
|
||
and loss_in_atr >= 0.40 * loss_mult
|
||
and _accel < -0.005
|
||
and len(guard.profit_history) >= 6
|
||
)
|
||
|
||
if velocity_emergency:
|
||
logger.info(
|
||
f"[VELOCITY EXIT] Loss ${abs(current_profit):.2f} ({loss_in_atr:.2f} ATR) "
|
||
f"vel={_vel:.3f} accel={_accel:.4f} — EMERGENCY CUT"
|
||
)
|
||
return True, ExitReason.TREND_REVERSAL, (
|
||
f"[VELOCITY EXIT] Loss ${abs(current_profit):.2f} ({loss_in_atr:.2f} ATR) "
|
||
f"vel={_vel:.3f} accel={_accel:.4f} — fast drop detected"
|
||
)
|
||
|
||
if momentum_trigger or velocity_trigger:
|
||
if trade_age_minutes < grace_minutes:
|
||
logger.info(f"[GRACE] Loss ${abs(current_profit):.2f} ({loss_in_atr:.2f} ATR) + momentum ({momentum:.0f}) vel({_vel:.3f}) — holding {trade_age_minutes:.1f}m/{grace_minutes}m grace")
|
||
else:
|
||
trigger_type = "momentum" if momentum_trigger else "velocity"
|
||
logger.info(f"[EARLY CUT] Loss ${abs(current_profit):.2f} ({loss_in_atr:.2f} ATR) + weak {trigger_type} — CUTTING")
|
||
return True, ExitReason.TREND_REVERSAL, f"[EARLY CUT] Loss ${abs(current_profit):.2f} ({loss_in_atr:.2f} ATR) + {trigger_type} — cutting"
|
||
|
||
# Time-aware stagnation: stagnant for 120s+ with loss > 0.25 ATR
|
||
# v4: much more patient — 120s (from 45s), min age 5 min (from 2)
|
||
if guard.stagnation_seconds >= 120 and abs(current_profit) > stagnant_loss and trade_age_minutes >= 5:
|
||
return True, ExitReason.TREND_REVERSAL, f"[STAGNANT] Loss ${abs(current_profit):.2f} stagnant {guard.stagnation_seconds:.0f}s — cutting"
|
||
|
||
# === CHECK 4: TREND REVERSAL (ATR-based) ===
|
||
# Close lebih cepat jika ML reversal + loss > 0.2 ATR
|
||
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
|
||
|
||
# ML reversal + loss > 0.2 ATR → cut (shorter grace: 10 min)
|
||
if is_reversal and current_profit < reversal_loss:
|
||
if trade_age_minutes < grace_minutes:
|
||
logger.info(f"[GRACE] Reversal ({ml_signal} {ml_confidence:.0%}) loss ${current_profit:.2f} — holding {trade_age_minutes:.1f}m/{grace_minutes}m grace")
|
||
else:
|
||
return True, ExitReason.TREND_REVERSAL, f"[REVERSAL] {ml_signal} ({ml_confidence:.0%}) - Loss: ${current_profit:.2f}"
|
||
|
||
# 3x reversal warnings + loss > 0.3 ATR → cut
|
||
if guard.reversal_warnings >= 3 and current_profit < warn_loss:
|
||
if trade_age_minutes < grace_minutes:
|
||
logger.info(f"[GRACE] {guard.reversal_warnings}x reversal warnings, loss ${current_profit:.2f} — holding {trade_age_minutes:.1f}m/{grace_minutes}m grace")
|
||
else:
|
||
return True, ExitReason.TREND_REVERSAL, f"[WARN] {guard.reversal_warnings}x reversal warnings - Loss: ${current_profit:.2f}"
|
||
|
||
# === CHECK 5: ABSOLUTE BACKUP STOP (dynamic safety net) ===
|
||
# v5d: BACKUP-SL now respects grace period (was firing at 1-2 min!)
|
||
# Also uses loss_mult floor of 0.8 so ML disagreement can't crush threshold
|
||
# to $4-5 (which fires on normal gold noise within seconds).
|
||
backup_loss_mult = max(0.7, loss_mult) # v6: relaxed 0.8→0.7 (ML fix makes band-aid unnecessary)
|
||
backup_pct = min(0.30, 0.20 * backup_loss_mult) # Cap at 30% of max_loss
|
||
if trade_age_minutes >= grace_minutes and current_profit <= -(effective_max_loss * backup_pct):
|
||
return True, ExitReason.POSITION_LIMIT, (
|
||
f"[BACKUP-SL] Loss ${abs(current_profit):.2f} ({backup_pct:.0%} of "
|
||
f"${effective_max_loss:.2f}) — safety net [{regime}|L×{backup_loss_mult:.1f}]"
|
||
)
|
||
|
||
# === CHECK 5b: STALL DETECTION (ATR-scaled) ===
|
||
# v4: more patient stall detection — 10 samples, 8 count threshold
|
||
stall_range_threshold = 0.10 * atr_unit # 10% of ATR unit
|
||
if len(guard.profit_history) >= 10 and trade_age_minutes >= 8:
|
||
recent_range = max(guard.profit_history[-10:]) - min(guard.profit_history[-10:])
|
||
if recent_range < stall_range_threshold and current_profit < stall_loss:
|
||
guard.stall_count += 1
|
||
if guard.stall_count >= 8: # v4: from 4 to 8
|
||
return True, ExitReason.TREND_REVERSAL, f"[STALL] Loss ${current_profit:.2f} stalled (range ${recent_range:.1f} < ${stall_range_threshold:.1f}) — 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
|
||
# Friday 22:30+ WIB = approaching weekend (reduce exposure)
|
||
# Saturday 04:30-05:00 WIB = last 30 min before close
|
||
is_friday_late = now.weekday() == 4 and now.hour >= 22 and now.minute >= 30
|
||
is_saturday_close = now.weekday() == 5 and now.hour >= 4 and now.minute >= 30 and now.hour < 5
|
||
near_weekend_close = is_friday_late or is_saturday_close
|
||
if near_weekend_close:
|
||
if current_profit > 0:
|
||
return True, ExitReason.WEEKEND_CLOSE, f"[WEEKEND] Weekend close - profit ${current_profit:.2f}"
|
||
elif current_profit > warn_loss:
|
||
return True, ExitReason.WEEKEND_CLOSE, f"[WEEKEND] Weekend close - small loss ${current_profit:.2f}"
|
||
|
||
# === CHECK 8: SMART TIME-BASED EXIT (session-scaled) ===
|
||
# 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 AND positive velocity)
|
||
# v6: uses Kalman-filtered velocity
|
||
profit_growing = momentum > 0 and _vel > 0
|
||
ml_agrees = (
|
||
(guard.direction == "BUY" and ml_signal == "BUY") or
|
||
(guard.direction == "SELL" and ml_signal == "SELL")
|
||
)
|
||
|
||
# v4: PATIENT time exits — gold trends can take hours to develop
|
||
# 4+ hours: Only exit if stuck (no profit growth)
|
||
if trade_duration_hours >= 4:
|
||
if current_profit < tp_early_min 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 > timeout_loss:
|
||
return True, ExitReason.TREND_REVERSAL, f"[TIMEOUT] Small loss ${current_profit:.2f} + no growth after {trade_duration_hours:.1f}h"
|
||
elif current_profit >= tp_early_min 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 < tp_min or not profit_growing:
|
||
return True, ExitReason.TREND_REVERSAL, f"[MAX TIME] {trade_duration_hours:.1f}h - profit ${current_profit:.2f}"
|
||
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}")
|