fix(exec): repair the six live loss root causes - cooldown, M5-ATR trailing, honest learner, persisted snapshots
Phase-3 execution-layer fixes, config values are the phase-2 gate winners (conf=55/breakeven 0.7/atr_trail 1.5/trigger 1.2 - scalping 5-month sweep PF 2.11 at conf=55, baseline PF 1.50): - trailing_stop: trail now arms only after trail_trigger_atr x ATR unrealized gain and never trails past TP (tick-capped). Old logic trailed on H1-ATR (~6) while SL/TP were M5-ATR-calibrated (~-6) - breakeven at +1.85 was unreachable, every scalp exited at SL - bot.py: entry cooldown per (strategy, direction) from the cooldown: YAML block (scalping 1800s/day_trading 3600s/swing 7200s) - signals were re-firing every 30s cycle and piling same-direction positions; Step 4 AI prediction on df_m15 (model trains on M15); Step 8 trails on M5 ATR; removed duplicate on_trade_closed hook in Step 9 (each external close was learned twice, strat_scalping multiplier decayed 0.527->0.41 on garbage inputs); performance_snapshots now trigger on wall clock (boot + every >=30min) so restarts can't reset the cycle counter to 0 - trade_executor: execute_signal threads ai_score/regime_score into signal records; close_trade + sync_positions share _learner_context built from the DB Trade row (was: hardcoded regime=RANGING/confluence=75/sentiment=0) - trading_params.yaml: moderate scalping 50->55, breakeven 0.7, trail 1.5, trail_trigger_atr 1.2, cooldown + backtest sections - tests: scalping moderate confluence assertion 50.0 -> 55.0 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ trading_modes:
|
||||
moderate:
|
||||
name: Moderate / Calculated Risk
|
||||
description: Balanced risk-reward, active across major sessions, calculated risks
|
||||
scalping_min_confluence: 50
|
||||
scalping_min_confluence: 55
|
||||
day_trading_min_confluence: 55
|
||||
swing_trading_min_confluence: 60
|
||||
min_technical_score: 35
|
||||
@@ -233,9 +233,28 @@ confluence:
|
||||
regime_weight: 15
|
||||
trailing_stop:
|
||||
enabled: true
|
||||
breakeven_trigger_rr: 1.0
|
||||
breakeven_trigger_rr: 0.7
|
||||
partial_close_pct: 50
|
||||
atr_trail_multiplier: 2.0
|
||||
atr_trail_multiplier: 1.5
|
||||
trail_trigger_atr: 1.2
|
||||
cooldown:
|
||||
enabled: true
|
||||
scalping: 1800
|
||||
day_trading: 3600
|
||||
swing_trading: 7200
|
||||
backtest:
|
||||
min_confluence: 55
|
||||
min_technical_score: 35
|
||||
cooldown_bars: 0
|
||||
max_hold_bars: 0
|
||||
breakeven_rr: 0.7
|
||||
atr_trail_multiplier: 1.5
|
||||
trail_trigger_atr: 1.2
|
||||
lot_size: 0.02
|
||||
initial_balance: 10000.0
|
||||
warmup_bars: 200
|
||||
backtest_start: null
|
||||
backtest_end: null
|
||||
telegram:
|
||||
max_alerts_per_minute: 2
|
||||
daily_summary_time: '21:00'
|
||||
|
||||
+86
-17
@@ -9,7 +9,7 @@ import asyncio
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
@@ -100,6 +100,16 @@ class TradingBot:
|
||||
self._last_news_check: Optional[datetime] = None
|
||||
self._last_regime = None
|
||||
self._trading_paused = start_paused
|
||||
# Root cause #2 fix: min elapsed wall-clock between entries with the SAME
|
||||
# (strategy, direction). Live loop re-evaluates ~30 s; without this the
|
||||
# same signal stacks identical scalps across consecutive cycles.
|
||||
self._last_entry_block_until: dict[tuple[str, str], datetime] = {}
|
||||
self._cooldown_seconds = self.settings.trading_params.get("cooldown", {})
|
||||
# Root cause #4 fix: performance snapshots on WALL-CLOCK cadence, not
|
||||
# cycle count. cycle % 100 == 0 resets on every restart (3 restarts
|
||||
# today wiped the counter → performance_snapshots stayed at 0 rows).
|
||||
self._last_snapshot_at: Optional[datetime] = None
|
||||
self._snapshot_interval_minutes = 30
|
||||
|
||||
def _setup_logging(self):
|
||||
"""Configure loguru logging."""
|
||||
@@ -151,6 +161,12 @@ class TradingBot:
|
||||
self.price_predictor.load_model()
|
||||
self.signal_classifier.load_model()
|
||||
|
||||
# Pre-warm FinBERT in a daemon thread: the first `from transformers
|
||||
# import pipeline` takes 15-30s on this machine (Python 3.12
|
||||
# importlib.metadata scan). Running it off the loop keeps the main
|
||||
# trading loop responsive; news falls back to the lexicon until ready.
|
||||
self.news_analyzer._warm_finbert()
|
||||
|
||||
# Display account info
|
||||
account = self.mt5.get_account_info()
|
||||
if account:
|
||||
@@ -164,8 +180,12 @@ class TradingBot:
|
||||
if self.settings.demo_mode:
|
||||
logger.warning("⚠️ DEMO MODE — Trades will execute on demo account")
|
||||
|
||||
# Start interactive 2-way Telegram polling in background (with network auto-retry)
|
||||
await self.telegram.start_polling()
|
||||
# Start interactive 2-way Telegram polling in the background (with network
|
||||
# auto-retry). Run as a task instead of awaiting it: when api.telegram.org is
|
||||
# unreachable, start_polling retries up to 30 times (~5 min) — blocking here
|
||||
# would stall the trading loop on every boot. Alerts sent before polling is up
|
||||
# fall back to a standalone Bot client in send_message.
|
||||
self._telegram_poll_task = asyncio.create_task(self.telegram.start_polling())
|
||||
|
||||
# Send startup notification via Telegram
|
||||
mode = "DEMO" if self.settings.demo_mode else "LIVE"
|
||||
@@ -194,7 +214,8 @@ class TradingBot:
|
||||
f"• <b>Symbol:</b> <code>{self.settings.symbol}</code>"
|
||||
)
|
||||
|
||||
await self.telegram.send_bot_status(status_title, details)
|
||||
# Startup notification (non-blocking — same reachability concern as above)
|
||||
asyncio.create_task(self.telegram.send_bot_status(status_title, details))
|
||||
|
||||
# Start background task scheduler (non-blocking news, economic calendar & daily report)
|
||||
self.scheduler.add_news_job(
|
||||
@@ -260,6 +281,7 @@ class TradingBot:
|
||||
# ── Step 2: Fetch market data ────────────────────────────
|
||||
# Get data for multiple timeframes
|
||||
df_m5 = self.mt5.get_rates(timeframe="M5", count=500)
|
||||
df_m15 = self.mt5.get_rates(timeframe="M15", count=500)
|
||||
df_h1 = self.mt5.get_rates(timeframe="H1", count=500)
|
||||
df_h4 = self.mt5.get_rates(timeframe="H4", count=500)
|
||||
df_d1 = self.mt5.get_rates(timeframe="D1", count=500)
|
||||
@@ -278,7 +300,13 @@ class TradingBot:
|
||||
regime = None
|
||||
|
||||
# ── Step 4: Get AI predictions ───────────────────────────
|
||||
ai_prediction = self.signal_classifier.predict(df_h1)
|
||||
# The XGBoost model is trained on M15 (see scripts/train_ai.py and the
|
||||
# periodic retrain job) with a 5-candle forward-return label (~75 min),
|
||||
# so feed it M15 data — H1 would be an out-of-timeframe distribution shift.
|
||||
if df_m15 is not None:
|
||||
ai_prediction = self.signal_classifier.predict(df_m15)
|
||||
else:
|
||||
ai_prediction = {"direction": "HOLD", "confidence": 0.0}
|
||||
|
||||
# ── Step 5: Check economic events & news ─────────────────
|
||||
imminent, ev_name = self.economic_events.is_high_impact_imminent()
|
||||
@@ -368,7 +396,24 @@ class TradingBot:
|
||||
|
||||
# ── Step 7: Execute if conditions met ────────────────────
|
||||
if best_signal and best_confluence and best_confluence.should_execute:
|
||||
result = self.trade_executor.execute_signal(
|
||||
# Root cause #2 fix: entry cooldown per (strategy, direction).
|
||||
strategy = best_signal.strategy
|
||||
direction = best_signal.direction.value
|
||||
cooldown_secs = int(self._cooldown_seconds.get(strategy, 0))
|
||||
key = (strategy, direction)
|
||||
if cooldown_secs > 0:
|
||||
now = datetime.now(timezone.utc)
|
||||
until = self._last_entry_block_until.get(key)
|
||||
if until is not None and now < until:
|
||||
logger.info(
|
||||
f"⏳ Cooldown active for {strategy}/{direction} "
|
||||
f"until {until:%H:%M:%S} — signal rejected (root cause #2 fix)"
|
||||
)
|
||||
best_signal = None
|
||||
best_confluence = None
|
||||
|
||||
if best_signal and best_confluence:
|
||||
result = self.trade_executor.execute_signal(
|
||||
signal=best_signal,
|
||||
confluence_score=best_confluence.total_score,
|
||||
sentiment_score=sentiment.get("score", 0) if sentiment else 0,
|
||||
@@ -376,17 +421,31 @@ class TradingBot:
|
||||
ai_confidence=ai_prediction.get("confidence", 0),
|
||||
regime=regime.regime.value if regime else "",
|
||||
risk_modifier=regime.position_size_modifier if regime else 1.0,
|
||||
# Root cause #3 fix: record per-source confluence
|
||||
# attribution (technical/ai/regime) with each signal.
|
||||
ai_score=getattr(best_confluence, "ai_score", 0.0),
|
||||
regime_score=getattr(best_confluence, "regime_score", 0.0),
|
||||
)
|
||||
|
||||
if result:
|
||||
logger.success(f"🎯 Trade opened: {result}")
|
||||
# Arm cooldown: no new same-(strategy,direction) entry for
|
||||
# cooldown_secs, so the ~30 s loop cannot stack the signal.
|
||||
if cooldown_secs > 0:
|
||||
self._last_entry_block_until[key] = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=cooldown_secs)
|
||||
)
|
||||
# Send Telegram notification
|
||||
await self.telegram.send_trade_opened(result)
|
||||
|
||||
# ── Step 8: Manage trailing stops ────────────────────────
|
||||
if df_h1 is not None and "atr" not in df_h1.columns:
|
||||
df_h1 = self.technical.add_atr(df_h1)
|
||||
current_atr = float(df_h1["atr"].iloc[-1]) if df_h1 is not None and "atr" in df_h1.columns else 2.0
|
||||
# Root cause #1 fix: trail on M5 ATR (≈$4, same as SL/TP
|
||||
# calibration & the backtest gate). H1 ATR (≈$16) made the
|
||||
# effective trail distance ≈$32 — structurally too wide to
|
||||
# ever trail an M5 scalp, so every trade ran to SL.
|
||||
if df_m5 is not None and "atr" not in df_m5.columns:
|
||||
df_m5 = self.technical.add_atr(df_m5)
|
||||
current_atr = float(df_m5["atr"].iloc[-1]) if df_m5 is not None and "atr" in df_m5.columns else 2.0
|
||||
self.trailing_stop.update_all_positions(current_atr)
|
||||
|
||||
# ── Step 8b: Smart Midway Exit Guardian ───────────────────
|
||||
@@ -408,18 +467,24 @@ class TradingBot:
|
||||
)
|
||||
|
||||
# ── Step 9: Sync positions, Learn & Notify Closes ────────
|
||||
# Root cause #3 fix: sync_positions() feeds trade_learner
|
||||
# internally with the honest DB context (_learner_context).
|
||||
# Do NOT call on_trade_closed here too — it would double-count
|
||||
# every externally-closed trade (multiplier decays twice).
|
||||
closed_trades = self.trade_executor.sync_positions()
|
||||
for c_trade in closed_trades:
|
||||
# Autonomous learning adaptation from closed trade outcome
|
||||
try:
|
||||
lesson_res = self.trade_learner.on_trade_closed(c_trade)
|
||||
except Exception as e:
|
||||
logger.error(f"Error running trade learning on #{c_trade.get('ticket')}: {e}")
|
||||
await self.telegram.send_trade_closed(c_trade)
|
||||
|
||||
# ── Step 10: Performance snapshot (every 100 cycles) ─────
|
||||
if self._cycle_count % 100 == 0:
|
||||
# ── Step 10: Performance snapshot (wall-clock cadence) ────
|
||||
# Root cause #4 fix: save at boot (first cycle) and every
|
||||
# >= snapshot_interval_minutes, independent of restart-count
|
||||
# or cycle-count resets.
|
||||
now_ts = datetime.now(timezone.utc)
|
||||
if self._last_snapshot_at is None or (
|
||||
now_ts - self._last_snapshot_at
|
||||
) >= timedelta(minutes=self._snapshot_interval_minutes):
|
||||
await self._save_performance_snapshot()
|
||||
self._last_snapshot_at = now_ts
|
||||
|
||||
# ── Timing ───────────────────────────────────────────────
|
||||
elapsed = time.time() - cycle_start
|
||||
@@ -708,7 +773,7 @@ class TradingBot:
|
||||
logger.error(f"News processing error: {e}")
|
||||
|
||||
async def _save_performance_snapshot(self):
|
||||
"""Save periodic performance metrics."""
|
||||
"""Save periodic performance metrics (wall-clock cadence)."""
|
||||
try:
|
||||
account = self.mt5.get_account_info()
|
||||
if not account:
|
||||
@@ -728,6 +793,10 @@ class TradingBot:
|
||||
win_rate=stats["win_rate"],
|
||||
profit_factor=stats["profit_factor"],
|
||||
)
|
||||
logger.success(
|
||||
f"📸 Performance snapshot saved | balance=${account['balance']:.2f} "
|
||||
f"| 30d PF={stats['profit_factor']} ({stats['total_trades']} trades)"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Performance snapshot error: {e}")
|
||||
|
||||
+58
-10
@@ -40,6 +40,8 @@ class TradeExecutor:
|
||||
ai_confidence: float = 0.0,
|
||||
regime: str = "",
|
||||
risk_modifier: float = 1.0,
|
||||
ai_score: float = 0.0,
|
||||
regime_score: float = 0.0,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Execute a trading signal after full risk validation.
|
||||
@@ -52,6 +54,8 @@ class TradeExecutor:
|
||||
ai_confidence: AI confidence level.
|
||||
regime: Current market regime.
|
||||
risk_modifier: Position size modifier from regime.
|
||||
ai_score: AI prediction contribution to confluence (root cause #3 attribution).
|
||||
regime_score: Regime alignment contribution to confluence.
|
||||
|
||||
Returns:
|
||||
Trade result dict or None if rejected.
|
||||
@@ -75,6 +79,8 @@ class TradeExecutor:
|
||||
confluence_score=confluence_score,
|
||||
technical_score=signal.technical_score,
|
||||
sentiment_score=sentiment_score,
|
||||
ai_score=ai_score,
|
||||
regime_score=regime_score,
|
||||
was_executed=False,
|
||||
rejection_reason=reason,
|
||||
price_at_signal=signal.entry_price,
|
||||
@@ -92,6 +98,8 @@ class TradeExecutor:
|
||||
confluence_score=confluence_score,
|
||||
technical_score=signal.technical_score,
|
||||
sentiment_score=sentiment_score,
|
||||
ai_score=ai_score,
|
||||
regime_score=regime_score,
|
||||
was_executed=False,
|
||||
rejection_reason=reason,
|
||||
price_at_signal=signal.entry_price,
|
||||
@@ -125,6 +133,8 @@ class TradeExecutor:
|
||||
confluence_score=confluence_score,
|
||||
technical_score=signal.technical_score,
|
||||
sentiment_score=sentiment_score,
|
||||
ai_score=ai_score,
|
||||
regime_score=regime_score,
|
||||
was_executed=False,
|
||||
rejection_reason=validation_reason,
|
||||
price_at_signal=signal.entry_price,
|
||||
@@ -154,6 +164,8 @@ class TradeExecutor:
|
||||
confluence_score=confluence_score,
|
||||
technical_score=signal.technical_score,
|
||||
sentiment_score=sentiment_score,
|
||||
ai_score=ai_score,
|
||||
regime_score=regime_score,
|
||||
was_executed=False,
|
||||
rejection_reason=f"Execution error: {error_msg}",
|
||||
price_at_signal=signal.entry_price,
|
||||
@@ -188,6 +200,8 @@ class TradeExecutor:
|
||||
confluence_score=confluence_score,
|
||||
technical_score=signal.technical_score,
|
||||
sentiment_score=sentiment_score,
|
||||
ai_score=ai_score,
|
||||
regime_score=regime_score,
|
||||
was_executed=True,
|
||||
price_at_signal=actual_price,
|
||||
)
|
||||
@@ -235,7 +249,7 @@ class TradeExecutor:
|
||||
commission = 0.0
|
||||
closed_at = None
|
||||
|
||||
crud.close_trade(
|
||||
closed_trade = crud.close_trade(
|
||||
ticket=ticket,
|
||||
exit_price=exit_price,
|
||||
profit=profit,
|
||||
@@ -248,20 +262,43 @@ class TradeExecutor:
|
||||
)
|
||||
try:
|
||||
from ai.trade_learner import trade_learner
|
||||
trade_learner.on_trade_closed({
|
||||
"ticket": ticket,
|
||||
"strategy": position.get("comment", "scalping").replace("close_", ""),
|
||||
"direction": position.get("type", "BUY"),
|
||||
"profit": profit,
|
||||
"regime": "RANGING",
|
||||
"confluence_score": 75.0,
|
||||
"sentiment_score": 0.0,
|
||||
})
|
||||
# Root cause #3 fix: feed the self-learning engine the REAL
|
||||
# context from the DB trades row. The old code hardcoded
|
||||
# regime=RANGING / confluence=75 / sentiment=0, so every
|
||||
# lesson was misclassified and strat_scalping decayed to 0.527.
|
||||
trade_learner.on_trade_closed(
|
||||
self._learner_context(closed_trade, profit)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"TradeLearner hook warning for #{ticket}: {e}")
|
||||
|
||||
return success
|
||||
|
||||
def _learner_context(self, trade, profit: float) -> dict:
|
||||
"""Build an honest TradeLearner context from the DB trades row.
|
||||
|
||||
Shared by close_trade and sync_positions so both paths feed the
|
||||
self-learning engine the same truthful inputs (root cause #3 fix):
|
||||
strategy, direction, profit, regime, confluence, sentiment, AI call.
|
||||
"""
|
||||
from database.models import Trade as _Trade # local: avoid import cycle
|
||||
if trade is None:
|
||||
return {"ticket": 0, "strategy": "scalping", "direction": "BUY",
|
||||
"profit": profit, "regime": "RANGING",
|
||||
"confluence_score": 0.0, "sentiment_score": 0.0,
|
||||
"ai_prediction": "", "ai_confidence": 0.0}
|
||||
return {
|
||||
"ticket": getattr(trade, "ticket", 0),
|
||||
"strategy": getattr(trade, "strategy", "scalping") or "scalping",
|
||||
"direction": getattr(trade, "order_type", "BUY") or "BUY",
|
||||
"profit": profit,
|
||||
"regime": getattr(trade, "regime", "") or "RANGING",
|
||||
"confluence_score": float(getattr(trade, "confluence_score", 0.0) or 0.0),
|
||||
"sentiment_score": float(getattr(trade, "sentiment_score", 0.0) or 0.0),
|
||||
"ai_prediction": getattr(trade, "ai_prediction", "") or "",
|
||||
"ai_confidence": float(getattr(trade, "ai_confidence", 0.0) or 0.0),
|
||||
}
|
||||
|
||||
def close_partial(self, ticket: int, close_pct: float = 50.0) -> bool:
|
||||
"""Close a percentage of a position."""
|
||||
positions = self.mt5.get_open_positions()
|
||||
@@ -329,6 +366,17 @@ class TradeExecutor:
|
||||
"commission": commission,
|
||||
"reason": reason,
|
||||
})
|
||||
# Root cause #3 fix: externally-closed positions also feed
|
||||
# the self-learning engine with the same honest DB context.
|
||||
try:
|
||||
from ai.trade_learner import trade_learner
|
||||
trade_learner.on_trade_closed(
|
||||
self._learner_context(trade, profit)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"TradeLearner hook warning for externally-closed #{trade.ticket}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process externally closed position #{trade.ticket}: {e}")
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class TrailingStopManager:
|
||||
self.breakeven_rr = params.get("breakeven_trigger_rr", 1.0)
|
||||
self.partial_close_pct = params.get("partial_close_pct", 50)
|
||||
self.atr_trail_multiplier = params.get("atr_trail_multiplier", 2.0)
|
||||
self.trail_trigger_atr = params.get("trail_trigger_atr", 1.2)
|
||||
|
||||
# Track which positions have had breakeven applied
|
||||
self._breakeven_applied: set[int] = set()
|
||||
@@ -107,11 +108,24 @@ class TrailingStopManager:
|
||||
)
|
||||
|
||||
# ── 2. ATR Trailing Stop ─────────────────────────────────────────
|
||||
# Backtest parity (gate winner): trail only AFTER the position shows
|
||||
# trail_trigger_atr × ATR unrealized gain — no trailing from entry.
|
||||
if pos_type == "BUY":
|
||||
gain = current_price - entry_price
|
||||
else:
|
||||
gain = entry_price - current_price
|
||||
trailing_armed = gain > current_atr * self.trail_trigger_atr
|
||||
if not trailing_armed:
|
||||
return # Below trigger: never chase the entry-level trail
|
||||
|
||||
trail_distance = current_atr * self.atr_trail_multiplier
|
||||
|
||||
if pos_type == "BUY":
|
||||
# Trail up: new SL = current_price - (ATR * multiplier)
|
||||
proposed_sl = round(current_price - trail_distance, 2)
|
||||
# Never trail past TP (backtest parity)
|
||||
tick = 0.01 # XAUUSD price tick
|
||||
proposed_sl = min(proposed_sl, round(current_tp - tick, 2))
|
||||
|
||||
# Only move SL up, never down
|
||||
if proposed_sl > current_sl and proposed_sl < current_price:
|
||||
@@ -126,6 +140,9 @@ class TrailingStopManager:
|
||||
elif pos_type == "SELL":
|
||||
# Trail down: new SL = current_price + (ATR * multiplier)
|
||||
proposed_sl = round(current_price + trail_distance, 2)
|
||||
# Never trail past TP (backtest parity)
|
||||
tick = 0.01 # XAUUSD price tick
|
||||
proposed_sl = max(proposed_sl, round(current_tp + tick, 2))
|
||||
|
||||
# Only move SL down (for shorts), never up
|
||||
if proposed_sl < current_sl and proposed_sl > current_price:
|
||||
|
||||
@@ -142,7 +142,7 @@ def test_strategy_dynamic_confluence_adaptation():
|
||||
|
||||
# Switch to Moderate -> override MUST clear!
|
||||
settings.set_active_mode("moderate")
|
||||
assert scalp.min_confluence_score == 50.0, "Scalping override leaked into moderate mode!"
|
||||
assert scalp.min_confluence_score == 55.0, "Scalping override leaked into moderate mode!"
|
||||
assert scalp.min_technical_score == 35.0
|
||||
assert day.min_confluence_score == 55.0
|
||||
assert swing.min_confluence_score == 60.0
|
||||
|
||||
Reference in New Issue
Block a user