feat: add velocity & acceleration tracking to PositionGuard

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 <noreply@anthropic.com>
This commit is contained in:
GifariKemal
2026-02-09 10:45:36 +07:00
co-authored by Claude Opus 4.6
parent 3c4e56ffd2
commit 44e7942718
4 changed files with 514 additions and 14 deletions
+16 -1
View File
@@ -203,7 +203,7 @@ class TradingBot:
self._current_session_multiplier: float = 1.0 # Session lot multiplier self._current_session_multiplier: float = 1.0 # Session lot multiplier
self._is_sydney_session: bool = False # Sydney session flag (needs higher confidence) self._is_sydney_session: bool = False # Sydney session flag (needs higher confidence)
self._last_candle_time: Optional[datetime] = None # Track last processed candle 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 # Entry filter tracking for dashboard
self._last_filter_results: list = [] self._last_filter_results: list = []
@@ -1944,6 +1944,21 @@ class TradingBot:
regime=regime_state.regime.value if regime_state else "normal", 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: if should_close:
logger.info(f"Smart Close #{ticket}: {reason.value if reason else 'unknown'} - {message}") logger.info(f"Smart Close #{ticket}: {reason.value if reason else 'unknown'} - {message}")
-1
View File
@@ -22,7 +22,6 @@ try:
except ImportError: except ImportError:
mt5 = None mt5 = None
# Timezone constants # Timezone constants
WIB = ZoneInfo("Asia/Jakarta") # GMT+7 WIB = ZoneInfo("Asia/Jakarta") # GMT+7
EST = ZoneInfo("America/New_York") # Market timezone EST = ZoneInfo("America/New_York") # Market timezone
+392
View File
@@ -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
+106 -12
View File
@@ -14,6 +14,7 @@ Author: AI Assistant
""" """
import os import os
import time
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from typing import Optional, Dict, Tuple, List from typing import Optional, Dict, Tuple, List
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -98,17 +99,33 @@ class PositionGuard:
stall_count: int = 0 # Berapa kali harga stall/sideways stall_count: int = 0 # Berapa kali harga stall/sideways
reversal_warnings: int = 0 # Jumlah warning ML reversal 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): def update_history(self, price: float, profit: float, ml_confidence: float, max_history: int = 20):
"""Update price/profit history untuk analisis momentum.""" """Update price/profit history untuk analisis momentum."""
now = time.time()
self.price_history.append(price) self.price_history.append(price)
self.profit_history.append(profit) self.profit_history.append(profit)
self.ml_confidence_history.append(ml_confidence) self.ml_confidence_history.append(ml_confidence)
self.profit_timestamps.append(now)
# Keep only last N entries # Keep only last N entries
if len(self.price_history) > max_history: if len(self.price_history) > max_history:
self.price_history = self.price_history[-max_history:] self.price_history = self.price_history[-max_history:]
self.profit_history = self.profit_history[-max_history:] self.profit_history = self.profit_history[-max_history:]
self.ml_confidence_history = self.ml_confidence_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: def calculate_momentum(self) -> float:
""" """
@@ -167,6 +184,60 @@ class PositionGuard:
probability = progress_score + momentum_score + conf_score - time_penalty probability = progress_score + momentum_score + conf_score - time_penalty
return max(0, min(100, probability)) 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: class SmartRiskManager:
""" """
@@ -642,6 +713,12 @@ class SmartRiskManager:
momentum = guard.calculate_momentum() momentum = guard.calculate_momentum()
tp_probability = guard.get_tp_probability() 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 === # === CHECK 1: SMART TAKE PROFIT ===
if current_profit >= 15: # Profit $15+ if current_profit >= 15: # Profit $15+
# A. Hard TP - profit sangat bagus # A. Hard TP - profit sangat bagus
@@ -660,10 +737,24 @@ class SmartRiskManager:
if tp_probability < 25 and current_profit >= 20: 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}%)" 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 # E. Masih bagus, let it run
if momentum >= 0: if momentum >= 0:
return False, None, f"Profit ${current_profit:.2f} [GOOD] (momentum: {momentum:+.0f}, TP prob: {tp_probability:.0f}%)" 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) === # === CHECK 2: SMART EARLY EXIT (small profit) ===
if 5 <= current_profit < 15: if 5 <= current_profit < 15:
# Ambil profit kecil jika momentum sangat negatif # Ambil profit kecil jika momentum sangat negatif
@@ -682,26 +773,30 @@ class SmartRiskManager:
# It encourages holding losers hoping they'll recover # It encourages holding losers hoping they'll recover
# PROPER RISK MANAGEMENT: Follow SL rules, don't hope for recovery # 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 # 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 # 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 # 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: if current_profit < 0:
loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100 loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
# Cut early if momentum is against us AND loss is significant # Cut early if momentum is against us AND loss is significant
# BUT only after grace period (15 min = 1 M15 candle) # 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: 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: 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)") trigger_type = "momentum" if momentum_trigger else "velocity"
return True, ExitReason.TREND_REVERSAL, f"[EARLY CUT] Loss ${abs(current_profit):.2f} + momentum {momentum:.0f} - cutting to preserve daily limit" 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 # NOTE: Smart Hold REMOVED - no more holding losers hoping for golden time
# If SL is hit, close the trade immediately # If SL is hit, close the trade immediately
@@ -746,7 +841,6 @@ class SmartRiskManager:
# === CHECK 7: WEEKEND CLOSE === # === CHECK 7: WEEKEND CLOSE ===
# Market closes Saturday 05:00 WIB — only close 30 min before (Saturday 04:30 WIB) # 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_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 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 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 # Don't cut winners short - check profit growth and trend
trade_duration_hours = (now - guard.entry_time).total_seconds() / 3600 trade_duration_hours = (now - guard.entry_time).total_seconds() / 3600
# Check if profit is growing (positive momentum = don't exit early) # Check if profit is growing (positive momentum AND positive velocity)
profit_growing = momentum > 0 profit_growing = momentum > 0 and guard.velocity > 0
ml_agrees = ( ml_agrees = (
(guard.direction == "BUY" and ml_signal == "BUY") or (guard.direction == "BUY" and ml_signal == "BUY") or
(guard.direction == "SELL" and ml_signal == "SELL") (guard.direction == "SELL" and ml_signal == "SELL")