From 44e794271846bd76781925d432245e58f5494f82 Mon Sep 17 00:00:00 2001 From: GifariKemal Date: Mon, 9 Feb 2026 10:45:36 +0700 Subject: [PATCH] feat: add velocity & acceleration tracking to PositionGuard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhance PositionGuard in SmartRiskManager with real-time profit velocity ($/s) and acceleration ($/s²) tracking for smarter exit decisions. Changes: - Add 7 velocity/acceleration fields to PositionGuard dataclass - Add _calculate_velocity_acceleration(), _update_stagnation(), get_velocity_summary() - Add 4 new exit checks: [VEL-EXIT], [DECEL], [VEL-WARN], [STAGNANT] - Enhance early cut with velocity trigger alternative (vel < -0.4) - Stricter profit_growing: requires momentum > 0 AND velocity > 0 - Reduce position check interval 10s → 5s for more data points - Add per-ticket [MOMENTUM] log every 30s in main loop - Revert unused momentum_tracker integration from position_manager - Add deprecation note to profit_momentum_tracker.py All velocity checks respect the 15-minute grace period. Co-Authored-By: Claude Opus 4.6 --- main_live.py | 17 +- src/position_manager.py | 1 - src/profit_momentum_tracker.py | 392 +++++++++++++++++++++++++++++++++ src/smart_risk_manager.py | 118 +++++++++- 4 files changed, 514 insertions(+), 14 deletions(-) create mode 100644 src/profit_momentum_tracker.py diff --git a/main_live.py b/main_live.py index 983b53e..5e4375b 100644 --- a/main_live.py +++ b/main_live.py @@ -203,7 +203,7 @@ class TradingBot: self._current_session_multiplier: float = 1.0 # Session lot multiplier self._is_sydney_session: bool = False # Sydney session flag (needs higher confidence) self._last_candle_time: Optional[datetime] = None # Track last processed candle - self._position_check_interval: int = 10 # Check positions every N seconds between candles + self._position_check_interval: int = 5 # Check positions every N seconds between candles (more data points for velocity) # Entry filter tracking for dashboard self._last_filter_results: list = [] @@ -1944,6 +1944,21 @@ class TradingBot: regime=regime_state.regime.value if regime_state else "normal", ) + # Per-ticket momentum log (~every 30 seconds) + guard = self.smart_risk._position_guards.get(ticket) + if guard and len(guard.profit_timestamps) >= 2: + now_ts = time.time() + if now_ts - guard.last_momentum_log_time >= 30: + guard.last_momentum_log_time = now_ts + vel_summary = guard.get_velocity_summary() + logger.info( + f"[MOMENTUM] #{ticket} profit=${profit:+.2f} | " + f"vel={vel_summary['velocity']:.4f}$/s | " + f"accel={vel_summary['acceleration']:.4f} | " + f"stag={vel_summary['stagnation_s']:.0f}s | " + f"samples={vel_summary['samples']}" + ) + if should_close: logger.info(f"Smart Close #{ticket}: {reason.value if reason else 'unknown'} - {message}") diff --git a/src/position_manager.py b/src/position_manager.py index d66fbac..8e1b4d5 100644 --- a/src/position_manager.py +++ b/src/position_manager.py @@ -22,7 +22,6 @@ try: except ImportError: mt5 = None - # Timezone constants WIB = ZoneInfo("Asia/Jakarta") # GMT+7 EST = ZoneInfo("America/New_York") # Market timezone diff --git a/src/profit_momentum_tracker.py b/src/profit_momentum_tracker.py new file mode 100644 index 0000000..a649a13 --- /dev/null +++ b/src/profit_momentum_tracker.py @@ -0,0 +1,392 @@ +""" +Profit Momentum Tracker +======================== +Monitors real-time profit movements to detect optimal exit timing. + +NOTE: Velocity/acceleration logic has been ported to PositionGuard in +smart_risk_manager.py (Feb 2026). PositionGuard now tracks velocity, +acceleration, and stagnation inline with its existing momentum scoring. +This module is kept available for potential future sub-second monitoring +use cases but is NOT actively used by the live trading loop. + +Features: +- Track profit velocity (rate of change) +- Detect profit acceleration/deceleration +- Identify momentum reversals +- Prevent early exits while protecting from losses +- Smart exit timing based on profit patterns + +Usage: + tracker = ProfitMomentumTracker() + + # In trading loop (every 500ms): + tracker.update(ticket, current_profit, current_price) + + # Check exit signal: + should_exit, reason = tracker.should_exit(ticket) +""" + +import time +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass, field +from collections import deque +import numpy as np +from loguru import logger + + +@dataclass +class ProfitSnapshot: + """Single profit measurement at a point in time.""" + timestamp: float + profit: float + price: float + + +@dataclass +class MomentumMetrics: + """Calculated momentum metrics for a position.""" + velocity: float # $/second (profit change rate) + acceleration: float # $/s² (velocity change rate) + peak_profit: float # Maximum profit achieved + drawdown_from_peak: float # % drawdown from peak + drawdown_amount: float # $ amount of drawdown + stagnation_count: int # Consecutive samples with low velocity + momentum_direction: str # "INCREASING", "STABLE", "DECREASING" + time_in_profit: float # Seconds since first profitable + sample_count: int # Number of samples collected + + +@dataclass +class PositionMomentum: + """Track momentum for a single position.""" + ticket: int + entry_time: float = field(default_factory=time.time) + first_profit_time: Optional[float] = None + history: deque = field(default_factory=lambda: deque(maxlen=40)) # ~20 seconds at 500ms + peak_profit: float = 0.0 + peak_profit_time: float = 0.0 + total_samples: int = 0 + + +class ProfitMomentumTracker: + """ + Tracks profit momentum for all open positions. + + Analyzes profit patterns to determine optimal exit timing: + - Exit when momentum is reversing (profit turning to loss) + - Exit when deceleration is significant (growth slowing) + - Protect profits from reversal + - Avoid premature exits during healthy momentum + """ + + def __init__( + self, + # Velocity thresholds + velocity_reversal_threshold: float = -0.5, # Exit if velocity < -0.5 $/s + deceleration_threshold: float = -1.0, # Exit if acceleration < -1.0 $/s² + stagnation_threshold: float = 0.1, # Velocity < 0.1 $/s = stagnant + stagnation_count_max: int = 8, # Exit after 8 consecutive stagnant samples (4s) + + # Drawdown protection + peak_drawdown_threshold: float = 40.0, # Exit if drawdown > 40% from peak + min_peak_to_protect: float = 10.0, # Only protect peaks > $10 + + # Anti-early-exit protection + min_profit_for_momentum_exit: float = 5.0, # Don't exit on momentum if profit < $5 + grace_period_seconds: float = 10.0, # Minimum 10s in profit before momentum exit + min_samples_required: int = 6, # Minimum 6 samples (3s) before analyzing + + # Logging + enable_logging: bool = True, + ): + self.velocity_reversal_threshold = velocity_reversal_threshold + self.deceleration_threshold = deceleration_threshold + self.stagnation_threshold = stagnation_threshold + self.stagnation_count_max = stagnation_count_max + self.peak_drawdown_threshold = peak_drawdown_threshold + self.min_peak_to_protect = min_peak_to_protect + self.min_profit_for_momentum_exit = min_profit_for_momentum_exit + self.grace_period_seconds = grace_period_seconds + self.min_samples_required = min_samples_required + self.enable_logging = enable_logging + + # Track positions + self.positions: Dict[int, PositionMomentum] = {} + + def update(self, ticket: int, profit: float, price: float) -> None: + """ + Update profit tracking for a position. + + Args: + ticket: MT5 ticket number + profit: Current profit in $ + price: Current market price + """ + now = time.time() + + # Initialize position tracking if new + if ticket not in self.positions: + self.positions[ticket] = PositionMomentum( + ticket=ticket, + entry_time=now, + ) + + pos = self.positions[ticket] + + # Track first time in profit + if profit > 0 and pos.first_profit_time is None: + pos.first_profit_time = now + + # Update peak profit + if profit > pos.peak_profit: + pos.peak_profit = profit + pos.peak_profit_time = now + + # Add snapshot to history + snapshot = ProfitSnapshot( + timestamp=now, + profit=profit, + price=price, + ) + pos.history.append(snapshot) + pos.total_samples += 1 + + def calculate_metrics(self, ticket: int) -> Optional[MomentumMetrics]: + """ + Calculate momentum metrics for a position. + + Args: + ticket: MT5 ticket number + + Returns: + MomentumMetrics or None if insufficient data + """ + if ticket not in self.positions: + return None + + pos = self.positions[ticket] + + # Need at least 2 samples to calculate velocity + if len(pos.history) < 2: + return None + + # Convert history to arrays + history = list(pos.history) + times = np.array([s.timestamp for s in history]) + profits = np.array([s.profit for s in history]) + + # Calculate velocity (profit change rate) + # Use recent samples for velocity (last 5 samples = 2.5s) + if len(history) >= 5: + recent_times = times[-5:] + recent_profits = profits[-5:] + dt = recent_times[-1] - recent_times[0] + if dt > 0: + velocity = (recent_profits[-1] - recent_profits[0]) / dt + else: + velocity = 0.0 + else: + dt = times[-1] - times[0] + velocity = (profits[-1] - profits[0]) / dt if dt > 0 else 0.0 + + # Calculate acceleration (velocity change rate) + # Need at least 10 samples for acceleration + acceleration = 0.0 + if len(history) >= 10: + # Split into two halves and compare velocities + mid = len(history) // 2 + + # First half velocity + t1 = times[:mid] + p1 = profits[:mid] + dt1 = t1[-1] - t1[0] + v1 = (p1[-1] - p1[0]) / dt1 if dt1 > 0 else 0.0 + + # Second half velocity + t2 = times[mid:] + p2 = profits[mid:] + dt2 = t2[-1] - t2[0] + v2 = (p2[-1] - p2[0]) / dt2 if dt2 > 0 else 0.0 + + # Acceleration = change in velocity + dt_total = times[-1] - times[0] + acceleration = (v2 - v1) / dt_total if dt_total > 0 else 0.0 + + # Determine momentum direction + if velocity > self.stagnation_threshold: + momentum_direction = "INCREASING" + elif velocity < -self.stagnation_threshold: + momentum_direction = "DECREASING" + else: + momentum_direction = "STABLE" + + # Count stagnation (consecutive samples with low velocity) + stagnation_count = 0 + if len(history) >= 4: + for i in range(len(history) - 1, max(len(history) - 9, 0), -1): + if i > 0: + dt = times[i] - times[i-1] + dp = profits[i] - profits[i-1] + v = dp / dt if dt > 0 else 0.0 + if abs(v) < self.stagnation_threshold: + stagnation_count += 1 + else: + break + + # Calculate drawdown from peak + current_profit = profits[-1] + drawdown_amount = pos.peak_profit - current_profit + drawdown_pct = (drawdown_amount / pos.peak_profit * 100) if pos.peak_profit > 0 else 0.0 + + # Time in profit + time_in_profit = 0.0 + if pos.first_profit_time is not None: + time_in_profit = time.time() - pos.first_profit_time + + return MomentumMetrics( + velocity=velocity, + acceleration=acceleration, + peak_profit=pos.peak_profit, + drawdown_from_peak=drawdown_pct, + drawdown_amount=drawdown_amount, + stagnation_count=stagnation_count, + momentum_direction=momentum_direction, + time_in_profit=time_in_profit, + sample_count=len(pos.history), + ) + + def should_exit(self, ticket: int, current_profit: float) -> Tuple[bool, Optional[str]]: + """ + Determine if position should exit based on momentum analysis. + + Args: + ticket: MT5 ticket number + current_profit: Current profit in $ + + Returns: + (should_exit: bool, reason: str or None) + """ + metrics = self.calculate_metrics(ticket) + + if metrics is None: + return False, None + + # Not enough samples yet + if metrics.sample_count < self.min_samples_required: + return False, None + + pos = self.positions[ticket] + + # === EXIT CONDITIONS === + + # 1. VELOCITY REVERSAL - Profit momentum turning negative + if metrics.velocity < self.velocity_reversal_threshold: + # Anti-early-exit: only if profit is significant or past grace period + if current_profit >= self.min_profit_for_momentum_exit or \ + metrics.time_in_profit >= self.grace_period_seconds: + reason = ( + f"Momentum reversal detected (velocity: {metrics.velocity:.2f} $/s, " + f"profit: ${current_profit:.2f})" + ) + if self.enable_logging: + logger.warning(f"#{ticket} {reason}") + return True, reason + + # 2. STRONG DECELERATION - Profit growth slowing significantly + if metrics.acceleration < self.deceleration_threshold: + # Only exit if already in decent profit + if current_profit >= self.min_profit_for_momentum_exit: + reason = ( + f"Strong deceleration (accel: {metrics.acceleration:.2f} $/s², " + f"velocity: {metrics.velocity:.2f} $/s)" + ) + if self.enable_logging: + logger.warning(f"#{ticket} {reason}") + return True, reason + + # 3. PEAK DRAWDOWN - Profit pulled back significantly from peak + if metrics.peak_profit >= self.min_peak_to_protect: + if metrics.drawdown_from_peak >= self.peak_drawdown_threshold: + reason = ( + f"Peak drawdown exceeded (peak: ${metrics.peak_profit:.2f}, " + f"current: ${current_profit:.2f}, drawdown: {metrics.drawdown_from_peak:.1f}%)" + ) + if self.enable_logging: + logger.warning(f"#{ticket} {reason}") + return True, reason + + # 4. STAGNATION - Profit flat for too long (might reverse soon) + if metrics.stagnation_count >= self.stagnation_count_max: + # Only exit if in profit and past grace period + if current_profit >= self.min_profit_for_momentum_exit and \ + metrics.time_in_profit >= self.grace_period_seconds: + reason = ( + f"Profit stagnation ({metrics.stagnation_count} samples, " + f"${current_profit:.2f} profit)" + ) + if self.enable_logging: + logger.info(f"#{ticket} {reason}") + return True, reason + + # No exit signal + return False, None + + def get_position_summary(self, ticket: int) -> Optional[Dict]: + """ + Get detailed summary for a position. + + Args: + ticket: MT5 ticket number + + Returns: + Dictionary with position metrics or None + """ + metrics = self.calculate_metrics(ticket) + + if metrics is None: + return None + + pos = self.positions[ticket] + history = list(pos.history) + + return { + "ticket": ticket, + "samples": metrics.sample_count, + "time_in_profit": metrics.time_in_profit, + "current_profit": history[-1].profit if history else 0.0, + "peak_profit": metrics.peak_profit, + "velocity": metrics.velocity, + "acceleration": metrics.acceleration, + "momentum": metrics.momentum_direction, + "stagnation_count": metrics.stagnation_count, + "drawdown_pct": metrics.drawdown_from_peak, + "drawdown_amount": metrics.drawdown_amount, + } + + def cleanup_position(self, ticket: int) -> None: + """ + Remove position tracking when closed. + + Args: + ticket: MT5 ticket number + """ + if ticket in self.positions: + if self.enable_logging: + summary = self.get_position_summary(ticket) + if summary: + logger.info( + f"Cleanup #{ticket} | " + f"Peak: ${summary['peak_profit']:.2f} | " + f"Samples: {summary['samples']} | " + f"Time in profit: {summary['time_in_profit']:.1f}s" + ) + del self.positions[ticket] + + def get_all_summaries(self) -> List[Dict]: + """Get summaries for all tracked positions.""" + summaries = [] + for ticket in self.positions: + summary = self.get_position_summary(ticket) + if summary: + summaries.append(summary) + return summaries diff --git a/src/smart_risk_manager.py b/src/smart_risk_manager.py index 0b6bb6e..3ed57c9 100644 --- a/src/smart_risk_manager.py +++ b/src/smart_risk_manager.py @@ -14,6 +14,7 @@ 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 @@ -98,17 +99,33 @@ class PositionGuard: stall_count: int = 0 # Berapa kali harga stall/sideways reversal_warnings: int = 0 # Jumlah warning ML reversal + # === 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 + 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) def calculate_momentum(self) -> float: """ @@ -167,6 +184,60 @@ class PositionGuard: 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: """ @@ -642,6 +713,12 @@ class SmartRiskManager: 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 + # === CHECK 1: SMART TAKE PROFIT === if current_profit >= 15: # Profit $15+ # A. Hard TP - profit sangat bagus @@ -660,10 +737,24 @@ class SmartRiskManager: 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}%)" + # F. Velocity reversal — profit >= $15 but velocity turning negative + if guard.velocity < -0.3 and trade_age_minutes >= 15: + return True, ExitReason.TAKE_PROFIT, f"[VEL-EXIT] Securing ${current_profit:.2f} (velocity: {guard.velocity:.3f} $/s, momentum: {momentum:+.0f})" + + # G. Deceleration — profit >= $20, growth slowing significantly + if current_profit >= 20 and guard.acceleration < -0.05 and guard.velocity < 0.1: + return True, ExitReason.TAKE_PROFIT, f"[DECEL] Securing ${current_profit:.2f} (accel: {guard.acceleration:.4f}, vel: {guard.velocity:.3f})" + # 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 $8-$15) === + if 8 <= current_profit < 15: + # Higher velocity threshold for smaller profits + if guard.velocity < -0.5 and trade_age_minutes >= 15: + return True, ExitReason.TAKE_PROFIT, f"[VEL-WARN] Fast reversal ${current_profit:.2f} (velocity: {guard.velocity:.3f} $/s)" + # === CHECK 2: SMART EARLY EXIT (small profit) === if 5 <= current_profit < 15: # Ambil profit kecil jika momentum sangat negatif @@ -682,26 +773,30 @@ class SmartRiskManager: # 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 # GRACE PERIOD: Wait at least 1 M15 candle (15 min) before early cut # Intra-candle moves are noise — let the trade develop on its timeframe - trade_age_seconds = (now - guard.entry_time).total_seconds() - trade_age_minutes = trade_age_seconds / 60 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 # BUT only after grace period (15 min = 1 M15 candle) - if momentum < -50 and loss_percent_of_max >= 30: # #24B: relaxed from -30 (backtest +$125) + momentum_trigger = momentum < -50 and loss_percent_of_max >= 30 # #24B: relaxed from -30 (backtest +$125) + # Velocity alternative: fast drop even if momentum score hasn't caught up + velocity_trigger = guard.velocity < -0.4 and loss_percent_of_max >= 20 + + if momentum_trigger or velocity_trigger: if trade_age_minutes < 15: - logger.info(f"[GRACE] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}%) + momentum ({momentum:.0f}) — holding {trade_age_minutes:.1f}m/{15}m grace period") + logger.info(f"[GRACE] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}%) + momentum ({momentum:.0f}) vel({guard.velocity:.3f}) — holding {trade_age_minutes:.1f}m/{15}m grace period") else: - logger.info(f"[EARLY CUT] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}%) + weak momentum ({momentum:.0f}) - CUTTING EARLY (age: {trade_age_minutes:.0f}m)") - return True, ExitReason.TREND_REVERSAL, f"[EARLY CUT] Loss ${abs(current_profit):.2f} + momentum {momentum:.0f} - cutting to preserve daily limit" + trigger_type = "momentum" if momentum_trigger else "velocity" + logger.info(f"[EARLY CUT] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}%) + weak {trigger_type} ({momentum:.0f} / vel:{guard.velocity:.3f}) - CUTTING EARLY (age: {trade_age_minutes:.0f}m)") + return True, ExitReason.TREND_REVERSAL, f"[EARLY CUT] Loss ${abs(current_profit):.2f} + {trigger_type} — cutting to preserve daily limit" + + # Time-aware stagnation: stagnant for 120s+ with loss > $10 + if guard.stagnation_seconds >= 120 and abs(current_profit) > 10 and trade_age_minutes >= 15: + return True, ExitReason.TREND_REVERSAL, f"[STAGNANT] Loss ${abs(current_profit):.2f} stagnant {guard.stagnation_seconds:.0f}s — cutting" # NOTE: Smart Hold REMOVED - no more holding losers hoping for golden time # If SL is hit, close the trade immediately @@ -746,7 +841,6 @@ class SmartRiskManager: # === 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 @@ -760,8 +854,8 @@ class SmartRiskManager: # 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 + # Check if profit is growing (positive momentum AND positive velocity) + profit_growing = momentum > 0 and guard.velocity > 0 ml_agrees = ( (guard.direction == "BUY" and ml_signal == "BUY") or (guard.direction == "SELL" and ml_signal == "SELL")