From f5c3f66a6255bf6a5c39710a4ecf1bbadfe14da3 Mon Sep 17 00:00:00 2001 From: buckybonez Date: Wed, 11 Feb 2026 08:28:31 +0700 Subject: [PATCH] feat: implement professional versioning system (v0.6.0) 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 --- CHANGELOG.md | 141 +++++ CLAUDE.md | 144 +++++ VERSION | 1 + main_live.py | 775 ++++++++++++++++++++---- src/fuzzy_exit_logic.py | 400 +++++++++++++ src/kalman_filter.py | 127 ++++ src/kelly_position_scaler.py | 200 +++++++ src/momentum_persistence.py | 330 +++++++++++ src/recovery_detector.py | 329 +++++++++++ src/smart_risk_manager.py | 1074 +++++++++++++++++++++++++++++++--- src/trajectory_predictor.py | 281 +++++++++ src/version.py | 253 ++++++++ 12 files changed, 3879 insertions(+), 176 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 VERSION create mode 100644 src/fuzzy_exit_logic.py create mode 100644 src/kalman_filter.py create mode 100644 src/kelly_position_scaler.py create mode 100644 src/momentum_persistence.py create mode 100644 src/recovery_detector.py create mode 100644 src/trajectory_predictor.py create mode 100644 src/version.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2d17c4e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,141 @@ +# Changelog + +All notable changes to XAUBot AI will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Professional versioning system with semantic versioning (MAJOR.MINOR.PATCH) +- Automated version detection based on enabled features +- Centralized version management via `src/version.py` +- Comprehensive changelog following Keep a Changelog format + +--- + +## [0.0.0] - 2026-02-11 + +### Initial Release +Starting point for versioned releases. All previous development consolidated into v0.0.0 baseline. + +#### Core Features +- **MT5 Integration**: Real-time connection to MetaTrader 5 +- **Smart Money Concepts (SMC)**: Order Blocks, Fair Value Gaps, BOS/CHoCH detection +- **Machine Learning**: XGBoost model for trade signal prediction (37 features) +- **HMM Regime Detection**: Market classification (trending/ranging/volatile) +- **Risk Management**: Multi-tier capital modes (MICRO/SMALL/MEDIUM/LARGE) +- **Session Filtering**: Sydney/London/NY session optimization +- **Telegram Notifications**: Real-time trade alerts and commands + +#### Advanced Exit Systems +- **v6.0 Kalman Intelligence**: Kalman filter for velocity smoothing +- **v6.1 Profit-Tier Strategy**: Dynamic exit thresholds based on profit magnitude +- **v6.2 Bug Fixes**: ExitReason.STOP_LOSS → POSITION_LIMIT correction +- **v6.3 Predictive Intelligence**: + - Trajectory Predictor (profit forecasting 1-5min ahead) + - Momentum Persistence Detector (continuation probability) + - Recovery Strength Analyzer (loss recovery optimization) + +#### Technical Infrastructure +- **Framework**: Python 3.11+, Polars (not Pandas), asyncio +- **Models**: XGBoost (binary classification), HMM (regime detection) +- **Database**: PostgreSQL for trade logging +- **Dashboard**: Next.js web monitoring interface +- **Deployment**: Docker support with multi-environment configs + +### Performance Metrics (Baseline) +- Win Rate: 56-58% +- Average Win: $2.78 (v6.2) → Target $6-8 (v6.3) +- Peak Capture: 71% → Target 85%+ +- Daily Loss Limit: 5% of capital +- Risk per Trade: 0.5-2% (capital-mode dependent) + +--- + +## Version History Format + +### [MAJOR.MINOR.PATCH] - YYYY-MM-DD + +#### Added +- New features that are backward compatible + +#### Changed +- Changes in existing functionality + +#### Deprecated +- Features that will be removed in future versions + +#### Removed +- Features that have been removed + +#### Fixed +- Bug fixes + +#### Security +- Security vulnerability fixes + +--- + +## Semantic Versioning Guidelines + +### MAJOR version (x.0.0) +Increment when making incompatible API changes: +- Breaking changes to core trading logic +- Removal of major features +- Database schema changes requiring migration +- Configuration format changes + +Examples: +- Switching from Pandas to Polars +- Changing ML model architecture completely +- Removing hard stop-loss system + +### MINOR version (0.x.0) +Increment when adding functionality in a backward-compatible manner: +- New exit strategies (e.g., v6.3 Predictive Intelligence) +- New indicators or features +- New filters or risk management modes +- Enhanced logging or monitoring + +Examples: +- Adding Trajectory Predictor +- Adding new session filter +- Implementing Kelly Criterion + +### PATCH version (0.0.x) +Increment when making backward-compatible bug fixes: +- Bug fixes that don't change behavior +- Performance optimizations +- Documentation updates +- Code refactoring (no logic changes) + +Examples: +- Fixing ExitReason.STOP_LOSS typo +- Fixing variable scope errors +- Correcting log messages + +--- + +## Feature Tracking + +Current feature set determines version automatically: + +| Feature | Version Component | Impact | +|---------|------------------|--------| +| Basic Trading (SMC + ML + MT5) | 0.x.x | Core | +| Exit v6.0 (Kalman) | 0.1.x | MINOR | +| Exit v6.1 (Profit-Tier) | 0.2.x | MINOR | +| Exit v6.2 (Bug Fixes) | 0.2.1 | PATCH | +| Exit v6.3 (Predictive) | 0.3.x | MINOR | +| Fuzzy Logic Controller | +0.1 | MINOR | +| Kelly Criterion | +0.1 | MINOR | +| Recovery Detector | +0.1 | MINOR | + +--- + +## Links +- [Repository](https://github.com/GifariKemal/xaubot-ai) +- [Documentation](./docs/) +- [Issues](https://github.com/GifariKemal/xaubot-ai/issues) diff --git a/CLAUDE.md b/CLAUDE.md index 90866ae..c83e2db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,3 +131,147 @@ Capital modes auto-configure risk parameters: - Models are stored as `.pkl` files in `models/` - Backtest logic is **synced with live** (`backtest_live_sync.py` mirrors `main_live.py`) - Scripts in `scripts/` and `tests/` include `sys.path` fix so they work from any directory + +--- + +## Versioning System + +### **Semantic Versioning (SemVer)** + +XAUBot AI uses **Semantic Versioning 2.0.0**: `MAJOR.MINOR.PATCH` + +- **MAJOR**: Incompatible API changes, breaking changes (e.g., 1.0.0 → 2.0.0) +- **MINOR**: New features, backward compatible (e.g., 0.1.0 → 0.2.0) +- **PATCH**: Bug fixes, backward compatible (e.g., 0.1.0 → 0.1.1) + +### **Version Files** + +1. **`VERSION`** - Single source of truth (base version) +2. **`CHANGELOG.md`** - Detailed change history (Keep a Changelog format) +3. **`src/version.py`** - Centralized version manager + +### **Auto-Versioning** + +Version is **automatically calculated** based on enabled features: + +```python +# Base version from VERSION file +Base: 0.0.0 + +# Feature increments (cumulative): ++ Kalman Filter → +0.1.0 = 0.1.0 ++ Fuzzy Logic → +0.1.0 = 0.2.0 ++ Kelly Criterion → +0.1.0 = 0.3.0 ++ Trajectory Predictor → +0.1.0 = 0.4.0 ++ Momentum Persistence → +0.1.0 = 0.5.0 ++ Recovery Detector → +0.1.0 = 0.6.0 + +# Effective version: v0.6.0 (Kalman + Fuzzy + Kelly + Predictive) +``` + +### **Feature Detection** + +Features auto-detected from: +- **Environment variables**: `KALMAN_ENABLED`, `ADVANCED_EXITS_ENABLED`, `PREDICTIVE_ENABLED` +- **Import availability**: Modules in `src/` directory +- **Runtime checks**: Component initialization + +### **Version Display** + +```python +from src.version import get_version, get_detailed_version + +print(get_version()) # "0.6.0" +print(get_detailed_version()) # "v0.6.0 (Kalman + Fuzzy + Kelly + Predictive)" +``` + +### **Changelog Management** + +All changes documented in `CHANGELOG.md`: + +```markdown +## [0.6.0] - 2026-02-11 +### Added +- Trajectory Predictor for profit forecasting +- Momentum Persistence Detector +- Recovery Strength Analyzer + +### Changed +- Exit strategy version: v6.2 → v6.3 +- Fuzzy threshold now dynamic (85-98%) + +### Fixed +- ExitReason.STOP_LOSS → POSITION_LIMIT +``` + +### **Version Update Workflow** + +1. **Add new feature** → Automatically increments MINOR version +2. **Fix bug** → Manually increment PATCH in `VERSION` file +3. **Breaking change** → Manually increment MAJOR in `VERSION` file +4. **Update CHANGELOG.md** → Document all changes +5. **Commit** → Version updates committed with changes + +### **When to Update VERSION File** + +**Auto-incremented** (no manual change needed): +- Adding new predictive modules +- Enabling/disabling feature flags +- Adding new exit strategies + +**Manual increment required**: +- Bug fixes → Increment PATCH (0.6.0 → 0.6.1) +- Breaking changes → Increment MAJOR (0.6.0 → 1.0.0) +- Resetting versions → Edit `VERSION` file directly + +### **Example Version History** + +``` +v0.0.0 - Initial release (baseline) +v0.1.0 - Added Kalman Filter +v0.2.0 - Added Fuzzy Logic Controller +v0.3.0 - Added Kelly Criterion +v0.4.0 - Added Trajectory Predictor +v0.5.0 - Added Momentum Persistence +v0.6.0 - Added Recovery Detector (v6.3 Predictive Intelligence complete) +v0.6.1 - Fixed variable scope bug (PATCH) +v0.7.0 - Added new session filter (MINOR) +v1.0.0 - Complete rewrite with new ML architecture (MAJOR) +``` + +### **Version in Logs** + +``` +============================================================ +XAUBOT AI v0.6.0 (Kalman + Fuzzy + Kelly + Predictive) +Strategy: Exit v6.3 Predictive Intelligence +============================================================ +SMART RISK MANAGER v0.6.0 (Exit v6.3 Predictive Intelligence) INITIALIZED + [OK] Fuzzy Exit Controller initialized + [OK] Kelly Position Scaler initialized + [OK] Trajectory Predictor initialized + [OK] Momentum Persistence initialized + [OK] Recovery Detector initialized + Advanced Exits: ENABLED (Kalman + Fuzzy + Kelly + Predictive) +============================================================ +``` + +### **Best Practices** + +1. **Always update CHANGELOG.md** when making changes +2. **Use semantic commit messages**: `feat:`, `fix:`, `docs:`, `refactor:` +3. **Version tags in git**: `git tag v0.6.0` after stable release +4. **Document breaking changes** clearly in CHANGELOG +5. **Test version detection**: `python src/version.py` to verify + +### **Quick Reference** + +| Action | Version Impact | Example | +|--------|---------------|---------| +| Add feature | +0.1.0 (MINOR) | Predictive Intelligence | +| Fix bug | +0.0.1 (PATCH) | Variable scope fix | +| Breaking change | +1.0.0 (MAJOR) | API redesign | +| Enable feature flag | Auto-detected | `PREDICTIVE_ENABLED=1` | +| Disable feature | Auto-detected | `KALMAN_ENABLED=0` | + +--- diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..77d6f4c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.0 diff --git a/main_live.py b/main_live.py index cfc8fb6..11305d8 100644 --- a/main_live.py +++ b/main_live.py @@ -20,7 +20,8 @@ import time import os import json from collections import deque -from datetime import datetime, date +from datetime import datetime, date, timedelta +from types import SimpleNamespace from typing import Optional, Dict, Tuple from zoneinfo import ZoneInfo from pathlib import Path @@ -130,28 +131,30 @@ class TradingBot: # Initialize filter config manager self.filter_config = FilterConfigManager("data/filter_config.json") - # Initialize ML V2 Model D (76 features, AUC 0.7339) + # Initialize ML Model (unified path — auto-trainer saves here after retrain) self.ml_model = TradingModelV2( - confidence_threshold=self.config.ml.confidence_threshold, - model_path="models/xgboost_model_v2d.pkl", + confidence_threshold=0.60, # Binary confidence threshold (adjustable 0.55-0.65) + model_path="models/xgboost_model.pkl", ) self.fe_v2 = MLV2FeatureEngineer() self._h1_df_cached = None # Cache H1 DataFrame with indicators for V2 features - # Initialize Smart Position Manager - ATR-ADAPTIVE (#24B) + # Initialize Smart Position Manager — EXIT STRATEGY v4 "Patient Recovery" + # Philosophy: Let trades BREATHE. Don't cut winners at $3-4. + # Regime danger only at $8+. Give losers room to recover. self.position_manager = SmartPositionManager( - breakeven_pips=30.0, # Fallback if ATR unavailable - trail_start_pips=50.0, # Fallback if ATR unavailable - trail_step_pips=30.0, # Fallback if ATR unavailable - atr_be_mult=2.0, # Breakeven = ATR * 2.0 (#24B) - atr_trail_start_mult=4.0, # Trail start = ATR * 4.0 (#24B) - atr_trail_step_mult=3.0, # Trail step = ATR * 3.0 (#24B) - min_profit_to_protect=5.0, # Protect profits > $5 - max_drawdown_from_peak=50.0, # Allow 50% drawdown (we use tiny lots) + breakeven_pips=20.0, # Fallback if ATR unavailable + trail_start_pips=35.0, # Fallback if ATR unavailable + trail_step_pips=20.0, # Fallback if ATR unavailable + atr_be_mult=2.0, # v4: BE at 2x ATR (from 1.0) — don't lock too early + atr_trail_start_mult=3.0, # v4: Trail at 3x ATR (from 2.0) — let profit run + atr_trail_step_mult=2.0, # v4: Trail step 2x ATR (from 1.5) + min_profit_to_protect=8.0, # v4: Regime/signal exit only at $8+ (from $3) + max_drawdown_from_peak=40.0, # v4: Allow 40% drawdown (from 25%) # Smart Market Close Handler enable_market_close_handler=True, - min_profit_before_close=5.0, # Take profit >= $5 before market close - max_loss_to_hold=30.0, # Max loss $30 per position + min_profit_before_close=3.0, # v4: from $2 + max_loss_to_hold=10.0, # v4: Max loss $10 per position (from $5) ) # Initialize Session Filter (WIB timezone for Batam) @@ -184,6 +187,10 @@ class TradingBot: self._loop_count = 0 self._h1_bias_cache = "NEUTRAL" self._h1_bias_loop = 0 + self._h1_bias_score = 0.0 + self._h1_bias_strength = "weak" + self._h1_bias_signals = {} + self._h1_bias_regime_weights = "unknown" self._last_signal: Optional[SMCSignal] = None self._last_retrain_check: Optional[datetime] = None self._last_trade_time: Optional[datetime] = None @@ -203,6 +210,8 @@ 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._pyramid_done_tickets: set = set() # Tickets that already triggered a pyramid + self._last_pyramid_time: Optional[datetime] = None # Cooldown between pyramids self._position_check_interval: int = 5 # Check positions every N seconds between candles (more data points for velocity) # Entry filter tracking for dashboard @@ -219,7 +228,82 @@ class TradingBot: self._dash_logs: deque = deque(maxlen=50) self._dash_last_price: float = 0.0 self._dash_status_file = Path("data/bot_status.json") - + + # Restore dashboard state from previous session + self._restore_dashboard_state() + + def _restore_dashboard_state(self): + """Restore dashboard histories from bot_status.json so restart doesn't lose data.""" + try: + if not self._dash_status_file.exists(): + return + import json + with open(self._dash_status_file, "r") as f: + prev = json.load(f) + + # Restore price/equity/balance histories + for val in prev.get("priceHistory", []): + self._dash_price_history.append(val) + for val in prev.get("equityHistory", []): + self._dash_equity_history.append(val) + for val in prev.get("balanceHistory", []): + self._dash_balance_history.append(val) + + # Restore logs + for log in prev.get("logs", []): + self._dash_logs.append(log) + + # Restore last price + self._dash_last_price = prev.get("price", 0.0) + + # Restore signal caches so dashboard doesn't show empty + smc = prev.get("smc", {}) + if smc.get("signal"): + self._last_raw_smc_signal = smc["signal"] + self._last_raw_smc_confidence = smc.get("confidence", 0.0) + self._last_raw_smc_reason = smc.get("reason", "") + self._last_raw_smc_updated = smc.get("updatedAt", "") + + ml = prev.get("ml", {}) + if ml.get("signal"): + self._last_ml_signal = ml["signal"] + self._last_ml_confidence = ml.get("confidence", 0.0) + self._last_ml_probability = ml.get("buyProb", ml.get("confidence", 0.0)) + self._last_ml_updated = ml.get("updatedAt", "") + + regime = prev.get("regime", {}) + if regime.get("name"): + from src.regime_detector import MarketRegime + regime_val = regime["name"].lower().replace(" ", "_") + try: + self._last_regime = MarketRegime(regime_val) + except ValueError: + pass + self._last_regime_volatility = regime.get("volatility", 0.0) + self._last_regime_confidence = regime.get("confidence", 0.0) + self._last_regime_updated = regime.get("updatedAt", "") + + # Restore performance stats + perf = prev.get("performance", {}) + self._loop_count = perf.get("loopCount", 0) + self._total_session_trades = perf.get("totalSessionTrades", 0) + self._total_session_wins = perf.get("totalSessionWins", 0) + self._total_session_profit = perf.get("totalSessionProfit", 0.0) + # Restore uptime: shift start_time back by previous uptime + prev_uptime_h = perf.get("uptimeHours", 0) + if prev_uptime_h > 0: + self._start_time = datetime.now() - timedelta(hours=prev_uptime_h) + + # H1 bias: restore values but force recalc on first loop + self._h1_ema20_value = prev.get("h1BiasDetails", {}).get("ema20", 0.0) + self._h1_current_price = prev.get("h1BiasDetails", {}).get("price", 0.0) + # DON'T restore _h1_bias_cache — let it recalculate fresh from MT5 + self._h1_bias_loop = -999 # Force recalc on first iteration + + logger.info(f"Dashboard state restored: {len(self._dash_price_history)} prices, {len(self._dash_logs)} logs, loops={self._loop_count}, uptime={prev_uptime_h}h") + except Exception as e: + logger.warning(f"Could not restore dashboard state: {e}") + def _load_models(self) -> bool: """Load pre-trained models.""" logger.info("Loading trained models...") @@ -281,11 +365,24 @@ class TradingBot: "updatedAt": datetime.now(ZoneInfo("Asia/Jakarta")).isoformat(), } - # Extract feature importance from XGBoost model - if self.ml_model.fitted and hasattr(self.ml_model, 'model') and self.ml_model.model is not None: + # Extract feature importance from XGBoost model (V2: xgb_model, V1: model) + booster = getattr(self.ml_model, 'xgb_model', None) or getattr(self.ml_model, 'model', None) + if self.ml_model.fitted and booster is not None: try: - booster = self.ml_model.model importance = booster.get_score(importance_type='gain') if hasattr(booster, 'get_score') else {} + # Map f0/f1/... back to feature names if needed + if importance and self.ml_model.feature_names: + mapped = {} + for key, val in importance.items(): + if key.startswith('f') and key[1:].isdigit(): + idx = int(key[1:]) + if idx < len(self.ml_model.feature_names): + mapped[self.ml_model.feature_names[idx]] = val + else: + mapped[key] = val + else: + mapped[key] = val + importance = mapped if not importance and hasattr(booster, 'feature_importances_'): names = self.ml_model.feature_names if hasattr(self.ml_model, 'feature_names') else [] importance = dict(zip(names, booster.feature_importances_)) @@ -306,10 +403,10 @@ class TradingBot: metrics["sampleCount"] = retrain_results.get("sample_count", 0) elif hasattr(self.ml_model, '_train_metrics') and self.ml_model._train_metrics: # Use metrics stored in the model pickle (loaded on startup) - # V1 uses train_auc/test_auc, V2 uses xgb_train_score/xgb_test_score + # V1: train_auc/test_auc, V2: xgb_train_score/xgb_test_score, V3: train_accuracy/test_accuracy tm = self.ml_model._train_metrics - metrics["trainAuc"] = tm.get("train_auc", 0) or tm.get("xgb_train_score", 0) - metrics["testAuc"] = tm.get("test_auc", 0) or tm.get("xgb_test_score", 0) + metrics["trainAuc"] = tm.get("train_auc", 0) or tm.get("xgb_train_score", 0) or tm.get("train_accuracy", 0) + metrics["testAuc"] = tm.get("test_auc", 0) or tm.get("xgb_test_score", 0) or tm.get("test_accuracy", 0) metrics["sampleCount"] = tm.get("train_samples", 0) + tm.get("test_samples", 0) elif hasattr(self, 'auto_trainer') and hasattr(self.auto_trainer, 'last_auc'): metrics["testAuc"] = self.auto_trainer.last_auc or 0 @@ -408,8 +505,8 @@ class TradingBot: ml_data = { "signal": ml_signal, "confidence": ml_conf, - "buyProb": ml_prob if ml_signal == "BUY" else (1.0 - ml_prob), - "sellProb": ml_prob if ml_signal == "SELL" else (1.0 - ml_prob), + "buyProb": ml_prob, # ml_prob = probability of BUY (always) + "sellProb": 1.0 - ml_prob, # complement = probability of SELL "updatedAt": getattr(self, "_last_ml_updated", ""), } @@ -464,11 +561,11 @@ class TradingBot: "logs": list(self._dash_logs), "settings": { "capitalMode": self.config.capital_mode.value, - "capital": self.config.capital, - "riskPerTrade": self.config.risk.risk_per_trade, - "maxDailyLoss": self.config.risk.max_daily_loss, - "maxPositions": self.config.risk.max_positions, - "maxLotSize": self.config.risk.max_lot_size, + "capital": self.smart_risk.capital, + "riskPerTrade": self.smart_risk.max_loss_per_trade_percent, + "maxDailyLoss": self.smart_risk.max_daily_loss_percent, + "maxPositions": self.smart_risk.max_concurrent_positions, + "maxLotSize": self.smart_risk.max_lot_size, "leverage": self.config.risk.max_leverage, "executionTF": self.config.execution_timeframe, "trendTF": self.config.trend_timeframe, @@ -512,15 +609,31 @@ class TradingBot: # === NEW: H1 Bias Details === "h1BiasDetails": { "bias": getattr(self, "_h1_bias_cache", "NEUTRAL"), + "score": getattr(self, "_h1_bias_score", 0.0), + "strength": getattr(self, "_h1_bias_strength", "weak"), + "indicators": getattr(self, "_h1_bias_signals", {}), + "regimeWeights": getattr(self, "_h1_bias_regime_weights", "unknown"), "ema20": getattr(self, "_h1_ema20_value", 0.0), "price": getattr(self, "_h1_current_price", 0.0), }, } - # Atomic write (write to temp then rename) - tmp_file = self._dash_status_file.with_suffix(".tmp") - tmp_file.write_text(json.dumps(status, default=str)) - tmp_file.replace(self._dash_status_file) + # Direct write with retry (Windows-friendly) + json_data = json.dumps(status, default=str) + status_path = str(self._dash_status_file) + written = False + for attempt in range(3): + try: + with open(status_path, "w", encoding="utf-8") as f: + f.write(json_data) + written = True + break + except (PermissionError, OSError) as e: + if attempt < 2: + import time as _time + _time.sleep(0.05) + else: + logger.debug(f"Dashboard write failed after 3 attempts: {e}") except Exception as e: logger.debug(f"Dashboard status write error: {e}") @@ -560,7 +673,7 @@ class TradingBot: """Get time filter (#34A) status for dashboard.""" try: wib_hour = datetime.now(ZoneInfo("Asia/Jakarta")).hour - blocked_hours = [9, 21] + blocked_hours = [] # All hours enabled return { "wibHour": wib_hour, "isBlocked": wib_hour in blocked_hours, @@ -600,9 +713,21 @@ class TradingBot: if self.auto_trainer._last_retrain_time: hours_since = (datetime.now(ZoneInfo("Asia/Jakarta")) - self.auto_trainer._last_retrain_time).total_seconds() / 3600 + # Get AUC: prefer auto_trainer's cached value, fallback to model's stored metrics + current_auc = self.auto_trainer._current_auc + if current_auc is None and hasattr(self.ml_model, '_train_metrics') and self.ml_model._train_metrics: + tm = self.ml_model._train_metrics + current_auc = tm.get("test_auc") or tm.get("xgb_test_score") or tm.get("test_accuracy") + + # Sanitize NaN values for JSON compliance + if current_auc is not None: + import math + if math.isnan(current_auc) or math.isinf(current_auc): + current_auc = None + return { "lastRetrain": self.auto_trainer._last_retrain_time.strftime("%Y-%m-%d %H:%M") if self.auto_trainer._last_retrain_time else None, - "currentAuc": self.auto_trainer._current_auc, + "currentAuc": current_auc, "minAucThreshold": self.auto_trainer.min_auc_threshold, "hoursSinceRetrain": round(hours_since, 1), "nextRetrainHour": self.auto_trainer.daily_retrain_hour, @@ -667,8 +792,18 @@ class TradingBot: async def start(self): """Start the trading bot.""" + # Import version info + try: + from src.version import get_detailed_version, __exit_strategy__ + version_str = get_detailed_version() + exit_str = __exit_strategy__ + except ImportError: + version_str = "v0.0.0 (Core)" + exit_str = "Exit v5.0" + logger.info("=" * 60) - logger.info("SMART AUTOMATIC TRADING BOT + AI") + logger.info(f"XAUBOT AI {version_str}") + logger.info(f"Strategy: {exit_str}") logger.info("=" * 60) logger.info(f"Symbol: {self.config.symbol}") logger.info(f"Capital: ${self.config.capital:,.2f}") @@ -714,6 +849,9 @@ class TradingBot: # Register Telegram commands self._register_telegram_commands() + # Sync position guards with MT5 (cleanup stale guards from previous restarts) + self._sync_position_guards() + # Start main loop self._running = True self._dash_log("info", "Bot started - trading loop active") @@ -735,6 +873,27 @@ class TradingBot: self.mt5.disconnect() self._log_summary() + def _sync_position_guards(self): + """Sync position guards with actual MT5 positions — remove stale guards from previous restarts.""" + try: + open_positions = self.mt5.get_open_positions( + symbol=self.config.symbol, + magic=self.config.magic_number, + ) + mt5_tickets = set() + if open_positions is not None and not open_positions.is_empty(): + mt5_tickets = set(open_positions["ticket"].to_list()) + + stale_guards = set(self.smart_risk._position_guards.keys()) - mt5_tickets + for ticket in stale_guards: + self.smart_risk.unregister_position(ticket) + + if stale_guards: + logger.info(f"Cleaned up {len(stale_guards)} stale position guards: {stale_guards}") + logger.info(f"Position guards synced: {len(self.smart_risk._position_guards)} active (MT5 has {len(mt5_tickets)} positions)") + except Exception as e: + logger.warning(f"Position guard sync failed: {e}") + def _get_available_features(self, df: pl.DataFrame) -> list: """Get feature columns that exist in DataFrame.""" if self.ml_model.fitted and self.ml_model.feature_names: @@ -774,14 +933,19 @@ class TradingBot: # --- H1 Multi-Timeframe Bias (Fix 5) --- def _get_h1_bias(self) -> str: """ - Determine H1 higher-timeframe bias using Price vs EMA20 (#31B). + Dynamic H1 higher-timeframe bias using multi-indicator scoring + regime-based weights. Returns: "BULLISH", "BEARISH", or "NEUTRAL" - Logic (#31B: backtest +$343, WR 81.8%, Sharpe 3.97, DD 2.5%): - - Fetch H1 data (100 bars) - - Calculate EMA20 on H1 closes - - If price > EMA20 * 1.001 → BULLISH (allow BUY only) - - If price < EMA20 * 0.999 → BEARISH (allow SELL only) + Uses 5 indicators with regime-adaptive weights: + 1. EMA Trend (price vs EMA21) + 2. EMA Cross (EMA9 vs EMA21) + 3. RSI Zone (>55 bull, <45 bear) + 4. MACD Histogram + 5. Candle Structure (last 5 candles) + + Weights adjust based on HMM regime (trending/ranging/volatile). + Score range: -1.0 (max bearish) to +1.0 (max bullish). + Threshold: ±0.3 (30% agreement needed). """ try: # Cache H1 bias — only update every 4 candles (1 hour) since H1 changes slowly @@ -795,7 +959,7 @@ class TradingBot: count=100, ) - if len(df_h1) < 20: + if len(df_h1) < 30: return "NEUTRAL" # Calculate indicators + SMC on H1 and cache for V2 features @@ -803,40 +967,132 @@ class TradingBot: df_h1 = self.smc.calculate_all(df_h1) self._h1_df_cached = df_h1 # Cache for V2 features - # #31B: Price vs EMA20 method (backtested winner) - import numpy as np - closes = df_h1["close"].to_list() - current_price = closes[-1] + # Extract latest values + last = df_h1.row(-1, named=True) + price = last["close"] + ema_9 = last["ema_9"] + ema_21 = last["ema_21"] + rsi = last["rsi"] + macd_hist = last["macd_histogram"] - # Calculate EMA20 - period = 20 - multiplier = 2 / (period + 1) - ema = np.mean(closes[:period]) - for val in closes[period:]: - ema = (val - ema) * multiplier + ema + # === 5 Indicator Signals (+1, -1, 0) === + signals = { + "ema_trend": 1 if price > ema_21 else (-1 if price < ema_21 else 0), + "ema_cross": 1 if ema_9 > ema_21 else (-1 if ema_9 < ema_21 else 0), + "rsi": 1 if rsi > 55 else (-1 if rsi < 45 else 0), + "macd": 1 if macd_hist > 0 else (-1 if macd_hist < 0 else 0), + "candles": self._count_candle_bias(df_h1), + } - # Determine bias with small buffer (0.1% threshold) - bias = "NEUTRAL" - if current_price > ema * 1.001: + # === Regime-Based Weights === + weights = self._get_regime_weights() + + # === Weighted Score === + score = sum(signals[k] * weights[k] for k in signals) + + # === Dynamic Threshold === + if score >= 0.3: bias = "BULLISH" - elif current_price < ema * 0.999: + elif score <= -0.3: bias = "BEARISH" + else: + bias = "NEUTRAL" - # Cache result + # === Determine Strength === + abs_score = abs(score) + if abs_score >= 0.7: + strength = "strong" + elif abs_score >= 0.5: + strength = "moderate" + else: + strength = "weak" + + # === Cache Results === self._h1_bias_cache = bias self._h1_bias_loop = self._loop_count - self._h1_ema20_value = float(ema) - self._h1_current_price = float(current_price) + self._h1_bias_score = float(score) + self._h1_bias_strength = strength + self._h1_bias_signals = signals.copy() + _regime_str = self._last_regime.value if hasattr(self, '_last_regime') and self._last_regime else "unknown" + self._h1_bias_regime_weights = _regime_str + self._h1_current_price = float(price) + # Keep EMA20 for backward compatibility (use EMA21 as proxy) + self._h1_ema20_value = float(ema_21) if self._loop_count % 4 == 0: - logger.info(f"H1 Bias: {bias} (price={current_price:.2f}, EMA20={ema:.2f})") + logger.info( + f"H1 Bias: {bias} ({strength}, score={score:.2f}) | " + f"Signals: EMA_trend={signals['ema_trend']:+d}, EMA_cross={signals['ema_cross']:+d}, " + f"RSI={signals['rsi']:+d}, MACD={signals['macd']:+d}, Candles={signals['candles']:+d} | " + f"Regime: {_regime_str}" + ) return bias except Exception as e: - logger.debug(f"H1 bias error: {e}") + logger.debug(f"H1 dynamic bias error: {e}") return "NEUTRAL" + def _count_candle_bias(self, df_h1) -> int: + """ + Count bullish/bearish candles in last 5 H1 candles. + Returns: +1 if majority bullish (≥3), -1 if majority bearish (≥3), 0 otherwise. + """ + try: + last_5 = df_h1.tail(5) + bullish = sum(1 for row in last_5.iter_rows(named=True) if row["close"] > row["open"]) + bearish = 5 - bullish + + if bullish >= 3: + return 1 + elif bearish >= 3: + return -1 + else: + return 0 + except Exception: + return 0 + + def _get_regime_weights(self) -> dict: + """ + Get indicator weights based on current HMM regime. + + Regimes: + - Low volatility (ranging): RSI/MACD dominate (mean-reversion) + - Medium volatility: Balanced + - High volatility (trending): EMA trend/cross dominate + + Returns: dict with keys matching signals (ema_trend, ema_cross, rsi, macd, candles) + """ + regime = (self._last_regime.value if hasattr(self, '_last_regime') and self._last_regime else "medium_volatility").lower() + + if "low" in regime or "ranging" in regime: + # Low volatility / ranging — RSI and MACD more useful + return { + "ema_trend": 0.15, + "ema_cross": 0.15, + "rsi": 0.30, + "macd": 0.25, + "candles": 0.15, + } + elif "high" in regime or "trending" in regime: + # High volatility / trending — EMA trend dominates + return { + "ema_trend": 0.30, + "ema_cross": 0.25, + "rsi": 0.10, + "macd": 0.25, + "candles": 0.10, + } + else: + # Medium volatility — balanced weights + return { + "ema_trend": 0.25, + "ema_cross": 0.20, + "rsi": 0.20, + "macd": 0.20, + "candles": 0.15, + } + def _is_filter_enabled(self, filter_key: str) -> bool: """ Check if a filter is enabled via filter_config.json. @@ -1016,9 +1272,172 @@ class TradingBot: ml_prediction=ml_prediction, current_price=current_price, ) + # --- PYRAMID CHECK: Add to Winner when trade 1 is in profit --- + if len(open_positions) > 0 and not self.simulation: + await self._check_pyramid_opportunity(open_positions, current_price) + except Exception as e: logger.debug(f"Position check error: {e}") - + + async def _check_pyramid_opportunity(self, open_positions, current_price: float): + """ + Add to Winner (Pyramiding): Buka trade ke-2 saat trade pertama sudah profit. + + Rules: + 1. Trade pertama harus profit >= $8 (ATR-scaled) + 2. Ticket belum pernah trigger pyramid sebelumnya + 3. SMC signal >= 75% sama arah + 4. ML prediction setuju sama arah + 5. Session harus London atau New York (high liquidity) + 6. Max 2 posisi concurrent + 7. Cooldown 30 detik antar pyramid + 8. Lot size sama dengan trade pertama + """ + try: + # Cooldown check: minimal 30 detik antar pyramid + if self._last_pyramid_time: + seconds_since = (datetime.now() - self._last_pyramid_time).total_seconds() + if seconds_since < 30: + return + + # Position limit check + can_open, limit_reason = self.smart_risk.can_open_position() + if not can_open: + return + + # Session check: only London and New York (high liquidity for pyramiding) + session_info = self.session_filter.get_status_report() + session_name = session_info.get("current_session", "Unknown") + if session_name not in ("London", "New York", "London-NY Overlap"): + return + + # Get cached signals + cached_smc_signal = getattr(self, '_last_raw_smc_signal', '') + cached_smc_conf = getattr(self, '_last_raw_smc_confidence', 0.0) + cached_ml = getattr(self, '_cached_ml_prediction', None) + + if not cached_smc_signal or not cached_ml: + return + + # ATR scaling for profit threshold + _current_atr = 0.0 + _baseline_atr = 0.0 + cached_df = getattr(self, '_cached_df', None) + if cached_df is not None and "atr" in cached_df.columns: + atr_series = cached_df["atr"].drop_nulls() + if len(atr_series) > 0: + _current_atr = atr_series.tail(1).item() or 0 + if len(atr_series) >= 96: + _baseline_atr = atr_series.tail(96).mean() + elif len(atr_series) >= 20: + _baseline_atr = atr_series.mean() + + # Check each open position for pyramid opportunity + for row in open_positions.iter_rows(named=True): + ticket = row["ticket"] + profit = row.get("profit", 0) + position_type = row.get("type", 0) # 0=BUY, 1=SELL + direction = "BUY" if position_type == 0 else "SELL" + lot_size = row.get("volume", 0.01) + + # Skip if already triggered pyramid + if ticket in self._pyramid_done_tickets: + continue + + # ATR-based profit threshold (per-position, adapts to lot size) + atr_dollars = _current_atr * lot_size * 100 if _current_atr > 0 else 0 + sm = max(0.3, min(1.5, _current_atr / _baseline_atr)) if _baseline_atr > 0 else 1.0 + atr_unit = atr_dollars if atr_dollars > 0 else 10 * sm + min_profit_for_pyramid = 0.5 * atr_unit # 0.5 ATR — same as tp_min + + # Trade must be profitable enough + if profit < min_profit_for_pyramid: + continue + + # Check velocity is positive (trade still moving in our favor) + guard = self.smart_risk._position_guards.get(ticket) + if guard and guard.velocity <= 0: + continue # Don't pyramid into a stalling trade + + # SMC signal must match direction with >= 75% confidence + if cached_smc_signal != direction or cached_smc_conf < 0.75: + continue + + # ML must agree with direction + if cached_ml.signal != direction: + continue + + # All conditions passed — execute pyramid trade + logger.info(f"[PYRAMID] Conditions met for #{ticket}: profit=${profit:.2f}, " + f"SMC={cached_smc_signal}({cached_smc_conf:.0%}), ML={cached_ml.signal}({cached_ml.confidence:.0%})") + + # Build signal from cached data + last_signal = getattr(self, '_last_signal', None) + if not last_signal: + logger.debug("[PYRAMID] No cached signal available") + continue + + # Create fresh SMC signal for pyramid entry + tick = self.mt5.get_tick(self.config.symbol) + if not tick: + continue + + entry_price = tick.ask if direction == "BUY" else tick.bid + + # Use cached signal's SL/TP structure but adjust entry to current price + pyramid_signal = SMCSignal( + signal_type=direction, + entry_price=entry_price, + stop_loss=last_signal.stop_loss, + take_profit=last_signal.take_profit, + confidence=cached_smc_conf, + reason=f"PYRAMID: Add to winner #{ticket} (profit=${profit:.2f})", + ) + + # Use same lot size as original trade + sl_distance = abs(entry_price - pyramid_signal.stop_loss) + risk_amount = lot_size * sl_distance * 10 + account_balance = self.mt5.account_balance or self.config.capital + risk_percent = (risk_amount / account_balance) * 100 + + pyramid_pos = SimpleNamespace( + lot_size=lot_size, # Same lot as original + risk_amount=risk_amount, + risk_percent=risk_percent, + ) + + # Get regime state for execution + cached_regime = None + if hasattr(self, '_last_regime') and self._last_regime: + cached_regime = RegimeState( + regime=self._last_regime, + volatility=getattr(self, '_last_regime_volatility', 0.0), + confidence=getattr(self, '_last_regime_confidence', 0.0), + probabilities={}, + recommendation="TRADE", + ) + + # Execute pyramid trade + logger.info(f"[PYRAMID] Opening {direction} {lot_size} lot @ {entry_price:.2f} " + f"(adding to winner #{ticket})") + + trade_time_before = self._last_trade_time + await self._execute_trade_safe(pyramid_signal, pyramid_pos, cached_regime) + + # Only mark as done if trade was actually executed (trade_time updates on success) + if self._last_trade_time != trade_time_before: + self._pyramid_done_tickets.add(ticket) + self._last_pyramid_time = datetime.now() + self._dash_log("trade", f"PYRAMID: {direction} {lot_size} lot (adding to #{ticket}, profit=${profit:.2f})") + else: + logger.warning(f"[PYRAMID] Trade execution failed for #{ticket}, will retry next cycle") + + # Only one pyramid per check cycle + break + + except Exception as e: + logger.debug(f"Pyramid check error: {e}") + async def _trading_iteration(self): """Single trading iteration.""" # Reset filter tracking for dashboard @@ -1044,6 +1463,10 @@ class TradingBot: # 3. Apply SMC analysis df = self.smc.calculate_all(df) + # 3a. Ensure H1 data is cached BEFORE V2 features (fixes "No H1 data" warning) + if self._h1_df_cached is None: + self._get_h1_bias() + # 3b. Add V2 features for Model D (23 extra features) df = self.fe_v2.add_all_v2_features(df, self._h1_df_cached) @@ -1113,6 +1536,24 @@ class TradingBot: self._cached_ml_prediction = ml_prediction self._cached_df = df + # Cache SMC signal for dashboard (runs before filters so dashboard always updates) + smc_signal = self.smc.generate_signal(df) + _wib_now = datetime.now(ZoneInfo("Asia/Jakarta")).strftime("%H:%M:%S") + if smc_signal: + self._last_raw_smc_signal = smc_signal.signal_type + self._last_raw_smc_confidence = smc_signal.confidence + self._last_raw_smc_reason = smc_signal.reason + self._last_raw_smc_updated = _wib_now + self._dash_log("trade", f"SMC: {smc_signal.signal_type} ({smc_signal.confidence:.0%}) - {smc_signal.reason}") + else: + self._last_raw_smc_signal = "" + self._last_raw_smc_confidence = 0.0 + self._last_raw_smc_reason = "" + self._last_raw_smc_updated = _wib_now + + # H1 Multi-Timeframe Bias (runs before filters so dashboard always updates) + h1_bias = self._get_h1_bias() + # 6.5 SMART POSITION MANAGEMENT - NO HARD STOP LOSS # Hanya close jika: TP tercapai, ML reversal kuat, atau max loss if len(open_positions) > 0: @@ -1195,26 +1636,9 @@ class TradingBot: # 7.6 NEWS AGENT - DISABLED (backtest: costs $178 profit, ML handles volatility) - # 7.7 H1 Multi-Timeframe Bias (Fix 5) - # Fetch H1 data and determine higher-TF bias for M15 signal filtering - h1_bias = self._get_h1_bias() + # 7.7 H1 bias already calculated above (before filters, for dashboard) - # 8. Get SMC signal - smc_signal = self.smc.generate_signal(df) - - # Cache raw SMC for dashboard (before filtering) - _wib_now = datetime.now(ZoneInfo("Asia/Jakarta")).strftime("%H:%M:%S") - if smc_signal: - self._last_raw_smc_signal = smc_signal.signal_type - self._last_raw_smc_confidence = smc_signal.confidence - self._last_raw_smc_reason = smc_signal.reason - self._last_raw_smc_updated = _wib_now - self._dash_log("trade", f"SMC: {smc_signal.signal_type} ({smc_signal.confidence:.0%}) - {smc_signal.reason}") - else: - self._last_raw_smc_signal = "" - self._last_raw_smc_confidence = 0.0 - self._last_raw_smc_reason = "" - self._last_raw_smc_updated = _wib_now + # 8. SMC signal already generated above (before filters, for dashboard) # 9. ML prediction already done above for position management @@ -1251,22 +1675,36 @@ class TradingBot: h1_detail = f"H1={h1_bias}" if h1_enabled: - if h1_bias != "NEUTRAL": - if (final_signal.signal_type == "BUY" and h1_bias != "BULLISH") or \ - (final_signal.signal_type == "SELL" and h1_bias != "BEARISH"): + h1_opposed = False + if h1_bias == "NEUTRAL": + # NEUTRAL = no opinion → allow trade (don't block) + logger.debug(f"H1 Filter: NEUTRAL — no H1 opinion, allowing {final_signal.signal_type}") + elif (final_signal.signal_type == "BUY" and h1_bias == "BEARISH") or \ + (final_signal.signal_type == "SELL" and h1_bias == "BULLISH"): + # Actively opposed — block unless strong override + h1_opposed = True + else: + logger.info(f"H1 Filter: {final_signal.signal_type} aligned with H1={h1_bias}") + + if h1_opposed: + # Strong signal override: if SMC >= 80% AND ML agrees >= 65%, bypass H1 + smc_strong = smc_signal and smc_signal.confidence >= 0.80 + ml_agrees = ml_prediction and ml_prediction.signal == final_signal.signal_type + ml_strong = ml_prediction and ml_prediction.confidence >= 0.65 + + if smc_strong and ml_agrees and ml_strong: + h1_passed = True + h1_detail = f"OVERRIDE: {final_signal.signal_type} vs H1={h1_bias} (SMC={smc_signal.confidence:.0%}+ML={ml_prediction.confidence:.0%})" + logger.info(f"H1 Filter: OVERRIDE — {final_signal.signal_type} allowed despite H1={h1_bias} (SMC={smc_signal.confidence:.0%}, ML={ml_prediction.signal} {ml_prediction.confidence:.0%})") + self._last_filter_results.append({"name": "H1 Bias (#31B)", "passed": True, "detail": h1_detail}) + else: h1_passed = False h1_detail = f"{final_signal.signal_type} vs H1={h1_bias}" self._last_filter_results.append({"name": "H1 Bias (#31B)", "passed": False, "detail": h1_detail}) logger.info(f"H1 Filter: {final_signal.signal_type} blocked (H1={h1_bias})") return - logger.info(f"H1 Filter: {final_signal.signal_type} aligned with H1={h1_bias}") else: - h1_passed = False - h1_detail = f"{final_signal.signal_type} blocked (NEUTRAL)" - self._last_filter_results.append({"name": "H1 Bias (#31B)", "passed": False, "detail": h1_detail}) - logger.info(f"H1 Filter: {final_signal.signal_type} blocked (H1=NEUTRAL)") - return - self._last_filter_results.append({"name": "H1 Bias (#31B)", "passed": True, "detail": f"Aligned {h1_bias}"}) + self._last_filter_results.append({"name": "H1 Bias (#31B)", "passed": True, "detail": f"Aligned {h1_bias}"}) else: self._last_filter_results.append({"name": "H1 Bias (#31B)", "passed": True, "detail": f"H1={h1_bias} [DISABLED]"}) @@ -1274,17 +1712,39 @@ class TradingBot: # Hour 9 WIB (02:00 UTC) = end of NY session, low liquidity # Hour 21 WIB (14:00 UTC) = London-NY transition, whipsaw prone wib_hour = datetime.now(ZoneInfo("Asia/Jakarta")).hour - time_blocked = wib_hour in (9, 21) + time_blocked = False # All hours enabled — risk managed by ATR scaling + lot multiplier time_enabled = self._is_filter_enabled("time_filter") time_filter_blocked = time_blocked and time_enabled + + # v6.1: NIGHT SAFETY - Spread filter for late night hours (22:00-05:59 WIB) + is_night_hours = wib_hour >= 22 or wib_hour <= 5 + night_spread_ok = True + night_spread_msg = "" + if is_night_hours: + # Get current spread + tick = self.mt5.get_tick(self.config.symbol) + if tick: + current_spread_points = (tick.ask - tick.bid) / 0.01 # Spread in points (0.01 = 1 pip for gold) + # Normal max spread: 30 points ($0.30) + # Night max spread: 50 points ($0.50) - allow wider spread but still filter extremes + night_max_spread = 50 + if current_spread_points > night_max_spread: + night_spread_ok = False + night_spread_msg = f"spread {current_spread_points:.1f}p > {night_max_spread}p" + else: + night_spread_msg = f"spread {current_spread_points:.1f}p OK (night limit {night_max_spread}p)" + self._last_filter_results.append({ "name": "Time Filter (#34A)", - "passed": not time_filter_blocked, - "detail": f"WIB {wib_hour}" + (" BLOCKED" if time_blocked else "") + (" [DISABLED]" if not time_enabled else "") + "passed": not time_filter_blocked and night_spread_ok, + "detail": f"WIB {wib_hour}" + (" BLOCKED" if time_blocked else "") + (" [DISABLED]" if not time_enabled else "") + (f" NIGHT: {night_spread_msg}" if is_night_hours else "") }) if time_filter_blocked: logger.info(f"Time Filter: {final_signal.signal_type} blocked (WIB hour {wib_hour} is skip hour)") return + if not night_spread_ok: + logger.warning(f"Night Safety: {final_signal.signal_type} blocked - {night_spread_msg} (WIB {wib_hour})") + return # 10.5 Check trade cooldown cooldown_blocked = False @@ -1330,11 +1790,21 @@ class TradingBot: session_mult = getattr(self, '_current_session_multiplier', 1.0) if session_mult < 1.0: original_lot = safe_lot - safe_lot = max(0.01, safe_lot * session_mult) # Minimum 0.01 + safe_lot = max(0.01, round(safe_lot * session_mult, 2)) # Minimum 0.01, rounded to 0.01 step sydney_mode = getattr(self, '_is_sydney_session', False) if sydney_mode: logger.info(f"Sydney SAFE MODE: Lot {original_lot:.2f} -> {safe_lot:.2f} (0.5x)") + # v6.1: NIGHT SAFETY - Lot reduction for late night hours (22:00-05:59 WIB) + # Night trading has lower win rate (14%) and higher loss risk + # Reduce lot by 50% to minimize damage from night volatility + wib_hour = datetime.now(ZoneInfo("Asia/Jakarta")).hour + is_night_hours = wib_hour >= 22 or wib_hour <= 5 + if is_night_hours: + original_lot = safe_lot + safe_lot = max(0.01, round(safe_lot * 0.5, 2)) # 50% reduction + logger.warning(f"NIGHT SAFETY MODE: Lot {original_lot:.2f} -> {safe_lot:.2f} (0.5x) - WIB {wib_hour}:xx (high risk hours)") + if safe_lot <= 0: logger.debug("Smart Risk: Lot size is 0 - skipping trade") return @@ -1450,6 +1920,14 @@ class TradingBot: (smc_signal.signal_type == "SELL" and ml_prediction.signal == "SELL") ) + # SELL-SPECIFIC CONFIDENCE FILTER (Step 4: Improve 41.2% win rate) + # Require ML confidence >= 0.75 for SELL signals to filter weak trades + if smc_signal.signal_type == "SELL": + if ml_prediction.signal != "SELL" or ml_prediction.confidence < 0.75: + if self._loop_count % 60 == 0: + logger.info(f"SELL blocked: ML confidence too low ({ml_prediction.signal} {ml_prediction.confidence:.0%}, need SELL >=75%)") + return None + if ml_agrees: combined_confidence = (smc_signal.confidence + ml_prediction.confidence) / 2 reason_suffix = f" | ML AGREES: {ml_prediction.signal} ({ml_prediction.confidence:.0%})" @@ -1882,6 +2360,24 @@ class TradingBot: 1. SmartRiskManager: TP, ML reversal, max loss, daily limit 2. SmartPositionManager: Trailing SL, breakeven, market close, drawdown protection """ + # Sync guards with MT5 — remove guards for positions that no longer exist + # IMPORTANT: Use FRESH MT5 call, not stale open_positions parameter + # (open_positions may not include positions opened during this loop iteration) + try: + fresh_mt5 = self.mt5.get_open_positions( + symbol=self.config.symbol, + magic=self.config.magic_number, + ) + mt5_tickets = set() + if fresh_mt5 is not None and not fresh_mt5.is_empty(): + mt5_tickets = set(fresh_mt5["ticket"].to_list()) + stale = set(self.smart_risk._position_guards.keys()) - mt5_tickets + for ticket in stale: + self.smart_risk.unregister_position(ticket) + logger.debug(f"Cleaned stale guard #{ticket}") + except Exception as e: + logger.debug(f"Guard sync error: {e}") + # --- SmartPositionManager: trailing SL, breakeven, market close --- if df is not None and len(df) > 0: pm_actions = self.position_manager.analyze_positions( @@ -1910,6 +2406,7 @@ class TradingBot: risk_result = self.smart_risk.record_trade_result(profit) self.smart_risk.unregister_position(action.ticket) self.position_manager._peak_profits.pop(action.ticket, None) + self._pyramid_done_tickets.discard(action.ticket) # Cleanup pyramid tracking await self.notifications.notify_trade_close_smart(action.ticket, profit, current_price, action.reason) logger.info(f"CLOSED #{action.ticket}: {action.reason}") continue # Skip SmartRiskManager eval for this ticket @@ -1945,7 +2442,30 @@ class TradingBot: current_profit=profit, ) - # Evaluate with smart risk manager + # Calculate ATR for dynamic threshold scaling + _current_atr = 0.0 + _baseline_atr = 0.0 + if df is not None and "atr" in df.columns: + atr_series = df["atr"].drop_nulls() + if len(atr_series) > 0: + _current_atr = atr_series.tail(1).item() or 0 + if len(atr_series) >= 96: # ~24h of M15 data + _baseline_atr = atr_series.tail(96).mean() + elif len(atr_series) >= 20: + _baseline_atr = atr_series.mean() + + # Build market context for dynamic exit intelligence + _market_ctx = None + if df is not None: + _market_ctx = {} + for col in ("rsi", "stoch_k", "adx", "histogram"): + if col in df.columns: + vals = df[col].drop_nulls() + _market_ctx[col if col != "histogram" else "macd_hist"] = ( + vals.tail(1).item() if len(vals) > 0 else None + ) + + # Evaluate with smart risk manager (dynamic thresholds v5) should_close, reason, message = self.smart_risk.evaluate_position( ticket=ticket, current_price=current_price, @@ -1953,6 +2473,9 @@ class TradingBot: ml_signal=ml_prediction.signal, ml_confidence=ml_prediction.confidence, regime=regime_state.regime.value if regime_state else "normal", + current_atr=_current_atr, + baseline_atr=_baseline_atr, + market_context=_market_ctx, ) # Per-ticket momentum log (~every 30 seconds) @@ -1962,11 +2485,13 @@ class TradingBot: if now_ts - guard.last_momentum_log_time >= 30: guard.last_momentum_log_time = now_ts vel_summary = guard.get_velocity_summary() + atr_ratio = _current_atr / _baseline_atr if _baseline_atr > 0 else 1.0 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"ATR={_current_atr:.1f}({atr_ratio:.2f}x) | " f"samples={vel_summary['samples']}" ) @@ -1981,6 +2506,7 @@ class TradingBot: # Record result and check for limit violations risk_result = self.smart_risk.record_trade_result(profit) self.smart_risk.unregister_position(ticket) + self._pyramid_done_tickets.discard(ticket) # Cleanup pyramid tracking # Log trade close for auto-training try: @@ -2063,6 +2589,8 @@ class TradingBot: if result.success: logger.info(f"Closed position {ticket}") closed_count += 1 + self._pyramid_done_tickets.discard(ticket) # Cleanup pyramid tracking + self.smart_risk.unregister_position(ticket) # Cleanup risk tracking # Remove from failed list if was there if ticket in failed_tickets: failed_tickets.remove(ticket) @@ -2187,14 +2715,17 @@ class TradingBot: logger.info(f" HMM: {'OK' if self.regime_detector.fitted else 'FAILED'}") logger.info(f" XGBoost: {'OK' if self.ml_model.fitted else 'FAILED'}") + logger.info(f" Features: {len(self.ml_model.feature_names) if self.ml_model.feature_names else 0}") logger.info(f" Train AUC: {results.get('xgb_train_auc', 0):.4f}") logger.info(f" Test AUC: {results.get('xgb_test_auc', 0):.4f}") + # Update auto_trainer's cached AUC for dashboard + self.auto_trainer._current_auc = results.get("xgb_test_auc", 0) + # Write updated model metrics for dashboard self._write_model_metrics(retrain_results=results) # Check if new model is worse - rollback if needed - # FIX: Increased minimum AUC from 0.52 to 0.60 (0.52 is barely better than random) if results.get("xgb_test_auc", 0) < 0.60: logger.warning("New model AUC too low - rolling back!") self.auto_trainer.rollback_models() @@ -2240,5 +2771,51 @@ async def main(): await bot.stop() +def _acquire_lock(): + """Prevent duplicate bot instances via PID lockfile.""" + lockfile = Path("data/bot.lock") + lockfile.parent.mkdir(exist_ok=True) + + if lockfile.exists(): + try: + old_pid = int(lockfile.read_text().strip()) + # Check if old process is still alive (Windows) + import subprocess + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {old_pid}", "/NH"], + capture_output=True, text=True, timeout=5 + ) + if f"{old_pid}" in result.stdout and "python" in result.stdout.lower(): + logger.error(f"ANOTHER BOT INSTANCE IS RUNNING (PID {old_pid})!") + logger.error("Kill it first: taskkill /F /PID " + str(old_pid)) + sys.exit(1) + else: + logger.info(f"Stale lockfile found (PID {old_pid} not running), removing...") + except (ValueError, Exception) as e: + logger.warning(f"Could not check lockfile: {e}, removing...") + + # Write our PID + lockfile.write_text(str(os.getpid())) + logger.info(f"Bot lockfile acquired: PID {os.getpid()}") + return lockfile + + +def _release_lock(): + """Release PID lockfile.""" + lockfile = Path("data/bot.lock") + try: + if lockfile.exists(): + stored_pid = int(lockfile.read_text().strip()) + if stored_pid == os.getpid(): + lockfile.unlink() + logger.info("Bot lockfile released") + except Exception: + pass + + if __name__ == "__main__": - asyncio.run(main()) + lockfile = _acquire_lock() + try: + asyncio.run(main()) + finally: + _release_lock() diff --git a/src/fuzzy_exit_logic.py b/src/fuzzy_exit_logic.py new file mode 100644 index 0000000..968f4a5 --- /dev/null +++ b/src/fuzzy_exit_logic.py @@ -0,0 +1,400 @@ +""" +Fuzzy Logic Controller for Exit Confidence Aggregation +======================================================= +Combines multiple weak exit signals into a single confidence score (0.0-1.0). + +Current problem: 8 isolated exit checks return True/False, missing weak correlations. +Fuzzy solution: Aggregate velocity, acceleration, profit_retention, RSI, time, etc. +into probabilistic exit decision. + +Input variables (6): +- velocity: $/second (-0.5 to +0.5) +- acceleration: $/s² (-0.01 to +0.01) +- profit_retention: current_profit / peak_profit (0.0-1.2) +- rsi: RSI indicator (0-100) +- time_in_trade: Minutes since entry (0-60+) +- profit_level: profit / tp_target (0.0-2.0) + +Output: +- exit_confidence: 0.0-1.0 (exit if > 0.70, warning if > 0.50) + +Rule base: 30+ fuzzy rules derived from v6 exit logic. + +Author: AI Assistant (Phase 3 - Advanced Exit Strategies) +""" + +import numpy as np +from typing import Optional + +try: + import skfuzzy as fuzz + from skfuzzy import control as ctrl + _SKFUZZY_AVAILABLE = True +except ImportError: + _SKFUZZY_AVAILABLE = False + + +class FuzzyExitController: + """ + Fuzzy logic system for exit confidence calculation. + + Aggregates 6 input variables into exit confidence score. + """ + + def __init__(self): + """Initialize fuzzy control system with rules.""" + if not _SKFUZZY_AVAILABLE: + raise ImportError( + "scikit-fuzzy not installed. Install with: pip install scikit-fuzzy" + ) + + # === INPUT VARIABLES === + self.velocity = ctrl.Antecedent(np.linspace(-0.5, 0.5, 101), 'velocity') + self.acceleration = ctrl.Antecedent(np.linspace(-0.01, 0.01, 101), 'accel') + self.profit_retention = ctrl.Antecedent(np.linspace(0, 1.2, 121), 'retention') + self.rsi = ctrl.Antecedent(np.linspace(0, 100, 101), 'rsi') + self.time_in_trade = ctrl.Antecedent(np.linspace(0, 60, 61), 'time') + self.profit_level = ctrl.Antecedent(np.linspace(0, 2.0, 101), 'profit_lvl') + + # === OUTPUT VARIABLE === + self.exit_confidence = ctrl.Consequent(np.linspace(0, 1, 101), 'exit_conf') + + # === MEMBERSHIP FUNCTIONS === + self._define_membership_functions() + + # === FUZZY RULES === + self.rules = self._create_rule_base() + + # Create control system + self.exit_ctrl = ctrl.ControlSystem(self.rules) + self.simulation = ctrl.ControlSystemSimulation(self.exit_ctrl) + + def _define_membership_functions(self): + """Define membership functions for all variables.""" + + # VELOCITY ($/second) + self.velocity['crashing'] = fuzz.trapmf(self.velocity.universe, [-0.5, -0.5, -0.15, -0.08]) + self.velocity['declining'] = fuzz.trimf(self.velocity.universe, [-0.15, -0.05, 0]) + self.velocity['stalling'] = fuzz.trimf(self.velocity.universe, [-0.03, 0, 0.03]) + self.velocity['growing'] = fuzz.trimf(self.velocity.universe, [0, 0.05, 0.15]) + self.velocity['accelerating'] = fuzz.trapmf(self.velocity.universe, [0.08, 0.15, 0.5, 0.5]) + + # ACCELERATION ($/s²) + self.acceleration['strong_negative'] = fuzz.trapmf(self.acceleration.universe, [-0.01, -0.01, -0.005, -0.002]) + self.acceleration['negative'] = fuzz.trimf(self.acceleration.universe, [-0.005, -0.001, 0]) + self.acceleration['neutral'] = fuzz.trimf(self.acceleration.universe, [-0.001, 0, 0.001]) + self.acceleration['positive'] = fuzz.trimf(self.acceleration.universe, [0, 0.001, 0.005]) + self.acceleration['strong_positive'] = fuzz.trapmf(self.acceleration.universe, [0.002, 0.005, 0.01, 0.01]) + + # PROFIT RETENTION (current / peak) + self.profit_retention['collapsed'] = fuzz.trapmf(self.profit_retention.universe, [0, 0, 0.3, 0.5]) + self.profit_retention['low'] = fuzz.trimf(self.profit_retention.universe, [0.3, 0.5, 0.7]) + self.profit_retention['medium'] = fuzz.trimf(self.profit_retention.universe, [0.6, 0.8, 0.95]) + self.profit_retention['high'] = fuzz.trimf(self.profit_retention.universe, [0.9, 1.0, 1.1]) + self.profit_retention['peak'] = fuzz.trapmf(self.profit_retention.universe, [1.05, 1.15, 1.2, 1.2]) + + # RSI (0-100) + self.rsi['oversold'] = fuzz.trapmf(self.rsi.universe, [0, 0, 20, 30]) + self.rsi['low'] = fuzz.trimf(self.rsi.universe, [20, 35, 45]) + self.rsi['neutral'] = fuzz.trimf(self.rsi.universe, [40, 50, 60]) + self.rsi['high'] = fuzz.trimf(self.rsi.universe, [55, 65, 80]) + self.rsi['overbought'] = fuzz.trapmf(self.rsi.universe, [70, 80, 100, 100]) + + # TIME IN TRADE (minutes) + self.time_in_trade['very_short'] = fuzz.trapmf(self.time_in_trade.universe, [0, 0, 3, 5]) + self.time_in_trade['short'] = fuzz.trimf(self.time_in_trade.universe, [3, 7, 12]) + self.time_in_trade['medium'] = fuzz.trimf(self.time_in_trade.universe, [10, 15, 25]) + self.time_in_trade['long'] = fuzz.trimf(self.time_in_trade.universe, [20, 35, 50]) + self.time_in_trade['very_long'] = fuzz.trapmf(self.time_in_trade.universe, [45, 55, 60, 60]) + + # PROFIT LEVEL (profit / tp_target) + self.profit_level['none'] = fuzz.trapmf(self.profit_level.universe, [0, 0, 0.1, 0.2]) + self.profit_level['small'] = fuzz.trimf(self.profit_level.universe, [0.1, 0.3, 0.5]) + self.profit_level['medium'] = fuzz.trimf(self.profit_level.universe, [0.4, 0.6, 0.8]) + self.profit_level['high'] = fuzz.trimf(self.profit_level.universe, [0.7, 0.9, 1.1]) + self.profit_level['exceeded'] = fuzz.trapmf(self.profit_level.universe, [1.0, 1.2, 2.0, 2.0]) + + # EXIT CONFIDENCE (0-1) + self.exit_confidence['very_low'] = fuzz.trimf(self.exit_confidence.universe, [0, 0, 0.25]) + self.exit_confidence['low'] = fuzz.trimf(self.exit_confidence.universe, [0.1, 0.3, 0.5]) + self.exit_confidence['medium'] = fuzz.trimf(self.exit_confidence.universe, [0.4, 0.6, 0.75]) + self.exit_confidence['high'] = fuzz.trimf(self.exit_confidence.universe, [0.65, 0.8, 0.95]) + self.exit_confidence['very_high'] = fuzz.trapmf(self.exit_confidence.universe, [0.85, 0.95, 1.0, 1.0]) + + def _create_rule_base(self): + """Create 30+ fuzzy rules for exit decisions.""" + rules = [] + + # === VELOCITY-BASED RULES (highest priority) === + # Rule 1: Crashing velocity = immediate exit + rules.append(ctrl.Rule( + self.velocity['crashing'], + self.exit_confidence['very_high'] + )) + + # Rule 2: Declining velocity + negative acceleration = high exit + rules.append(ctrl.Rule( + self.velocity['declining'] & self.acceleration['negative'], + self.exit_confidence['high'] + )) + + # Rule 3: Declining velocity + collapsed retention = very high exit + rules.append(ctrl.Rule( + self.velocity['declining'] & self.profit_retention['collapsed'], + self.exit_confidence['very_high'] + )) + + # Rule 4: Stalling velocity + low retention = medium exit + rules.append(ctrl.Rule( + self.velocity['stalling'] & self.profit_retention['low'], + self.exit_confidence['medium'] + )) + + # Rule 5: Stalling velocity + long time = high exit + rules.append(ctrl.Rule( + self.velocity['stalling'] & self.time_in_trade['long'], + self.exit_confidence['high'] + )) + + # === ACCELERATION-BASED RULES === + # Rule 6: Strong negative accel + medium profit = high exit + rules.append(ctrl.Rule( + self.acceleration['strong_negative'] & self.profit_level['medium'], + self.exit_confidence['high'] + )) + + # Rule 7: Negative accel + declining velocity = high exit + rules.append(ctrl.Rule( + self.acceleration['negative'] & self.velocity['declining'], + self.exit_confidence['high'] + )) + + # === PROFIT RETENTION RULES === + # Rule 8: Collapsed retention (regardless of velocity) = very high exit + rules.append(ctrl.Rule( + self.profit_retention['collapsed'], + self.exit_confidence['very_high'] + )) + + # Rule 9: Low retention + stalling velocity = high exit + rules.append(ctrl.Rule( + self.profit_retention['low'] & self.velocity['stalling'], + self.exit_confidence['high'] + )) + + # Rule 10: Low retention + medium time = medium exit + rules.append(ctrl.Rule( + self.profit_retention['low'] & self.time_in_trade['medium'], + self.exit_confidence['medium'] + )) + + # === RSI REVERSAL RULES (position-dependent) === + # Rule 11: Oversold RSI + high profit (SELL position exiting at support) = high exit + rules.append(ctrl.Rule( + self.rsi['oversold'] & self.profit_retention['high'], + self.exit_confidence['high'] + )) + + # Rule 12: Overbought RSI + high profit (BUY position exiting at resistance) = high exit + rules.append(ctrl.Rule( + self.rsi['overbought'] & self.profit_retention['high'], + self.exit_confidence['high'] + )) + + # Rule 13: Oversold RSI + low retention (SELL position, price bouncing) = medium exit + rules.append(ctrl.Rule( + self.rsi['oversold'] & self.profit_retention['low'], + self.exit_confidence['medium'] + )) + + # === TIME-BASED RULES === + # Rule 14: Very long time + stalling velocity = high exit (trade exhausted) + rules.append(ctrl.Rule( + self.time_in_trade['very_long'] & self.velocity['stalling'], + self.exit_confidence['high'] + )) + + # Rule 15: Long time + low retention = high exit + rules.append(ctrl.Rule( + self.time_in_trade['long'] & self.profit_retention['low'], + self.exit_confidence['high'] + )) + + # Rule 16: Medium time + collapsed retention = very high exit + rules.append(ctrl.Rule( + self.time_in_trade['medium'] & self.profit_retention['collapsed'], + self.exit_confidence['very_high'] + )) + + # === PROFIT LEVEL RULES === + # Rule 17: Exceeded profit + declining velocity = high exit (take profit) + rules.append(ctrl.Rule( + self.profit_level['exceeded'] & self.velocity['declining'], + self.exit_confidence['high'] + )) + + # Rule 18: High profit + stalling velocity = medium exit + rules.append(ctrl.Rule( + self.profit_level['high'] & self.velocity['stalling'], + self.exit_confidence['medium'] + )) + + # Rule 19: High profit + strong negative accel = high exit + rules.append(ctrl.Rule( + self.profit_level['high'] & self.acceleration['strong_negative'], + self.exit_confidence['high'] + )) + + # === POSITIVE SCENARIOS (low exit confidence) === + # Rule 20: Growing velocity + high retention = very low exit (hold) + rules.append(ctrl.Rule( + self.velocity['growing'] & self.profit_retention['high'], + self.exit_confidence['very_low'] + )) + + # Rule 21: Accelerating velocity + positive accel = very low exit (strong trend) + rules.append(ctrl.Rule( + self.velocity['accelerating'] & self.acceleration['positive'], + self.exit_confidence['very_low'] + )) + + # Rule 22: Peak retention + growing velocity = very low exit (at new high) + rules.append(ctrl.Rule( + self.profit_retention['peak'] & self.velocity['growing'], + self.exit_confidence['very_low'] + )) + + # === COMBINATION RULES (weak signals together) === + # Rule 23: Stalling + neutral accel + medium retention + long time = medium exit + rules.append(ctrl.Rule( + self.velocity['stalling'] & self.acceleration['neutral'] & + self.profit_retention['medium'] & self.time_in_trade['long'], + self.exit_confidence['medium'] + )) + + # Rule 24: Declining + negative accel + low retention = very high exit (triple threat) + rules.append(ctrl.Rule( + self.velocity['declining'] & self.acceleration['negative'] & + self.profit_retention['low'], + self.exit_confidence['very_high'] + )) + + # Rule 25: Small profit + very long time + stalling = high exit (cut losses) + rules.append(ctrl.Rule( + self.profit_level['small'] & self.time_in_trade['very_long'] & + self.velocity['stalling'], + self.exit_confidence['high'] + )) + + # === EARLY EXIT RULES (prevent holding too long) === + # Rule 26: Medium profit + declining + long time = high exit + rules.append(ctrl.Rule( + self.profit_level['medium'] & self.velocity['declining'] & + self.time_in_trade['long'], + self.exit_confidence['high'] + )) + + # Rule 27: High profit + low retention + declining = very high exit (protect gains) + rules.append(ctrl.Rule( + self.profit_level['high'] & self.profit_retention['low'] & + self.velocity['declining'], + self.exit_confidence['very_high'] + )) + + # === DEFENSIVE RULES (prevent premature exit) === + # Rule 28: Short time + growing velocity = very low exit (give time to develop) + rules.append(ctrl.Rule( + self.time_in_trade['short'] & self.velocity['growing'], + self.exit_confidence['very_low'] + )) + + # Rule 29: Very short time + high retention = very low exit (just started) + rules.append(ctrl.Rule( + self.time_in_trade['very_short'] & self.profit_retention['high'], + self.exit_confidence['very_low'] + )) + + # Rule 30: Medium profit + accelerating velocity = low exit (let it run) + rules.append(ctrl.Rule( + self.profit_level['medium'] & self.velocity['accelerating'], + self.exit_confidence['low'] + )) + + return rules + + def evaluate( + self, + velocity: float, + acceleration: float, + profit_retention: float, + rsi: float, + time_in_trade: float, + profit_level: float, + ) -> float: + """ + Evaluate exit confidence for current trade state. + + Args: + velocity: Profit velocity ($/second) + acceleration: Profit acceleration ($/s²) + profit_retention: current_profit / peak_profit + rsi: RSI indicator (0-100) + time_in_trade: Minutes since entry + profit_level: profit / tp_target + + Returns: + Exit confidence (0.0-1.0) + > 0.75: High confidence, exit now + 0.50-0.75: Medium confidence, warning + < 0.50: Low confidence, hold + """ + # Clamp inputs to universe ranges + velocity = np.clip(velocity, -0.5, 0.5) + acceleration = np.clip(acceleration, -0.01, 0.01) + profit_retention = np.clip(profit_retention, 0, 1.2) + rsi = np.clip(rsi, 0, 100) + time_in_trade = np.clip(time_in_trade, 0, 60) + profit_level = np.clip(profit_level, 0, 2.0) + + # Set inputs + self.simulation.input['velocity'] = velocity + self.simulation.input['accel'] = acceleration + self.simulation.input['retention'] = profit_retention + self.simulation.input['rsi'] = rsi + self.simulation.input['time'] = time_in_trade + self.simulation.input['profit_lvl'] = profit_level + + # Compute output + try: + self.simulation.compute() + return float(self.simulation.output['exit_conf']) + except Exception as e: + # Fallback: if fuzzy system fails, return conservative confidence + # (likely due to no rules firing) + return 0.3 + + def visualize(self, variable_name: str): + """ + Visualize membership functions for a variable. + + Args: + variable_name: 'velocity', 'accel', 'retention', 'rsi', 'time', 'profit_lvl', 'exit_conf' + """ + import matplotlib.pyplot as plt + + var_map = { + 'velocity': self.velocity, + 'accel': self.acceleration, + 'retention': self.profit_retention, + 'rsi': self.rsi, + 'time': self.time_in_trade, + 'profit_lvl': self.profit_level, + 'exit_conf': self.exit_confidence, + } + + if variable_name not in var_map: + raise ValueError(f"Unknown variable: {variable_name}") + + var = var_map[variable_name] + var.view() + plt.show() diff --git a/src/kalman_filter.py b/src/kalman_filter.py new file mode 100644 index 0000000..3f39f80 --- /dev/null +++ b/src/kalman_filter.py @@ -0,0 +1,127 @@ +""" +Kalman Filter for Profit Velocity Smoothing +============================================ +Constant-velocity Kalman filter that smooths noisy profit readings +and produces filtered velocity + acceleration estimates. + +State vector: [profit, velocity] +Observation: [profit] (direct measurement) + +Tuning: + - process_noise_velocity=0.01: smooth velocity strongly (suppress single-sample spikes) + - measurement_noise=0.25: XAUUSD bid/ask noise for 0.01 lot (~$0.25 per tick) + - Responds to genuine reversal within 2-3 samples (10-15s) while ignoring noise + +Author: AI Assistant +""" + +import time + +try: + from filterpy.kalman import KalmanFilter + from filterpy.common import Q_continuous_white_noise + import numpy as np + _FILTERPY_AVAILABLE = True +except ImportError: + _FILTERPY_AVAILABLE = False + + +class ProfitKalmanFilter: + """ + Kalman filter for profit time series. + + Tracks [profit, velocity] state with constant-velocity dynamics. + F matrix updated per call with actual time delta for accuracy. + """ + + def __init__( + self, + process_noise_velocity: float = 0.01, + measurement_noise: float = 0.25, + ): + if not _FILTERPY_AVAILABLE: + raise ImportError( + "filterpy not installed. Install with: pip install filterpy" + ) + + self._process_noise_vel = process_noise_velocity + self._measurement_noise = measurement_noise + self._last_time: float = 0.0 + self._initialized: bool = False + self._prev_velocity: float = 0.0 + + # Create 2D Kalman filter: state = [profit, velocity] + self._kf = KalmanFilter(dim_x=2, dim_z=1) + + # Observation matrix: we observe profit directly + self._kf.H = np.array([[1.0, 0.0]]) + + # Measurement noise + self._kf.R = np.array([[self._measurement_noise]]) + + # Initial state covariance (high uncertainty) + self._kf.P = np.array([ + [1.0, 0.0], + [0.0, 1.0], + ]) + + def update(self, profit: float, timestamp: float = 0.0) -> tuple: + """ + Feed a new profit observation and get filtered estimates. + + Args: + profit: Current profit in USD + timestamp: time.time() value (0 = use current time) + + Returns: + (filtered_profit, filtered_velocity, acceleration) + """ + now = timestamp if timestamp > 0 else time.time() + + if not self._initialized: + # First observation: initialize state + self._kf.x = np.array([[profit], [0.0]]) + self._last_time = now + self._initialized = True + return profit, 0.0, 0.0 + + # Time delta since last update + dt = now - self._last_time + if dt <= 0: + dt = 1.0 # Fallback: assume 1 second + self._last_time = now + + # Update F matrix (state transition) with actual dt + self._kf.F = np.array([ + [1.0, dt], + [0.0, 1.0], + ]) + + # Update Q matrix (process noise) scaled by dt + self._kf.Q = Q_continuous_white_noise( + dim=2, dt=dt, spectral_density=self._process_noise_vel + ) + + # Predict + update + self._kf.predict() + self._kf.update(np.array([[profit]])) + + # Extract filtered state + filtered_profit = float(self._kf.x[0, 0]) + filtered_velocity = float(self._kf.x[1, 0]) + + # Calculate acceleration from velocity change + acceleration = (filtered_velocity - self._prev_velocity) / dt if dt > 0 else 0.0 + self._prev_velocity = filtered_velocity + + return filtered_profit, filtered_velocity, acceleration + + def reset(self): + """Reset filter state (e.g., for new trade).""" + self._initialized = False + self._last_time = 0.0 + self._prev_velocity = 0.0 + self._kf.P = np.array([ + [1.0, 0.0], + [0.0, 1.0], + ]) diff --git a/src/kelly_position_scaler.py b/src/kelly_position_scaler.py new file mode 100644 index 0000000..c934b4a --- /dev/null +++ b/src/kelly_position_scaler.py @@ -0,0 +1,200 @@ +""" +Kelly Criterion for Dynamic Position Scaling +============================================= +Optimal position sizing based on win probability and payoff ratio. + +Kelly Formula: + f* = (p × b - q) / b + where: + p = win probability + q = loss probability (1 - p) + b = win/loss ratio (avg_win / avg_loss) + +Application: +- Partial exits when exit_confidence is medium (0.50-0.70) +- Scale position down if Kelly fraction suggests reducing exposure +- Full exit if Kelly fraction < 0.3 + +Integration with Fuzzy Logic: +- High exit_confidence (>0.75) → adjust win probability down → Kelly suggests reduce +- Low exit_confidence (<0.50) → maintain position → Kelly suggests hold + +Author: AI Assistant (Phase 6 - Advanced Exit Strategies) +""" + +import numpy as np +from typing import Tuple, Optional +from loguru import logger + + +class KellyPositionScaler: + """ + Kelly criterion calculator for position scaling. + + Dynamically adjusts position size based on: + - Exit confidence (from fuzzy logic) + - Trade statistics (win rate, avg win/loss) + """ + + def __init__( + self, + base_win_rate: float = 0.55, + avg_win: float = 8.0, + avg_loss: float = 4.0, + kelly_fraction: float = 0.5, + ): + """ + Initialize Kelly scaler. + + Args: + base_win_rate: Historical win rate (0-1) + avg_win: Average winning trade ($) + avg_loss: Average losing trade ($) + kelly_fraction: Fraction of Kelly to use (0.5 = half Kelly for safety) + """ + self.base_win_rate = base_win_rate + self.avg_win = avg_win + self.avg_loss = avg_loss + self.kelly_fraction = kelly_fraction + + # Running statistics (updated from trade history) + self.total_trades = 0 + self.total_wins = 0 + self.total_losses = 0 + self.sum_wins = 0.0 + self.sum_losses = 0.0 + + def calculate_optimal_fraction( + self, + exit_confidence: float, + current_profit: float, + target_profit: float, + ) -> float: + """ + Calculate optimal position fraction to hold. + + Args: + exit_confidence: Fuzzy exit confidence (0-1) + current_profit: Current profit ($) + target_profit: Target TP ($) + + Returns: + Fraction of position to hold (0-1) + 1.0 = hold 100% + 0.5 = close 50% + 0.0 = close 100% + """ + # Adjust win probability based on exit confidence + # High exit_confidence = lower win probability for continuing + p_continue_win = self.base_win_rate * (1 - exit_confidence * 0.7) + + # Win/loss ratio + if self.avg_loss > 0: + b = self.avg_win / self.avg_loss + else: + b = 2.0 # Default + + # Kelly formula + q = 1 - p_continue_win + kelly_optimal = (p_continue_win * b - q) / b + + # Apply fractional Kelly for safety + kelly_optimal *= self.kelly_fraction + + # Clamp to [0, 1] + kelly_optimal = np.clip(kelly_optimal, 0, 1) + + return kelly_optimal + + def get_exit_action( + self, + exit_confidence: float, + current_profit: float, + target_profit: float, + ) -> Tuple[bool, float, str]: + """ + Get exit action based on Kelly criterion. + + Args: + exit_confidence: Fuzzy exit confidence (0-1) + current_profit: Current profit ($) + target_profit: Target TP ($) + + Returns: + (should_exit, close_fraction, reason) + should_exit: True if any exit recommended + close_fraction: 0-1 (0=hold, 1=full exit) + reason: Exit reason string + """ + kelly_hold = self.calculate_optimal_fraction( + exit_confidence, current_profit, target_profit + ) + + # Full exit: Kelly suggests 0% hold + if kelly_hold < 0.25: + return True, 1.0, f"Kelly full exit: hold={kelly_hold:.2f}" + + # Partial exit: Kelly suggests 25-70% hold + elif kelly_hold < 0.70: + close_fraction = 1 - kelly_hold + return True, close_fraction, f"Kelly partial: close {close_fraction:.0%} (hold={kelly_hold:.2f})" + + # Hold: Kelly suggests 70%+ hold + else: + return False, 0.0, f"Kelly hold: {kelly_hold:.2%}" + + def update_statistics(self, profit: float): + """ + Update running statistics from completed trade. + + Args: + profit: Trade profit/loss ($) + """ + self.total_trades += 1 + + if profit > 0: + self.total_wins += 1 + self.sum_wins += profit + else: + self.total_losses += 1 + self.sum_losses += abs(profit) + + # Recalculate base parameters + if self.total_trades > 0: + self.base_win_rate = self.total_wins / self.total_trades + + if self.total_wins > 0: + self.avg_win = self.sum_wins / self.total_wins + + if self.total_losses > 0: + self.avg_loss = self.sum_losses / self.total_losses + + def get_statistics(self) -> dict: + """Get current statistics.""" + win_loss_ratio = self.avg_win / self.avg_loss if self.avg_loss > 0 else 0 + + return { + "total_trades": self.total_trades, + "win_rate": self.base_win_rate, + "avg_win": self.avg_win, + "avg_loss": self.avg_loss, + "win_loss_ratio": win_loss_ratio, + "kelly_fraction": self.kelly_fraction, + } + + def set_parameters( + self, + base_win_rate: Optional[float] = None, + avg_win: Optional[float] = None, + avg_loss: Optional[float] = None, + kelly_fraction: Optional[float] = None, + ): + """Update parameters manually.""" + if base_win_rate is not None: + self.base_win_rate = base_win_rate + if avg_win is not None: + self.avg_win = avg_win + if avg_loss is not None: + self.avg_loss = avg_loss + if kelly_fraction is not None: + self.kelly_fraction = kelly_fraction diff --git a/src/momentum_persistence.py b/src/momentum_persistence.py new file mode 100644 index 0000000..8d8f8fc --- /dev/null +++ b/src/momentum_persistence.py @@ -0,0 +1,330 @@ +""" +Momentum Persistence Detector - Deteksi apakah momentum akan continue atau reverse +Menggunakan velocity/acceleration history untuk predict persistence +""" + +import numpy as np +from typing import List, Tuple, Dict +from loguru import logger + + +class MomentumPersistence: + """ + Analisis persistence (kekuatan berkelanjutan) dari momentum trading. + + Skor tinggi (>0.7) = Momentum kuat, likely continue → HOLD position + Skor rendah (<0.3) = Momentum lemah, likely reverse → EXIT position + + Features analyzed: + 1. Velocity trend consistency (all positive/negative) + 2. Velocity increasing/decreasing pattern + 3. Acceleration stability (low variance = stable momentum) + 4. Momentum duration (how long momentum has persisted) + """ + + def __init__(self, lookback_periods: int = 5): + """ + Args: + lookback_periods: Number of recent samples to analyze (default: 5 = 30 seconds) + """ + self.lookback = lookback_periods + self.high_threshold = 0.7 # Persistence > 0.7 = strong, HOLD + self.low_threshold = 0.3 # Persistence < 0.3 = weak, EXIT + + def calculate_persistence_score( + self, + velocity_history: List[float], + acceleration_history: List[float], + profit_history: List[float] = None + ) -> float: + """ + Hitung momentum persistence score (0-1). + + Args: + velocity_history: Recent velocity values ($/second) + acceleration_history: Recent acceleration values ($/second²) + profit_history: Recent profit values (optional, for trend analysis) + + Returns: + Persistence score 0.0-1.0 + - 1.0 = Very persistent (strong momentum, HOLD) + - 0.5 = Neutral + - 0.0 = Reversing (EXIT) + + Example: + >>> persistence = MomentumPersistence() + >>> score = persistence.calculate_persistence_score( + ... velocity_history=[0.08, 0.09, 0.10, 0.12, 0.13], # Increasing! + ... acceleration_history=[0.001, 0.001, 0.001, 0.001, 0.001] # Stable + ... ) + >>> print(f"Persistence: {score:.2f}") # Should be high (~0.9) + """ + if len(velocity_history) < 3 or len(acceleration_history) < 3: + return 0.5 # Neutral if insufficient data + + # Get recent samples + recent_vels = velocity_history[-self.lookback:] + recent_accels = acceleration_history[-self.lookback:] + + score = 0.0 + + # === COMPONENT 1: Velocity Direction Consistency (40%) === + # All positive or all negative = consistent + all_positive = all(v > 0 for v in recent_vels) + all_negative = all(v < 0 for v in recent_vels) + + if all_positive or all_negative: + score += 0.4 + else: + # Mixed signs = weak momentum + positive_ratio = sum(1 for v in recent_vels if v > 0) / len(recent_vels) + score += abs(positive_ratio - 0.5) * 0.8 # Max 0.4 if all one sign + + # === COMPONENT 2: Velocity Trend (30%) === + # Increasing velocity = strengthening momentum + # Decreasing velocity = weakening momentum + + # Check if velocity magnitude is increasing + vel_magnitudes = [abs(v) for v in recent_vels] + increasing_count = sum( + 1 for i in range(1, len(vel_magnitudes)) + if vel_magnitudes[i] > vel_magnitudes[i-1] + ) + increasing_ratio = increasing_count / (len(vel_magnitudes) - 1) + + if increasing_ratio > 0.6: # Mostly increasing + score += 0.3 + elif increasing_ratio > 0.4: # Mixed + score += 0.15 + # else: decreasing, no points + + # === COMPONENT 3: Acceleration Stability (30%) === + # Low variance = stable momentum (predictable) + # High variance = erratic movement (unpredictable) + accel_std = np.std(recent_accels) + + if accel_std < 0.001: # Very stable + score += 0.3 + elif accel_std < 0.003: # Moderately stable + score += 0.2 + elif accel_std < 0.005: # Slightly unstable + score += 0.1 + # else: very unstable, no points + + # Normalize to 0-1 + return min(max(score, 0.0), 1.0) + + def analyze_momentum_quality( + self, + velocity_history: List[float], + acceleration_history: List[float], + current_profit: float + ) -> Dict[str, any]: + """ + Analisis komprehensif kualitas momentum. + + Returns dict dengan: + - persistence_score: Overall score (0-1) + - trend: "strengthening", "weakening", "stable", "reversing" + - recommendation: "HOLD", "CONSIDER_EXIT", "EXIT" + - components: Breakdown of score components + """ + persistence = self.calculate_persistence_score( + velocity_history, acceleration_history + ) + + # Determine trend + if len(velocity_history) >= 3: + recent_vels = velocity_history[-3:] + if all(abs(recent_vels[i]) > abs(recent_vels[i-1]) for i in range(1, len(recent_vels))): + trend = "strengthening" + elif all(abs(recent_vels[i]) < abs(recent_vels[i-1]) for i in range(1, len(recent_vels))): + trend = "weakening" + elif len(velocity_history) >= 2 and \ + (recent_vels[-1] * recent_vels[-2]) < 0: # Sign flip + trend = "reversing" + else: + trend = "stable" + else: + trend = "unknown" + + # Recommendation based on persistence + trend + if persistence > self.high_threshold and trend in ["strengthening", "stable"]: + recommendation = "HOLD" + elif persistence < self.low_threshold or trend == "reversing": + recommendation = "EXIT" + else: + recommendation = "CONSIDER_EXIT" + + # Component breakdown + recent_vels = velocity_history[-self.lookback:] + recent_accels = acceleration_history[-self.lookback:] + + components = { + "direction_consistency": 1.0 if all(v > 0 for v in recent_vels) or all(v < 0 for v in recent_vels) else 0.5, + "trend_strength": abs(np.mean(recent_vels)), + "acceleration_stability": 1.0 / (1.0 + np.std(recent_accels) * 100), # Inverse of std + "sample_count": len(velocity_history) + } + + return { + "persistence_score": persistence, + "trend": trend, + "recommendation": recommendation, + "components": components + } + + def should_raise_exit_threshold( + self, + velocity_history: List[float], + acceleration_history: List[float], + current_profit: float, + base_threshold: float = 0.85 + ) -> Tuple[bool, float, str]: + """ + Tentukan apakah exit threshold harus dinaikkan karena momentum kuat. + + Args: + velocity_history: Recent velocity values + acceleration_history: Recent acceleration values + current_profit: Current profit ($) + base_threshold: Base fuzzy exit threshold + + Returns: + (should_raise, new_threshold, reason) + + Example: + >>> persistence = MomentumPersistence() + >>> should_raise, new_threshold, reason = persistence.should_raise_exit_threshold( + ... velocity_history=[0.10, 0.11, 0.12, 0.13, 0.14], # Strong increasing + ... acceleration_history=[0.001] * 5, # Stable + ... current_profit=2.0, + ... base_threshold=0.85 + ... ) + >>> print(f"Raise: {should_raise}, New: {new_threshold:.0%}") + Raise: True, New: 95% + """ + analysis = self.analyze_momentum_quality( + velocity_history, acceleration_history, current_profit + ) + + persistence = analysis["persistence_score"] + trend = analysis["trend"] + + should_raise = False + new_threshold = base_threshold + reason = "" + + # HIGH PERSISTENCE + STRENGTHENING = Raise threshold significantly + if persistence > 0.8 and trend == "strengthening": + should_raise = True + new_threshold = min(base_threshold + 0.10, 0.98) + reason = f"Very strong momentum (persistence={persistence:.0%}, {trend})" + + # MODERATE PERSISTENCE + STABLE = Raise threshold slightly + elif persistence > 0.7 and trend in ["strengthening", "stable"]: + should_raise = True + new_threshold = min(base_threshold + 0.05, 0.95) + reason = f"Strong momentum (persistence={persistence:.0%}, {trend})" + + # LOW PERSISTENCE or REVERSING = Keep or lower threshold + elif persistence < 0.3 or trend == "reversing": + should_raise = False + new_threshold = max(base_threshold - 0.05, 0.70) + reason = f"Weak/reversing momentum (persistence={persistence:.0%}, {trend})" + + else: + reason = f"Neutral momentum (persistence={persistence:.0%})" + + return should_raise, new_threshold, reason + + def detect_momentum_reversal( + self, + velocity_history: List[float], + min_samples: int = 3 + ) -> Tuple[bool, str]: + """ + Deteksi reversal cepat dalam momentum (danger signal). + + Returns: + (is_reversing, reason) + + Example momentum reversal patterns: + - Velocity sign flip: [+0.05, +0.03, -0.02] → reversing! + - Rapid deceleration: [+0.10, +0.08, +0.03, +0.01] → reversing! + """ + if len(velocity_history) < min_samples: + return False, "Insufficient data" + + recent = velocity_history[-min_samples:] + + # Pattern 1: Sign flip (positive → negative or vice versa) + if len(recent) >= 2: + signs = [1 if v > 0 else -1 if v < 0 else 0 for v in recent] + if signs[-1] != signs[0] and signs[-1] != 0 and signs[0] != 0: + return True, f"Momentum sign flip: {signs[0]} → {signs[-1]}" + + # Pattern 2: Rapid deceleration (magnitude dropping >50% in 3 samples) + if len(recent) >= 3: + magnitudes = [abs(v) for v in recent] + if magnitudes[0] > 0.05: # Only if initial velocity significant + decel_ratio = magnitudes[-1] / magnitudes[0] + if decel_ratio < 0.5: + return True, f"Rapid deceleration: {decel_ratio:.0%} of initial velocity" + + # Pattern 3: Consistent deceleration (all decreasing) + if len(recent) >= 3: + magnitudes = [abs(v) for v in recent] + if all(magnitudes[i] < magnitudes[i-1] for i in range(1, len(magnitudes))): + return True, "Consistent deceleration trend" + + return False, "No reversal detected" + + +if __name__ == "__main__": + # Test cases + persistence = MomentumPersistence() + + # Test 1: Strong persistent momentum (Trade #161613468 at exit) + print("=== Test 1: Strong Persistent Momentum ===") + vel_history = [0.0827, 0.0411, 0.0273, 0.0433, 0.1335] # Increasing + accel_history = [0.0004, 0.0004, 0.0001, 0.0005, 0.0017] # Accelerating + + score = persistence.calculate_persistence_score(vel_history, accel_history) + print(f"Persistence Score: {score:.2f}") + + analysis = persistence.analyze_momentum_quality(vel_history, accel_history, 0.05) + print(f"Trend: {analysis['trend']}") + print(f"Recommendation: {analysis['recommendation']}") + + should_raise, new_thresh, reason = persistence.should_raise_exit_threshold( + vel_history, accel_history, 0.05, base_threshold=0.90 + ) + print(f"Raise Threshold: {should_raise} → {new_thresh:.0%}") + print(f"Reason: {reason}\n") + + # Test 2: Reversing momentum + print("=== Test 2: Reversing Momentum ===") + vel_history_rev = [0.08, 0.05, 0.02, -0.01, -0.03] # Sign flip! + accel_history_rev = [0.001, 0.0005, 0.0, -0.0005, -0.001] + + score_rev = persistence.calculate_persistence_score(vel_history_rev, accel_history_rev) + print(f"Persistence Score: {score_rev:.2f}") + + is_reversing, reason = persistence.detect_momentum_reversal(vel_history_rev) + print(f"Reversing: {is_reversing}") + print(f"Reason: {reason}\n") + + # Test 3: Stable momentum + print("=== Test 3: Stable Momentum ===") + vel_history_stable = [0.05, 0.05, 0.05, 0.05, 0.05] + accel_history_stable = [0.0, 0.0, 0.0, 0.0, 0.0] + + score_stable = persistence.calculate_persistence_score(vel_history_stable, accel_history_stable) + print(f"Persistence Score: {score_stable:.2f}") + + should_raise, new_thresh, reason = persistence.should_raise_exit_threshold( + vel_history_stable, accel_history_stable, 3.0, base_threshold=0.85 + ) + print(f"Raise Threshold: {should_raise} → {new_thresh:.0%}") + print(f"Reason: {reason}") diff --git a/src/recovery_detector.py b/src/recovery_detector.py new file mode 100644 index 0000000..2bc9f3d --- /dev/null +++ b/src/recovery_detector.py @@ -0,0 +1,329 @@ +""" +Recovery Strength Detector - Deteksi kekuatan recovery dari loss +Khusus untuk trade yang recovering dari drawdown +""" + +import numpy as np +from typing import List, Tuple, Dict +from loguru import logger + + +class RecoveryDetector: + """ + Analisis kekuatan recovery dari loss positions. + + Scenario: Trade went to -$6.39, now at $0.05 + Question: Apakah recovery akan continue ke profit besar, atau stop di sini? + + Strong recovery indicators: + 1. High recovery percentage (>80% from peak loss) + 2. Fast recovery velocity (>0.05 $/s average) + 3. Sustained recovery (not just spike) + 4. Accelerating recovery (getting faster) + """ + + def __init__(self): + self.strong_recovery_threshold = 0.8 # 80% recovery from loss + self.fast_recovery_velocity = 0.05 # $/second + self.min_recovery_samples = 5 # Min data points untuk validate + + def analyze_recovery_strength( + self, + profit_history: List[float], + peak_loss: float, + velocity_history: List[float] = None + ) -> Tuple[bool, Dict[str, float]]: + """ + Analisis apakah recovery dari loss cukup kuat untuk continue. + + Args: + profit_history: Recent profit values + peak_loss: Peak (worst) loss achieved (negative value) + velocity_history: Optional velocity history for trend analysis + + Returns: + (is_strong_recovery, metrics_dict) + + Example: + >>> detector = RecoveryDetector() + >>> is_strong, metrics = detector.analyze_recovery_strength( + ... profit_history=[-6.39, -5.20, -4.35, -2.99, 0.05], + ... peak_loss=-6.39 + ... ) + >>> print(f"Strong: {is_strong}, Recovery: {metrics['recovery_pct']:.0%}") + Strong: True, Recovery: 101% + """ + if not profit_history or len(profit_history) < 2: + return False, {"reason": "Insufficient data"} + + current_profit = profit_history[-1] + + # Can't analyze recovery if never was in loss + if peak_loss >= 0: + return False, {"reason": "No loss to recover from"} + + # 1. Recovery Percentage + # From peak_loss (-6.39) to current (0.05) = 6.44 improvement + # Recovery % = 6.44 / 6.39 = 100.78% + recovery_amount = current_profit - peak_loss + recovery_pct = recovery_amount / abs(peak_loss) + + # 2. Recovery Velocity (average over last N samples) + recovery_samples = [] + for i in range(len(profit_history) - 1, 0, -1): + if profit_history[i] > peak_loss: + recovery_samples.append(profit_history[i]) + else: + break # Stop when we hit the loss zone + + if len(recovery_samples) < self.min_recovery_samples: + return False, { + "reason": "Recovery too brief", + "samples": len(recovery_samples), + "recovery_pct": recovery_pct + } + + # Calculate average recovery velocity + recovery_deltas = [ + recovery_samples[i] - recovery_samples[i-1] + for i in range(1, len(recovery_samples)) + ] + avg_recovery_vel = np.mean(recovery_deltas) if recovery_deltas else 0.0 + + # 3. Recovery Acceleration (is it speeding up?) + # Compare first half vs second half velocity + if len(recovery_deltas) >= 4: + mid = len(recovery_deltas) // 2 + first_half_vel = np.mean(recovery_deltas[:mid]) + second_half_vel = np.mean(recovery_deltas[mid:]) + is_accelerating = second_half_vel > first_half_vel + else: + is_accelerating = False + + # 4. Recovery Consistency (not erratic) + recovery_std = np.std(recovery_deltas) if len(recovery_deltas) > 1 else 0.0 + is_consistent = recovery_std < 0.5 # Low variance + + # === DECISION LOGIC === + is_strong = False + + # Strong recovery criteria: + if ( + recovery_pct > self.strong_recovery_threshold and # >80% recovered + avg_recovery_vel > self.fast_recovery_velocity and # Fast recovery + len(recovery_samples) >= self.min_recovery_samples # Sustained + ): + is_strong = True + + # OR: Accelerating recovery even if not 80% yet + elif ( + recovery_pct > 0.5 and # At least 50% recovered + is_accelerating and # Getting faster + avg_recovery_vel > 0.03 # Reasonable speed + ): + is_strong = True + + # Metrics + metrics = { + "recovery_pct": recovery_pct, + "recovery_amount": recovery_amount, + "avg_recovery_vel": avg_recovery_vel, + "recovery_samples": len(recovery_samples), + "is_accelerating": is_accelerating, + "is_consistent": is_consistent, + "recovery_std": recovery_std + } + + return is_strong, metrics + + def should_extend_grace_period( + self, + profit_history: List[float], + peak_loss: float, + current_grace_seconds: int, + max_grace_seconds: int = 720 # 12 minutes + ) -> Tuple[bool, int, str]: + """ + Tentukan apakah grace period harus diperpanjang untuk recovery. + + Args: + profit_history: Recent profit values + peak_loss: Peak loss value + current_grace_seconds: Current grace period + max_grace_seconds: Maximum allowed grace + + Returns: + (should_extend, new_grace_seconds, reason) + """ + is_strong, metrics = self.analyze_recovery_strength( + profit_history, peak_loss + ) + + if not is_strong: + return False, current_grace_seconds, "Weak recovery, no extension" + + # Calculate extension based on recovery strength + recovery_pct = metrics.get("recovery_pct", 0) + recovery_vel = metrics.get("avg_recovery_vel", 0) + + # Strong recovery = extend grace significantly + if recovery_pct > 0.8 and recovery_vel > 0.08: + extension = 180 # +3 minutes + reason = f"Very strong recovery ({recovery_pct:.0%} at {recovery_vel:.4f}$/s)" + elif recovery_pct > 0.6 and recovery_vel > 0.05: + extension = 120 # +2 minutes + reason = f"Strong recovery ({recovery_pct:.0%})" + else: + extension = 60 # +1 minute + reason = f"Moderate recovery ({recovery_pct:.0%})" + + new_grace = min(current_grace_seconds + extension, max_grace_seconds) + + return True, new_grace, reason + + def predict_breakeven_time( + self, + profit_history: List[float], + velocity_history: List[float] + ) -> Tuple[int, float]: + """ + Estimasi berapa lama lagi untuk mencapai breakeven. + + Args: + profit_history: Recent profit values + velocity_history: Recent velocity values + + Returns: + (seconds_to_breakeven, confidence) + """ + if not profit_history or not velocity_history: + return -1, 0.0 + + current_profit = profit_history[-1] + + # Already at breakeven or profit + if current_profit >= 0: + return 0, 1.0 + + # Calculate average velocity during recovery + avg_vel = np.mean(velocity_history[-10:]) # Last 10 samples + + # Not recovering (velocity negative or near zero) + if avg_vel <= 0.01: + return -1, 0.0 # Can't predict + + # Time to breakeven = distance / velocity + distance_to_be = abs(current_profit) + time_to_be = distance_to_be / avg_vel + + # Confidence based on velocity stability + vel_std = np.std(velocity_history[-10:]) + confidence = max(0, 1.0 - vel_std * 10) # Lower std = higher confidence + + return int(time_to_be), confidence + + def get_recovery_recommendation( + self, + profit_history: List[float], + peak_loss: float, + velocity_history: List[float] = None, + current_exit_threshold: float = 0.85 + ) -> Tuple[str, float, str]: + """ + Rekomendasi lengkap untuk recovering position. + + Returns: + (action, adjusted_threshold, reason) + action: "HOLD_STRONG", "HOLD_WEAK", "EXIT" + """ + is_strong, metrics = self.analyze_recovery_strength( + profit_history, peak_loss, velocity_history + ) + + current_profit = profit_history[-1] + recovery_pct = metrics.get("recovery_pct", 0) + recovery_vel = metrics.get("avg_recovery_vel", 0) + + # HOLD_STRONG: Very strong recovery, raise threshold + if is_strong and current_profit >= 0: + # Recovered to profit - strong signal + adjusted_threshold = min(current_exit_threshold + 0.15, 0.98) + action = "HOLD_STRONG" + reason = ( + f"Strong recovery to profit ({recovery_pct:.0%} from ${peak_loss:.2f}, " + f"vel={recovery_vel:.4f}$/s)" + ) + + elif is_strong and current_profit < 0: + # Still in loss but strong recovery - give more time + adjusted_threshold = min(current_exit_threshold + 0.10, 0.95) + action = "HOLD_STRONG" + reason = ( + f"Strong recovery in progress ({recovery_pct:.0%}, " + f"vel={recovery_vel:.4f}$/s)" + ) + + # HOLD_WEAK: Moderate recovery + elif recovery_pct > 0.5 and recovery_vel > 0.03: + adjusted_threshold = min(current_exit_threshold + 0.05, 0.90) + action = "HOLD_WEAK" + reason = f"Moderate recovery ({recovery_pct:.0%})" + + # EXIT: Weak or stalled recovery + else: + adjusted_threshold = max(current_exit_threshold - 0.05, 0.70) + action = "EXIT" + reason = f"Weak recovery ({recovery_pct:.0%}, vel={recovery_vel:.4f}$/s)" + + return action, adjusted_threshold, reason + + +if __name__ == "__main__": + # Test cases + detector = RecoveryDetector() + + # Test 1: Strong recovery (Trade #161613468) + print("=== Test 1: Strong Recovery from -$6.39 to $0.05 ===") + profit_history = [-6.39, -5.67, -5.20, -4.35, -2.99, -0.50, 0.05] + peak_loss = -6.39 + + is_strong, metrics = detector.analyze_recovery_strength(profit_history, peak_loss) + print(f"Is Strong Recovery: {is_strong}") + print(f"Recovery %: {metrics['recovery_pct']:.0%}") + print(f"Recovery Velocity: {metrics['avg_recovery_vel']:.4f} $/s") + print(f"Samples: {metrics['recovery_samples']}") + print(f"Accelerating: {metrics['is_accelerating']}\n") + + # Test 2: Recovery recommendation + print("=== Test 2: Recovery Recommendation ===") + velocity_history = [0.0058, 0.0250, 0.0827, 0.0411, 0.1335] + + action, adj_threshold, reason = detector.get_recovery_recommendation( + profit_history, peak_loss, velocity_history, current_exit_threshold=0.90 + ) + print(f"Action: {action}") + print(f"Adjusted Threshold: {adj_threshold:.0%}") + print(f"Reason: {reason}\n") + + # Test 3: Breakeven prediction + print("=== Test 3: Breakeven Time Prediction ===") + profit_history_loss = [-3.0, -2.5, -2.0, -1.5, -1.0] + velocity_history_loss = [0.05, 0.05, 0.05, 0.05, 0.05] + + time_to_be, confidence = detector.predict_breakeven_time( + profit_history_loss, velocity_history_loss + ) + print(f"Time to Breakeven: {time_to_be}s ({time_to_be//60}m {time_to_be%60}s)") + print(f"Confidence: {confidence:.0%}") + + # Test 4: Weak recovery + print("\n=== Test 4: Weak/Stalled Recovery ===") + profit_history_weak = [-5.0, -4.8, -4.7, -4.6, -4.5] # Slow + peak_loss_weak = -5.0 + + is_strong_weak, metrics_weak = detector.analyze_recovery_strength( + profit_history_weak, peak_loss_weak + ) + print(f"Is Strong Recovery: {is_strong_weak}") + print(f"Recovery %: {metrics_weak['recovery_pct']:.0%}") + print(f"Recovery Velocity: {metrics_weak['avg_recovery_vel']:.4f} $/s") diff --git a/src/smart_risk_manager.py b/src/smart_risk_manager.py index 3ed57c9..e5c9cab 100644 --- a/src/smart_risk_manager.py +++ b/src/smart_risk_manager.py @@ -25,6 +25,11 @@ 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.""" @@ -98,6 +103,7 @@ class PositionGuard: 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 @@ -108,6 +114,39 @@ class PositionGuard: 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() @@ -127,6 +166,77 @@ class PositionGuard: 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. @@ -293,8 +403,22 @@ class SmartRiskManager: # Load state self._load_daily_state() + # === Advanced Exit Systems (v7) === + self._init_advanced_exit_systems() + logger.info("=" * 50) - logger.info("SMART RISK MANAGER v2.2 INITIALIZED") + # 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})") @@ -304,8 +428,79 @@ class SmartRiskManager: 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" @@ -677,6 +872,123 @@ class SmartRiskManager: """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, @@ -685,15 +997,20 @@ class SmartRiskManager: 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 - Evaluate if position should be closed. + SMART DYNAMIC TP v5 - Evaluate if position should be closed. - TIDAK hanya menunggu TP tercapai, tapi juga: - 1. Analisis momentum - apakah harga bergerak ke arah TP? - 2. Probabilitas TP - masih mungkin tercapai? - 3. ML confidence trend - apakah trend masih kuat? - 4. Early exit jika probabilitas TP rendah + 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) """ @@ -701,6 +1018,116 @@ class SmartRiskManager: 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: @@ -719,47 +1146,497 @@ class SmartRiskManager: 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 >= 15: # Profit $15+ + if current_profit >= tp_min: # Profit >= scaled threshold # A. Hard TP - profit sangat bagus - if current_profit >= 40: + 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 >= 25 and momentum < -30: + 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 - if guard.peak_profit > 30 and current_profit < guard.peak_profit * 0.6: + # 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 >= 20: + 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 >= $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})" + # 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 >= $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})" + # 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 $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 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) === - if 5 <= current_profit < 15: - # Ambil profit kecil jika momentum sangat negatif - if momentum < -50 and ml_confidence >= 0.65: - # ML yakin trend berbalik + # === 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") @@ -773,36 +1650,63 @@ class SmartRiskManager: # It encourages holding losers hoping they'll recover # PROPER RISK MANAGEMENT: Follow SL rules, don't hope for recovery - # 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 + # === 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_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100 + loss_in_atr = abs(current_profit) / atr_unit if atr_unit > 0 else 0 - # Cut early if momentum is against us AND loss is significant - # BUT only after grace period (15 min = 1 M15 candle) - 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 + # 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 < 15: - 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") + 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_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" + 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 > $10 - if guard.stagnation_seconds >= 120 and abs(current_profit) > 10 and trade_age_minutes >= 15: + # 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" - # NOTE: Smart Hold REMOVED - no more holding losers hoping for golden time - # If SL is hit, close the trade immediately - - # === CHECK 4: TREND REVERSAL (LEBIH SENSITIF) === - # Close lebih cepat jika ada reversal signal - tidak perlu tunggu loss besar + # === 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 @@ -811,28 +1715,41 @@ class SmartRiskManager: is_reversal = True guard.reversal_warnings += 1 - # LEBIH KETAT: Close pada reversal jika loss > 40% dari max (sebelumnya 60%) - loss_moderate = abs(current_profit) > (self.max_loss_per_trade * 0.4) - if is_reversal and current_profit < -8 and loss_moderate: - return True, ExitReason.TREND_REVERSAL, f"[REVERSAL] Reversal signal ({ml_signal} {ml_confidence:.0%}) - Loss: ${current_profit:.2f}" + # 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}" - # Close jika sudah 3x warning reversal (sebelumnya 5x) - if guard.reversal_warnings >= 3 and current_profit < -10: - return True, ExitReason.TREND_REVERSAL, f"[WARN] Multiple reversal warnings ({guard.reversal_warnings}x) - Loss: ${current_profit:.2f}" + # 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: MAXIMUM LOSS PER TRADE === - # Close jika loss sudah 50%+ dari max — no exceptions - if current_profit <= -(self.max_loss_per_trade * 0.50): - return True, ExitReason.POSITION_LIMIT, f"[S/L] Position loss limit: ${current_profit:.2f} (50% of ${self.max_loss_per_trade:.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 5: STALL DETECTION === - # Jika harga tidak bergerak (stall) terlalu lama dengan loss - if len(guard.profit_history) >= 10: + # === 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 < 3 and current_profit < -15: # Stall dengan loss + if recent_range < stall_range_threshold and current_profit < stall_loss: guard.stall_count += 1 - if guard.stall_count >= 5: - return True, ExitReason.TREND_REVERSAL, f"[STALL] Stalled with loss ${current_profit:.2f} - cutting" + 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)) @@ -840,44 +1757,47 @@ class SmartRiskManager: return True, ExitReason.DAILY_LIMIT, f"[LIMIT] Would exceed daily loss limit" # === CHECK 7: WEEKEND CLOSE === - # Market closes Saturday 05:00 WIB — only close 30 min before (Saturday 04:30 WIB) - 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 + # 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 > -10: + 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 === + # === 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) - profit_growing = momentum > 0 and guard.velocity > 0 + # 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 < 5 and not profit_growing: + 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 > -15: + 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 >= 5 and profit_growing and ml_agrees: + 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 < 10 or not profit_growing: + 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}" - # If profit > $10 and growing, allow up to 8 hours elif trade_duration_hours >= 8: return True, ExitReason.TAKE_PROFIT, f"[MAX TIME] Taking profit ${current_profit:.2f} after {trade_duration_hours:.1f}h" diff --git a/src/trajectory_predictor.py b/src/trajectory_predictor.py new file mode 100644 index 0000000..3722f50 --- /dev/null +++ b/src/trajectory_predictor.py @@ -0,0 +1,281 @@ +""" +Trajectory Predictor - Prediksi pergerakan profit masa depan +Menggunakan parabolic motion model untuk forecast profit 1-5 menit ke depan +""" + +import numpy as np +from typing import List, Tuple, Dict +from loguru import logger + + +class TrajectoryPredictor: + """ + Prediksi trajectory profit menggunakan kinematic equations. + + Model: profit(t) = profit₀ + velocity*t + 0.5*acceleration*t² + + Cocok untuk: + - Deteksi early exit (jangan close jika prediksi profit tinggi) + - Validasi exit timing (exit jika prediksi profit turun) + - Recovery continuation (prediksi apakah recovery akan lanjut) + """ + + def __init__(self): + self.default_horizons = [60, 180, 300] # 1m, 3m, 5m (seconds) + self.confidence_threshold = 0.7 # Minimum confidence untuk pakai prediksi + + def predict_future_profit( + self, + current_profit: float, + velocity: float, + acceleration: float, + horizons: List[int] = None + ) -> List[float]: + """ + Prediksi profit di masa depan menggunakan parabolic motion. + + Args: + current_profit: Profit saat ini ($) + velocity: Profit velocity ($/second) + acceleration: Profit acceleration ($/second²) + horizons: List of time horizons dalam seconds (default: [60, 180, 300]) + + Returns: + List of predicted profits untuk setiap horizon + + Example: + >>> predictor = TrajectoryPredictor() + >>> pred_1m, pred_3m, pred_5m = predictor.predict_future_profit( + ... current_profit=0.05, + ... velocity=0.1335, + ... acceleration=0.0017 + ... ) + >>> print(f"1min: ${pred_1m:.2f}, 3min: ${pred_3m:.2f}") + 1min: $11.12, 3min: $27.39 + """ + if horizons is None: + horizons = self.default_horizons + + predictions = [] + for dt in horizons: + # Kinematic equation: s = s₀ + v*t + 0.5*a*t² + predicted_profit = current_profit + velocity * dt + 0.5 * acceleration * dt**2 + predictions.append(predicted_profit) + + return predictions + + def calculate_prediction_confidence( + self, + velocity_history: List[float], + acceleration_history: List[float] + ) -> float: + """ + Hitung confidence level prediksi (0-1). + + High confidence jika: + - Velocity stable (low variance) + - Acceleration consistent + - Sufficient data points + + Args: + velocity_history: List of recent velocity values + acceleration_history: List of recent acceleration values + + Returns: + Confidence score 0.0-1.0 + """ + if len(velocity_history) < 3 or len(acceleration_history) < 3: + return 0.3 # Low confidence if insufficient data + + # 1. Velocity stability (lower std = higher confidence) + vel_std = np.std(velocity_history[-5:]) + vel_score = max(0, 1.0 - vel_std * 10) # Normalize + + # 2. Acceleration consistency + accel_std = np.std(acceleration_history[-5:]) + accel_score = max(0, 1.0 - accel_std * 100) + + # 3. Data sufficiency bonus + data_score = min(len(velocity_history) / 20, 1.0) # Max at 20 samples + + # Weighted average + confidence = vel_score * 0.4 + accel_score * 0.4 + data_score * 0.2 + return min(max(confidence, 0.0), 1.0) + + def should_hold_position( + self, + current_profit: float, + velocity: float, + acceleration: float, + min_target: float, + velocity_history: List[float] = None, + acceleration_history: List[float] = None + ) -> Tuple[bool, str, Dict[str, float]]: + """ + Rekomendasi apakah HOLD position berdasarkan prediksi. + + Args: + current_profit: Current profit ($) + velocity: Current velocity ($/s) + acceleration: Current acceleration ($/s²) + min_target: Minimum profit target ($) + velocity_history: Recent velocity values (optional) + acceleration_history: Recent acceleration values (optional) + + Returns: + (should_hold, reason, predictions_dict) + + Example: + >>> should_hold, reason, preds = predictor.should_hold_position( + ... current_profit=0.05, + ... velocity=0.1335, + ... acceleration=0.0017, + ... min_target=3.0 + ... ) + >>> print(f"Hold: {should_hold}, Reason: {reason}") + Hold: True, Reason: Predicted $11.12 in 1min (target: $3.00) + """ + # Predict 1m, 3m, 5m ahead + pred_1m, pred_3m, pred_5m = self.predict_future_profit( + current_profit, velocity, acceleration + ) + + # Calculate confidence (if history provided) + confidence = 1.0 + if velocity_history and acceleration_history: + confidence = self.calculate_prediction_confidence( + velocity_history, acceleration_history + ) + + predictions = { + 'pred_1m': pred_1m, + 'pred_3m': pred_3m, + 'pred_5m': pred_5m, + 'confidence': confidence + } + + # Decision logic + should_hold = False + reason = "" + + # Check if low confidence - don't rely on predictions + if confidence < self.confidence_threshold: + reason = f"Low prediction confidence ({confidence:.0%}), use standard logic" + return False, reason, predictions + + # HOLD if 1-minute prediction exceeds target significantly + if pred_1m > min_target * 2 and acceleration > 0: + should_hold = True + reason = f"Predicted ${pred_1m:.2f} in 1min (target: ${min_target:.2f}, conf: {confidence:.0%})" + + # HOLD if strong acceleration even if current profit low + elif acceleration > 0.001 and velocity > 0.05 and pred_1m > min_target: + should_hold = True + reason = f"Strong acceleration ({acceleration:.4f}), pred ${pred_1m:.2f} > target" + + # HOLD if recovering strongly (negative to positive trajectory) + elif current_profit < 0 and pred_1m > abs(current_profit) * 0.5: + should_hold = True + reason = f"Strong recovery trajectory: ${current_profit:.2f} → ${pred_1m:.2f}" + + # EXIT if prediction shows decline + elif pred_1m < current_profit * 0.8 and velocity < 0: + should_hold = False + reason = f"Declining trajectory: ${current_profit:.2f} → ${pred_1m:.2f}" + + else: + reason = f"Neutral prediction (1m: ${pred_1m:.2f})" + + return should_hold, reason, predictions + + def get_optimal_exit_time( + self, + current_profit: float, + velocity: float, + acceleration: float, + tp_target: float + ) -> Tuple[float, int]: + """ + Estimasi waktu optimal untuk exit berdasarkan trajectory. + + Args: + current_profit: Current profit + velocity: Current velocity + acceleration: Current acceleration + tp_target: Take profit target + + Returns: + (peak_profit, time_to_peak_seconds) + + Example: + >>> peak, time_to_peak = predictor.get_optimal_exit_time( + ... current_profit=5.0, + ... velocity=0.08, + ... acceleration=-0.002, # Decelerating + ... tp_target=10.0 + ... ) + >>> print(f"Peak at ${peak:.2f} in {time_to_peak}s") + """ + # For parabolic motion with deceleration: + # Profit reaches peak when velocity = 0 + # velocity(t) = v₀ + a*t = 0 → t = -v₀/a + + if acceleration >= 0: + # Still accelerating - no peak in near future + # Estimate based on reaching TP + if velocity > 0: + time_to_tp = (tp_target - current_profit) / velocity + return tp_target, int(time_to_tp) + else: + return current_profit, 0 + + # Decelerating (acceleration < 0) + time_to_peak = -velocity / acceleration # When velocity reaches 0 + + # Clamp to reasonable range (0-600 seconds = 10 minutes) + time_to_peak = max(0, min(time_to_peak, 600)) + + # Calculate peak profit + peak_profit = current_profit + velocity * time_to_peak + 0.5 * acceleration * time_to_peak**2 + + return peak_profit, int(time_to_peak) + + +if __name__ == "__main__": + # Test cases + predictor = TrajectoryPredictor() + + # Test 1: Strong upward momentum (Trade #161613468 case) + print("=== Test 1: Strong Upward Momentum ===") + should_hold, reason, preds = predictor.should_hold_position( + current_profit=0.05, + velocity=0.1335, + acceleration=0.0017, + min_target=3.0 + ) + print(f"Should Hold: {should_hold}") + print(f"Reason: {reason}") + print(f"Predictions: 1m=${preds['pred_1m']:.2f}, 3m=${preds['pred_3m']:.2f}, 5m=${preds['pred_5m']:.2f}\n") + + # Test 2: Declining trajectory + print("=== Test 2: Declining Trajectory ===") + should_hold, reason, preds = predictor.should_hold_position( + current_profit=5.0, + velocity=-0.05, + acceleration=-0.001, + min_target=3.0 + ) + print(f"Should Hold: {should_hold}") + print(f"Reason: {reason}") + print(f"Predictions: 1m=${preds['pred_1m']:.2f}\n") + + # Test 3: Optimal exit time + print("=== Test 3: Optimal Exit Time ===") + peak, time_to_peak = predictor.get_optimal_exit_time( + current_profit=5.0, + velocity=0.08, + acceleration=-0.002, + tp_target=10.0 + ) + print(f"Peak Profit: ${peak:.2f}") + print(f"Time to Peak: {time_to_peak}s ({time_to_peak//60}m {time_to_peak%60}s)") diff --git a/src/version.py b/src/version.py new file mode 100644 index 0000000..3a7c8dc --- /dev/null +++ b/src/version.py @@ -0,0 +1,253 @@ +""" +XAUBot AI - Centralized Version Management +========================================== + +Semantic Versioning (SemVer): MAJOR.MINOR.PATCH + +MAJOR: Incompatible API changes, breaking changes +MINOR: New features, backward compatible +PATCH: Bug fixes, backward compatible + +Author: AI Assistant +""" + +import os +from pathlib import Path +from typing import Dict, Tuple +from loguru import logger + + +class VersionManager: + """ + Centralized version management for XAUBot AI. + + Auto-detects version based on enabled features and components. + Reads base version from VERSION file, calculates effective version. + """ + + def __init__(self): + self.base_version = self._read_version_file() + self.features = self._detect_features() + self.effective_version = self._calculate_version() + + def _read_version_file(self) -> Tuple[int, int, int]: + """Read version from VERSION file.""" + version_file = Path(__file__).parent.parent / "VERSION" + try: + with open(version_file, 'r') as f: + version_str = f.read().strip() + parts = version_str.split('.') + if len(parts) != 3: + raise ValueError(f"Invalid version format: {version_str}") + return tuple(int(p) for p in parts) + except Exception as e: + logger.warning(f"Could not read VERSION file: {e}, using default 0.0.0") + return (0, 0, 0) + + def _detect_features(self) -> Dict[str, bool]: + """ + Auto-detect enabled features from environment and imports. + """ + features = {} + + # Core features (always enabled) + features['mt5_integration'] = True + features['smc_analysis'] = True + features['ml_prediction'] = True + features['hmm_regime'] = True + + # Advanced exit features (from environment flags) + features['kalman_filter'] = os.environ.get("KALMAN_ENABLED", "1") == "1" + features['advanced_exits'] = os.environ.get("ADVANCED_EXITS_ENABLED", "1") == "1" + features['predictive_intelligence'] = os.environ.get("PREDICTIVE_ENABLED", "1") == "1" + + # Component detection + try: + # Fuzzy Logic + from src.fuzzy_exit_logic import FuzzyExitController + features['fuzzy_logic'] = True + except ImportError: + features['fuzzy_logic'] = False + + try: + # Kelly Criterion + from src.kelly_position_scaler import KellyPositionScaler + features['kelly_criterion'] = True + except ImportError: + features['kelly_criterion'] = False + + try: + # Trajectory Predictor + from src.trajectory_predictor import TrajectoryPredictor + features['trajectory_predictor'] = True + except ImportError: + features['trajectory_predictor'] = False + + try: + # Momentum Persistence + from src.momentum_persistence import MomentumPersistence + features['momentum_persistence'] = True + except ImportError: + features['momentum_persistence'] = False + + try: + # Recovery Detector + from src.recovery_detector import RecoveryDetector + features['recovery_detector'] = True + except ImportError: + features['recovery_detector'] = False + + return features + + def _calculate_version(self) -> Tuple[int, int, int]: + """ + Calculate effective version based on base + features. + + Version increments: + - Kalman Filter: +0.1.0 (MINOR) + - Fuzzy Logic: +0.1.0 (MINOR) + - Kelly Criterion: +0.1.0 (MINOR) + - Predictive Intelligence (all 3): +0.3.0 (MINOR) + - Each predictor separately: +0.1.0 (MINOR) + """ + major, minor, patch = self.base_version + + # MINOR increments for features + if self.features.get('kalman_filter', False): + minor += 1 # v0.1.0 + + if self.features.get('fuzzy_logic', False): + minor += 1 # v0.2.0 + + if self.features.get('kelly_criterion', False): + minor += 1 # v0.3.0 + + # Predictive Intelligence components + predictive_count = sum([ + self.features.get('trajectory_predictor', False), + self.features.get('momentum_persistence', False), + self.features.get('recovery_detector', False) + ]) + + if predictive_count > 0: + minor += predictive_count # Each predictor = +0.1.0 + + return (major, minor, patch) + + def get_version_string(self) -> str: + """Get version as string (MAJOR.MINOR.PATCH).""" + major, minor, patch = self.effective_version + return f"{major}.{minor}.{patch}" + + def get_detailed_version(self) -> str: + """ + Get detailed version with feature breakdown. + + Example: "v0.6.0 (Kalman + Fuzzy + Kelly + Predictive)" + """ + major, minor, patch = self.effective_version + version_str = f"v{major}.{minor}.{patch}" + + # Build feature list + feature_list = [] + + if self.features.get('kalman_filter', False): + feature_list.append("Kalman") + + if self.features.get('fuzzy_logic', False): + feature_list.append("Fuzzy") + + if self.features.get('kelly_criterion', False): + feature_list.append("Kelly") + + # Check if all 3 predictive components enabled + predictive_all = all([ + self.features.get('trajectory_predictor', False), + self.features.get('momentum_persistence', False), + self.features.get('recovery_detector', False) + ]) + + if predictive_all: + feature_list.append("Predictive") + else: + # Add individual predictive components + if self.features.get('trajectory_predictor', False): + feature_list.append("Trajectory") + if self.features.get('momentum_persistence', False): + feature_list.append("Momentum") + if self.features.get('recovery_detector', False): + feature_list.append("Recovery") + + if feature_list: + features_str = " + ".join(feature_list) + return f"{version_str} ({features_str})" + else: + return f"{version_str} (Core)" + + def get_exit_strategy_version(self) -> str: + """Get exit strategy version label.""" + if self.features.get('predictive_intelligence', False): + return "Exit v6.3 Predictive Intelligence" + elif self.features.get('advanced_exits', False): + return "Exit v6.2 Advanced" + elif self.features.get('kalman_filter', False): + return "Exit v6.0 Kalman" + else: + return "Exit v5.0 Dynamic" + + def print_version_info(self): + """Print comprehensive version information.""" + logger.info("=" * 60) + logger.info(f"XAUBot AI {self.get_detailed_version()}") + logger.info(f"Exit Strategy: {self.get_exit_strategy_version()}") + logger.info("=" * 60) + logger.info("Enabled Features:") + + for feature, enabled in sorted(self.features.items()): + status = "✓" if enabled else "✗" + feature_name = feature.replace('_', ' ').title() + logger.info(f" [{status}] {feature_name}") + + logger.info("=" * 60) + + def get_component_versions(self) -> Dict[str, str]: + """Get version info for each component.""" + return { + "core": self.get_version_string(), + "exit_strategy": self.get_exit_strategy_version(), + "detailed": self.get_detailed_version(), + "base": f"{self.base_version[0]}.{self.base_version[1]}.{self.base_version[2]}", + "effective": self.get_version_string() + } + + +# Global version instance +__version_manager__ = VersionManager() + +# Expose convenient module-level attributes +__version__ = __version_manager__.get_version_string() +__version_detailed__ = __version_manager__.get_detailed_version() +__exit_strategy__ = __version_manager__.get_exit_strategy_version() + + +def get_version() -> str: + """Get version string.""" + return __version__ + + +def get_detailed_version() -> str: + """Get detailed version with features.""" + return __version_detailed__ + + +def print_version_info(): + """Print version information.""" + __version_manager__.print_version_info() + + +if __name__ == "__main__": + # Test version detection + print_version_info() + print(f"\nVersion: {get_version()}") + print(f"Detailed: {get_detailed_version()}") + print(f"\nComponents: {__version_manager__.get_component_versions()}")