7 Commits
Author SHA1 Message Date
gavindiazandClaude Code 1a7b0111ad fix(startup): build asyncio socketpair over AF_UNIX - unblocks .bat console freeze
The run_bot.bat console path froze for minutes after two log lines with 0 CPU:
asyncio.run() -> ProactorEventLoop.__init__ -> socket.socketpair() -> Python
3.12 _fallback_socketpair() blocks forever in accept() on the loopback TCP
handshake (security software filters 127.0.0.1 in this interactive console +
xau-trader.exe session). Git-bash redirect runs worked 4/4, proving it is
environment, not interpreter.

Windows 10 17063+ supports AF_UNIX sockets, which need no loopback TCP at all.
Monkey-patch socket.socketpair on win32 to self-pair over AF_UNIX via ctypes
(ws2_32; python.org builds don't expose the AF_UNIX constant), falling back to
the stock socketpair if AF_UNIX is unavailable. socket.socket(fileno=N) treats
N as a raw SOCKET handle on Windows, so the raw handles wrap cleanly.

Verified: smoke test raw pair family=1 + full asyncio.run Proactor loop; the
exe boot went from hang to 'Main loop started' in ~10s.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 20:58:08 +08:00
gavindiazandClaude Code f38fa944ea 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>
2026-10-21 20:57:42 +08:00
gavindiazandClaude Code 7e50a0fef6 feat(llm): provider-agnostic OpenAI-compatible client; FinBERT warm-up off the event loop
Replace the Google Gemini lock-in with a unified chat client usable with any
OpenAI-compatible /v1 endpoint (OpenAI, OpenRouter, DeepSeek, Moonshot, Qwen,
Ollama, Gemini-via-compat...). Switch provider purely via AI_BASE_URL/AI_MODEL.

- ai/llm_client.py (new): lazy openai SDK, per-candidate 429/503/timeout
  failover across AI_MODEL_FAILOVER, robust JSON extraction, reasoning-budget
  retry, image analysis via base64 data URI, singleton get_llm_client()
- settings.py: GeminiSettings -> LLMSettings (api_key/base_url/model/failover/
  vision_model), keeps GEMINI_API_KEY fallback so existing .env keeps working
- news_analyzer.py: analyze_with_gemini -> analyze_with_llm (same output keys);
  FinBERT now loads in a daemon warm-up thread with lexicon fallback - the
  transformers first import was blocking the asyncio main loop 15-45s
  (importlib.metadata full package scan); HF_ENDPOINT hf-mirror setdefault
- telegram_bot.py: news digest + AI-vision chart analysis via the LLM client
- requirements: google-genai -> openai>=1.0; docs/.env.example/README updated
  for the new provider-agnostic setup; dashboard meta updated

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 20:56:52 +08:00
gavindiazandClaude Code 886de825fa feat(backtest): real-data gate runners for scalping and day_trading + grid results
The prove-before-live gate tooling. Both runners fetch real MT5 history
(M5/H1 for simulation, M15 for AI, H4 for regime), build zero-lookahead
time-aligned lookups, run the live-parity engine, append config->metrics rows
to grid_results.csv and print the gate verdict:

  expectancy > 0 AND profit_factor >= 1.2 AND max_drawdown_pct <= 15 AND total_trades >= 60

- run_real_backtest.py: scalping gate mirroring live moderate params
- backtest_day_trading_gate.py: H1-frame gate for the day_trading family
  (isolation loop disables scalping/swing), compares conf=live(55) vs
  conf=raw(70); 5-month run: conf=55 PASS (72 trades, WR 70.8%, PF 3.45,
  exp +47.91, maxDD 2.25%), conf=70 FAIL (0 trades - threshold unbeatable)
- grid_results.csv: full scan history incl. the 5-month all-configs sweep
  whose baseline (50/0/2.0: 2214 trades, PF 1.50) proved live losses were
  the execution layer, not the entry spec

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 20:56:27 +08:00
gavindiazandClaude Code d9b0b19c02 feat(backtest): live-parity backtest engine with zero-lookahead AI/regime lookups
Phase 1 of the prove-before-live alignment. The backtest engine now mirrors
the live signal path instead of hardcoding HOLD/NEUTRAL:

- predict_batch (signal_classifier): vectorized per-bar AI signals reusing the
  exact live predict() pipeline (class-index mapping, conf<0.5 -> HOLD)
- backtester: per-bar time-aligned AI lookup (M15 close = signal-available
  time, searchsorted, no lookahead), exogenous H4 regime plan with dynamic
  confluence-key deltas, AttentionMarketScorer alignment, per-strategy entry
  cooldown, max-hold timeout exit, breakeven + trailing capped at TP, running-
  equity pnl_pct (was: static initial balance)
- fix: store direction.value on entry (Enum vs str compare silently disabled
  every exit - no trade ever closed)
- fix: _check_mode_sync only clears override on genuine mode transition (grid
  threshold injection was wiped on first read)
- SignalAggregator(use_adaptive_weights=True): backtest uses pure config
  weights so garbage live adaptive weights can't contaminate gate scores

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 20:55:45 +08:00
gavindiazandClaude Code 661fd8d941 fix(learner): scope mistake-guard thresholds to each strategy's own confluence base
The guard previously hardcoded the scalping base for every strategy, so
day_trading and swing_trading signals were screened against scalping's
numbers. New _strategy_base_confluence() resolves each strategy to its own
settings section (day_trading_params / swing_trading_params), mirroring
BaseStrategy.min_confluence_score routing; unknown names fall back to
scalping (legacy). Moderate-mode parity verified by smoke test: penalty
override 55+6=61, memory veto 55+8=63 for both day_trading and scalping.
Full suite: 39 tests pass.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 20:47:10 +08:00
gavindiazandClaude Code 239f4108df fix(learner): unblock Mistake Guard deadlock — conditional veto + time decay
The guard vetoed UNCONDITIONALLY whenever a strategy multiplier fell below
0.65x, never comparing the confluence the '+6' message claimed. A stale 0.408x
SCALPING multiplier (garbage-era lessons incl. one double-learned duplicate)
then locked the bot out permanently: no trades → no lessons → no recovery.

Now the veto is conditional on confluence >= base + 6 (penalty still applied
to effective_score downstream), and multipliers time-decay back toward 1.0
(~24h constant, persisted without touching win/loss counts).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 17:13:12 +08:00
23 changed files with 1791 additions and 277 deletions
+29 -11
View File
@@ -31,7 +31,7 @@ Processes 61 normalized features computed on every tick/candle:
- **Auto-Retraining Pipeline**: Standalone script (`scripts/train_ai.py`) for automated hyperparameter tuning and model export.
### 🧠 4. Autonomous Self-Learning & Mistake Memory Guard
- **Trade Post-Mortem Analysis**: Powered by Google Gemini LLM to analyze every losing trade.
- **Trade Post-Mortem Analysis**: Powered by a configurable OpenAI-compatible LLM (OpenAI, OpenRouter, DeepSeek, Moonshot, Qwen, Ollama…) to analyze every losing trade.
- **Vector Database Memory**: Stores failure context in local `ChromaDB`.
- **Pre-Trade Guard Check**: Queries past mistake memory before issuing new trades to prevent repeating historic bad entries in similar market states.
@@ -166,8 +166,14 @@ MT5_SERVER=Exness-MT5Trial6
TELEGRAM_BOT_TOKEN=123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ
TELEGRAM_CHAT_ID=987654321
# Google Gemini API Key (For Sentiment & Self-Learning Memory)
GEMINI_API_KEY=AIzaSyYourGeminiApiKeyHere
# OpenAI-compatible LLM (OpenAI / OpenRouter / DeepSeek / Moonshot / Qwen / Ollama...)
# Switch provider purely by pointing AI_BASE_URL at any OpenAI-compatible /v1 endpoint.
AI_API_KEY=your_openai_compatible_api_key
AI_BASE_URL=https://openrouter.ai/api/v1
AI_MODEL=openrouter/auto
# Legacy fallback: AI_API_KEY empty → uses GEMINI_API_KEY (if your provider is Google
# Gemini's OpenAI-compatible endpoint: https://generativelanguage.googleapis.com/v1beta/openai/)
GEMINI_API_KEY=you_can_leave_this_blank
# Bot Operational Settings
SYMBOL=XAUUSD
@@ -186,17 +192,29 @@ To configure the bot to automatically start actively trading in the background w
2. The bot will automatically launch in **Active Trading** mode (live or demo) upon startup and connect to MT5.
3. You can pause, monitor, or stop it anytime using the Telegram remote controller on your phone.
### Start Full Suite (Trading Bot + Web Dashboard + Telegram Bot)
```bash
python main.py --dashboard
```
- **Web Dashboard**: Access at [`http://127.0.0.1:8080`](http://127.0.0.1:8080)
- **Telegram Bot**: Active and responding to chat commands.
### Start the Trading Bot (Auto-Trading, UI-not-required)
> ⚠️ `python main.py --dashboard` starts **only the web dashboard** — it does
> **not** run the trading engine. To auto-trade you must launch the bot itself.
### Start Trading Bot Only (Headless)
```bash
python main.py
```
- Connects to your MT5 account (demo by default) and starts trading immediately.
- Fully autonomous: strategies, risk management and news filtering run on their own.
- Keep this terminal window open; closing it stops trading.
### Web Dashboard (optional — separate terminal window)
```bash
python main.py --dashboard
```
- **Web Dashboard**: Access at [`http://127.0.0.1:8080`](http://127.0.0.1:8080) for live P&L, positions and charts.
### Start in Standby Mode (Telegram Remote Control)
```bash
python main.py --standby
```
- Bot connects to MT5 but **pauses trading**; start/resume it later from your phone via the Telegram controller.
### Retrain XGBoost AI Model
To fetch fresh M5/M15 historical data from MT5 and train the XGBoost classifier on 10,000 candles:
@@ -205,7 +223,7 @@ python scripts/train_ai.py
```
### Run Diagnostic System Audit
To test MT5 connectivity, Gemini LLM API, News Deduplication, Web Dashboard, and Self-Attention Engine:
To test MT5 connectivity, the configured LLM API, News Deduplication, Web Dashboard, and Self-Attention Engine:
```bash
python audit_full_system.py
```
+321
View File
@@ -0,0 +1,321 @@
"""
Unified OpenAI-compatible LLM client.
Works with any provider exposing an OpenAI-compatible /v1 chat-completions API:
OpenAI https://api.openai.com/v1 (default)
OpenRouter https://openrouter.ai/api/v1
DeepSeek https://api.deepseek.com/v1
Moonshot https://api.moonshot.cn/v1
Qwen https://dashscope.aliyuncs.com/compatible-mode/v1
Gemini https://generativelanguage.googleapis.com/v1beta/openai/
Ollama http://localhost:11434/v1
Switch provider by changing AI_BASE_URL / AI_MODEL in config/.env
(see config/.env.example). A comma-separated AI_MODEL_FAILOVER list is tried
in order before an LLMError is raised, so 429 / 503 / model-not-found spikes
fail over automatically.
"""
import base64
import json
import re
from typing import Any, Dict, List, Optional
from loguru import logger
from config.settings import get_settings
# ── Error type ──────────────────────────────────────────────────────────────
class LLMError(Exception):
"""Raised when the LLM request fails after all candidate models were tried.
Attributes:
message: Human-readable description.
status: HTTP status (or "Timeout" / "Connection" / "Parse") that
dominated the failure — useful for upstream cooldown logic.
"""
def __init__(self, message: str, status: str = ""):
super().__init__(message)
self.message = message
self.status = status
# ── Lazy openai import (graceful degrade if SDK missing) ────────────────────
_client_type = None
_SDK_EXC_TYPES: tuple = ()
def _resolve_openai():
"""Import the openai AsyncOpenAI client lazily and cache it."""
global _client_type, _SDK_EXC_TYPES
if _client_type is not None:
return _client_type
try:
from openai import AsyncOpenAI
from openai import (
APIConnectionError as _APIConnectionError,
APIStatusError as _APIStatusError,
APITimeoutError as _APITimeoutError,
AuthenticationError as _AuthenticationError,
NotFoundError as _NotFoundError,
)
_SDK_EXC_TYPES = (
_APIConnectionError,
_APIStatusError,
_APITimeoutError,
_AuthenticationError,
_NotFoundError,
)
_client_type = AsyncOpenAI
return _client_type
except ImportError as e: # pragma: no cover — SDK listed in requirements
raise LLMError(
"openai SDK not installed. Run: pip install openai>=1.0"
) from e
# ── Client ──────────────────────────────────────────────────────────────────
class LLMClient:
"""Provider-agnostic OpenAI-compatible chat client with model failover."""
def __init__(self):
s = get_settings()
llm = s.llm
self.api_key = llm.api_key or llm.gemini_api_key
self.base_url = llm.base_url.rstrip("/") if llm.base_url else ""
self.max_tokens = llm.max_tokens
self.temperature = llm.temperature
self.timeout = llm.timeout
self.candidates: List[str] = self._dedup_preserve([llm.model, *llm.model_failover])
if not self.candidates:
self.candidates = [llm.model or "gpt-4o-mini"]
vision_first = (llm.vision_model or llm.model) or self.candidates[0]
self.vision_candidates: List[str] = self._dedup_preserve([vision_first, *llm.model_failover])
@staticmethod
def _dedup_preserve(items: List[str]) -> List[str]:
seen: set[str] = set()
out: List[str] = []
for it in items:
it = (it or "").strip()
if it and it not in seen:
seen.add(it)
out.append(it)
return out
def _client(self) -> Any:
cls = _resolve_openai()
return cls(api_key=self.api_key or "missing-api-key", base_url=self.base_url or None)
# ── Public API ──────────────────────────────────────────────────────────
async def complete(
self,
prompt: str,
*,
system: Optional[str] = None,
model: Optional[str] = None,
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
) -> str:
"""Generate plain text. Tries every candidate model before raising."""
messages: List[Dict[str, Any]] = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
candidates = self._dedup_preserve([model, *self.candidates]) if model else self.candidates
return await self._run_candidates(
candidates,
messages,
max_tokens=max_tokens or self.max_tokens,
temperature=temperature if temperature is not None else self.temperature,
)
async def complete_json(
self,
prompt: str,
*,
system: Optional[str] = None,
model: Optional[str] = None,
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
) -> Dict[str, Any]:
"""Generate text, then extract the first embedded JSON object."""
text = await self.complete(
prompt,
system=system,
model=model,
max_tokens=max_tokens,
temperature=temperature,
)
try:
return self._extract_json(text)
except (ValueError, json.JSONDecodeError) as e:
raise LLMError(f"Cannot parse JSON from LLM response: {e}", status="Parse") from e
async def analyze_image(
self,
image_bytes: bytes,
prompt: str,
*,
mime_type: str = "image/jpeg",
model: Optional[str] = None,
) -> str:
"""Vision analysis of an image (base64 data-URI image_url payload)."""
data_url = f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode()}"
content: List[Dict[str, Any]] = [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_url}},
]
messages = [{"role": "user", "content": content}]
candidates = self._dedup_preserve([model, *self.vision_candidates]) if model else self.vision_candidates
return await self._run_candidates(
candidates,
messages,
max_tokens=self.max_tokens,
temperature=self.temperature,
)
# ── Internals ───────────────────────────────────────────────────────────
@staticmethod
def _extract_json(text: str) -> Dict[str, Any]:
"""Robust JSON extraction from potential markdown / prose."""
cleaned = text.strip()
# Strip ```json ... ``` code fences if present
fenced = re.search(r"```(?:json)?\s*([\s\S]*?)```", cleaned)
if fenced:
cleaned = fenced.group(1).strip()
match = re.search(r"\{[\s\S]*\}", cleaned)
if match:
return json.loads(match.group(0))
return json.loads(cleaned)
async def _run_candidates(
self,
candidates: List[str],
messages: List[Dict[str, Any]],
*,
max_tokens: int,
temperature: float,
) -> str:
client = self._client()
errors: List[str] = []
dominant: Dict[str, int] = {} # status -> count, to pick dominant for LLMError
for model_name in candidates:
# Note: we deliberately do NOT pass response_format={"type": "json_object"}
# — many OpenAI-compatible providers don't support it. JSON is extracted
# robustly from plain prose in _extract_json instead.
retried = False # one budget-grow retry per candidate (reasoning models)
try:
resp = await client.chat.completions.create(
model=model_name,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
timeout=self.timeout,
)
if not resp.choices:
raise LLMError(f"Model {model_name} returned no choices", status="Empty")
msg = resp.choices[0].message
content = msg.content
if content is None or not content.strip():
# Reasoning models (e.g. some Qwen variants) burn the whole
# token budget in the `reasoning` field and never emit final
# content (finish_reason == "length"). If reasoning text is
# present, grow the budget and retry once so the model can
# finish its answer; if there is nothing at all, treat as
# a genuine empty response from this candidate.
reasoning = getattr(msg, "reasoning", None) or getattr(msg, "reasoning_content", None)
if reasoning and not retried:
retried = True
grow = max(max_tokens * 2, 8192)
logger.debug(
f"Model {model_name} only produced reasoning ({len(str(reasoning))} chars), "
f"retrying with max_tokens={grow}..."
)
resp = await client.chat.completions.create(
model=model_name,
messages=messages,
max_tokens=grow,
temperature=temperature,
timeout=self.timeout,
)
if resp.choices:
content = resp.choices[0].message.content
if content is None or not content.strip():
raise LLMError(
f"Model {model_name} returned empty content"
+ (f" (only {len(str(reasoning))} chars of reasoning)" if reasoning else ""),
status="Empty",
)
logger.debug(f"LLM response from {model_name} ({len(content)} chars)")
return content
except _SDK_EXC_TYPES as e:
status = self._classify_error(e)
dominant[status] = dominant.get(status, 0) + 1
errors.append(f"{model_name}: {self._short_status(e)}")
logger.debug(f"LLM model {model_name} unavailable ({status}), trying next candidate...")
continue
except LLMError as e:
errors.append(f"{model_name}: {e.message}")
dominant[e.status or "Empty"] = dominant.get(e.status or "Empty", 0) + 1
logger.debug(f"LLM model {model_name} failed ({e.status}), trying next candidate...")
continue
except Exception as e: # defensive: unknown SDK errors also fail over
status = self._classify_error(e)
dominant[status] = dominant.get(status, 0) + 1
errors.append(f"{model_name}: {type(e).__name__}: {e}")
logger.debug(f"LLM model {model_name} error ({status}), trying next candidate...")
continue
top_status = max(dominant.items(), key=lambda kv: kv[1])[0] if dominant else "Error"
detail = "; ".join(errors) or "no candidate models available"
raise LLMError(f"All LLM candidate models failed: {detail}", status=top_status)
@staticmethod
def _classify_error(e: Exception) -> str:
status = getattr(e, "status_code", None) or getattr(e, "status", None)
if status:
return str(status)
name = type(e).__name__
if "Timeout" in name:
return "Timeout"
if "Connection" in name:
return "Connection"
return name
@staticmethod
def _short_status(e: Exception) -> str:
detail = getattr(e, "message", str(e)) or str(e)
detail = str(detail).strip()
if len(detail) > 80:
detail = detail[:77] + "..."
return f"{LLMClient._classify_error(e)} {detail}"
# ── Singleton ───────────────────────────────────────────────────────────────
_llm_client: Optional[LLMClient] = None
def get_llm_client() -> LLMClient:
"""Get or create the singleton LLM client."""
global _llm_client
if _llm_client is None:
_llm_client = LLMClient()
return _llm_client
+57
View File
@@ -166,3 +166,60 @@ class SignalClassifier:
except Exception as e:
logger.error(f"XGBoost prediction error: {e}")
return {"direction": "HOLD", "confidence": 0.0}
def predict_batch(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Vectorized per-bar BUY/SELL/HOLD predictions for backtesting.
Uses the exact same feature pipeline as predict() (create_ml_features,
missing-column fill-0, confidence<0.5 → HOLD), so backtest AI signals
match live inference one-to-one. Features are trailing-window only →
no lookahead bias. Rows whose feature window is incomplete (NaN) are
marked direction=HOLD / confidence=0.0 (no prediction).
Returns:
DataFrame indexed like df with columns:
direction, confidence, p_buy, p_hold, p_sell
"""
if self._model is None:
if not self.load_model():
raise RuntimeError("XGBoost model not loaded — run scripts/train_ai.py first")
ta = TechnicalAnalyzer()
feature_df = ta.create_ml_features(df)
for col in self._feature_columns:
if col not in feature_df.columns:
feature_df[col] = 0
X = feature_df[self._feature_columns].astype(float)
valid_mask = X.notna().all(axis=1).values
n = len(X)
probs = np.zeros((n, 3))
if valid_mask.any():
proba_v = self._model.predict_proba(X.loc[valid_mask].values)
# Map model class indices (self._model.classes_) onto slots 0/1/2
for k, cls in enumerate(self._model.classes_):
probs[valid_mask, cls] = proba_v[:, k]
predicted = probs.argmax(axis=1)
confidence = probs.max(axis=1)
direction_map = {0: "BUY", 1: "HOLD", 2: "SELL"}
out = pd.DataFrame(
{
"direction": [
direction_map[p] if c >= 0.5 else "HOLD"
for p, c in zip(predicted, confidence)
],
"confidence": confidence,
"p_buy": probs[:, 0],
"p_hold": probs[:, 1],
"p_sell": probs[:, 2],
},
index=df.index,
)
# Rows with incomplete feature windows → no prediction (HOLD)
out.loc[~valid_mask, ["direction", "confidence"]] = ["HOLD", 0.0]
return out
+99 -8
View File
@@ -9,6 +9,7 @@ Equips the XAUUSD AI Trading Bot with:
"""
import json
import math
import asyncio
from datetime import datetime, timezone, timedelta
from typing import Optional, Any
@@ -46,6 +47,11 @@ class TradeLearner:
# Rolling active mistake memory signatures
self.mistake_memory: list[dict] = []
# Wall-clock of the last multiplier change, per strategy. Used by the
# time-decay below so a stale penalty cannot lock the bot out forever
# (deadlock: no trades → no lessons → multiplier never recovers).
self._multiplier_updated: dict[str, datetime] = {}
# Load persisted adaptive state from database
self._load_persisted_state()
@@ -61,6 +67,11 @@ class TradeLearner:
s_name = comp.replace("strat_", "")
if s_name in self.strategy_multipliers:
self.strategy_multipliers[s_name] = float(data.get("multiplier", 1.0))
ts = data.get("updated_at")
if ts is not None:
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
self._multiplier_updated[s_name] = ts
# Load recent mistake lessons into memory guard
lessons = crud.get_recent_lessons(limit=25)
@@ -75,6 +86,12 @@ class TradeLearner:
except Exception:
pass
# Apply time decay once at load so a stale penalty doesn't survive
# a restart unchanged (a bot left locked overnight would otherwise
# resume fully penalized and keep vetoing).
for s_name in list(self.strategy_multipliers):
self._apply_time_decay(s_name)
logger.info(
f"🧠 Self-Learning Engine initialized | Adaptive Multipliers: {self.strategy_multipliers} "
f"| Active Mistake Memories: {len(self.mistake_memory)}"
@@ -106,6 +123,9 @@ class TradeLearner:
outcome = "SCRATCH"
# 1. Adapt Strategy Multipliers
# Decay first so a win after a long dry spell doesn't compound on a
# stale penalty, and a fresh loss starts from the decayed value.
self._apply_time_decay(strategy)
current_strat_mult = self.strategy_multipliers.get(strategy, 1.0)
if outcome == "WIN":
# Reward successful strategy
@@ -273,8 +293,62 @@ class TradeLearner:
for k in self.component_weights:
self.component_weights[k] = round((self.component_weights[k] / total) * 100.0, 1)
# ── Time Decay of Stale Multipliers ──────────────────────────────────
def _apply_time_decay(self, strategy: str, now: Optional[datetime] = None):
"""Drift a strategy multiplier back toward 1.0 with wall-clock time.
A losing streak pushes a strategy's multiplier down, and a deep
penalty vetoes all future entries for it — but with no entries there
are no new lessons, so the penalty can never recover on its own (the
deadlock that locked the bot out overnight). Pull the multiplier back
toward neutral with a ~24h time constant instead.
"""
mult = self.strategy_multipliers.get(strategy)
if mult is None or mult == 1.0:
return
now = now or datetime.now(timezone.utc)
last = self._multiplier_updated.get(strategy)
if last is None:
self._multiplier_updated[strategy] = now
return
elapsed_days = max(0.0, (now - last).total_seconds() / 86400.0)
recovery = 1.0 - math.exp(-elapsed_days / 1.0) # 24h time constant
if recovery <= 1e-9:
return
new_mult = round(mult + (1.0 - mult) * recovery, 3)
if abs(new_mult - mult) >= 1e-3:
self.strategy_multipliers[strategy] = new_mult
self._multiplier_updated[strategy] = now
logger.info(
f"🧠 Multiplier decay: {strategy.upper()} {mult:.3f}x → {new_mult:.3f}x "
f"(no new lessons for {elapsed_days:.1f}d)"
)
try:
crud.update_adaptive_multiplier(f"strat_{strategy}", new_mult)
except Exception as e:
logger.warning(f"Could not persist decayed multiplier: {e}")
# ── Prospective Signal Screening (Mistake Memory Guard) ───────────────
def _strategy_base_confluence(self, strategy_name: str) -> float:
"""Return a strategy's own minimum-confluence base for guard thresholds.
The guard previously hardcoded the scalping base for every strategy,
so day_trading (base 55 in moderate) and swing_trading (60) were
screened against scalping's numbers. Resolve each strategy to its own
settings section, mirroring the per-strategy routing of
BaseStrategy.min_confluence_score in strategies/base_strategy.py.
Unknown names fall back to scalping (legacy behavior).
"""
section = {
"scalping": "scalping_params",
"day_trading": "day_trading_params",
"swing_trading": "swing_trading_params",
}.get(strategy_name.lower(), "scalping_params")
params = getattr(get_settings(), section, None) or {}
return float(params.get("min_confluence_score", 50.0))
def screen_prospective_signal(
self,
strategy_name: str,
@@ -289,20 +363,37 @@ class TradeLearner:
(is_approved: bool, explanation: str, penalty_score: float)
"""
strat = strategy_name.lower()
# Apply time decay before screening so a stale penalty never blocks.
self._apply_time_decay(strat)
mult = self.strategy_multipliers.get(strat, 1.0)
penalty = 0.0
# 1. Strategy heavily penalized check
# 1. Strategy heavily penalized check — conditional, honoring the "+6"
# the message claims. A penalized strategy still fires when the signal's
# confluence clears base_min + penalty (the +6 is then applied to
# effective_score downstream in bot.py). Before this fix the veto was
# UNCONDITIONAL below 0.65x, so a stale penalty permanently locked the
# strategy out (no trades → no lessons → no recovery). Combined with the
# time decay above, even an unreachable override threshold unblocks
# within ~a day as the multiplier drifts back above 0.65.
if mult < 0.65:
penalty = 6.0
return (
False,
f"Strategy '{strat.upper()}' is currently penalized (confidence: {mult:.2f}x). Requires +{penalty:.0f} higher confluence.",
penalty,
base_min = self._strategy_base_confluence(strat)
override_threshold = base_min + penalty
if confluence_score < override_threshold:
return (
False,
f"Strategy '{strat.upper()}' is currently penalized (confidence: {mult:.2f}x). "
f"Requires confluence ≥ {override_threshold:.1f} (base {base_min:.0f} + {penalty:.0f}) to override.",
penalty,
)
logger.info(
f"🛡️ Mistake Guard: penalized '{strat.upper()}' ({mult:.2f}x) "
f"cleared by strong confluence {confluence_score:.1f} ≥ {override_threshold:.1f}."
)
# 2. Match against active mistake memories
settings = get_settings()
base_min_confluence = float(settings.scalping_params.get("min_confluence_score", 50.0))
base_min_confluence = self._strategy_base_confluence(strat)
veto_threshold = base_min_confluence + 8.0
for m in reversed(self.mistake_memory[-15:]):
@@ -317,7 +408,7 @@ class TradeLearner:
8.0,
)
return (True, "Signal cleared all adaptive learning checks.", 0.0)
return (True, "Signal cleared all adaptive learning checks.", penalty)
# ── Inspection & Reporting ────────────────────────────────────────────
+244
View File
@@ -0,0 +1,244 @@
"""
Day-Trading Gate Runner — the prove-before-live gate for the day_trading family.
Why this exists: day_trading ran `enabled: true` while only scalping was ever gated
(run_real_backtest.py mirrors scalping on the M5 frame). Its two live H1 trades
(-$116.5 / +$19.5, SL = 2x H1-ATR) were never proven and never mistake-guarded.
This runner mirrors run_real_backtest.py but on the strategy's own frame (H1), so
DayTradingStrategy computes H1 indicators, SL = 2 x H1-ATR, TP = R:R 2.0 x SL — the
exact spec that produced the wide-stop trades. Scalping and swing are disabled, so
only day_trading signals are evaluated.
Disclosed divergence from live: live bot Step 8 trails EVERY position on M5-ATR,
whereas the backtest engine trails on the frame's own bar ATR. The gate therefore
trails on H1-ATR (wider than live's M5-based trail). If the H1-gate passes, live's
tighter M5-trail is a deviation toward live, not against it; if it fails badly
(deep negative expectancy), the M5-trail divergence cannot rescue the entry spec.
Usage:
python -m backtesting.backtest_day_trading_gate # gate run (H1 frame)
python -m backtesting.backtest_day_trading_gate --compare-baseline
# also run the SAME config on the M5 frame (scalping frame) to see how the
# day_trading setup behaves when its signals are evaluated on M5 bars
python -m backtesting.backtest_day_trading_gate --backtest-start 2026-05-13 --backtest-end 2026-10-13
# optional history slice (default: all fetched history)
python -m backtesting.backtest_day_trading_gate --list # print active config, no run
Output: appends one row per run to backtesting/results/grid_results.csv (same schema
as run_real_backtest.py) and prints the gate verdict:
expectancy > 0 AND profit_factor >= 1.2 AND max_drawdown_pct <= 15 AND total_trades >= 60
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
import pandas as pd
from loguru import logger
from core.mt5_connector import MT5Connector
from analysis.technical import TechnicalAnalyzer
from strategies.regime_detector import RegimeDetector
from backtesting.backtester import Backtester
from backtesting.performance import PerformanceMetrics, calculate_metrics
from config.settings import get_settings
from backtesting.run_real_backtest import (
fetch_rates,
build_ai_lookup,
active_backtest_params,
append_result,
gate_review,
)
# ── Regime plan on a non-M5 decision frame ────────────────────────────────
# run_real_backtest.build_regime_plan assumes 5-min decision bars; H1 needs a
# 1-hour offset. Same zero-lookahead logic (closed H4 bars only).
BASE_TIMEFRAMES = {
"scalping": "M5",
"day_trading": "H1",
}
def build_regime_plan(df_bars: pd.DataFrame, df_h4: pd.DataFrame, decision_offset_minutes: int) -> pd.DataFrame:
"""Map each decision bar (close time = open + offset) to the last CLOSED H4
regime. Closed H4 bars only (close time <= decision bar close) → zero lookahead."""
ta = TechnicalAnalyzer()
h4_ind = ta.add_all_indicators(df_h4.copy())
detector = RegimeDetector()
h4_closes, regimes, vols, adxs, should_trades = [], [], [], [], []
for idx in range(len(h4_ind)):
ra = detector.analyze(h4_ind.iloc[: idx + 1])
h4_closes.append(df_h4["time"].iloc[idx] + pd.Timedelta(hours=4)) # H4 bar close time
regimes.append(ra.regime.value)
vols.append(ra.volatility_label)
adxs.append(ra.adx)
should_trades.append(ra.should_trade)
closes_arr = np.array(sorted(h4_closes))
decision_times = pd.to_datetime(df_bars["time"]) + pd.Timedelta(minutes=decision_offset_minutes)
def _lookup(series, fallback):
series_arr = np.array(series)
out = []
for t in decision_times:
pos = np.searchsorted(closes_arr, t.to_pydatetime(), side="right") - 1
out.append(series_arr[pos] if pos >= 0 else fallback)
return out
return pd.DataFrame({
"regime": _lookup(regimes, "RANGING"),
"volatility": _lookup(vols, "NORMAL"),
"adx": [float(a) for a in _lookup(adxs, 15.0)],
"should_trade": [bool(s) for s in _lookup(should_trades, True)],
})
# ── Historical overrides for this strategy family ─────────────────────────
# day_trading's core spec lives in the strategy class itself (atr_sl_multiplier 2.0,
# risk_reward_ratio 2.0 from config/trading_params.yaml → settings.day_trading_params).
# These are the equivalence to the scalping gate's backtest: section — keep the
# shared exit engine values (breakeven/trail) but raise the confluence floor to the
# strategy's own YAML minimum (70) AND to the live mode-override value (55), so we
# can see both. Volumes/balance match the scalping gate for comparability.
DAY_TRADING_FIXED = {
"strategy": "day_trading",
"timeframe": "H1",
}
def _build_config(bt: dict, min_confluence: float, strategy_timeframe: str) -> dict:
cfg = dict(bt)
cfg.update(DAY_TRADING_FIXED)
cfg["min_confluence"] = float(min_confluence)
cfg["timeframe"] = strategy_timeframe
return cfg
def run_single_config(cfg: dict) -> PerformanceMetrics:
"""Fetch data, build lookups, run the backtester for one day_trading config."""
tf = cfg["timeframe"]
connector = MT5Connector()
# H1 span (~10+ months) needs the M15 AI lookup and H4 regime to cover it,
# otherwise old bars fall back to neutral AI / RANGING default and the gate
# understates the real strategy. 3:1 and 6:1 ratios mirror live frame maths.
fetch_map = {
"H1": (5200, 15600, 1300), # H1/M15/H4
"M5": (5200, 1800, 200), # scalping-parity (what run_real_backtest does)
}
count, count_m15, count_h4 = fetch_map[tf]
offset_min = 60 if tf == "H1" else 5
if not connector.connect():
raise RuntimeError("Failed to connect to MT5. Ensure MT5 is running and logged in.")
try:
df_main = fetch_rates(connector, tf, count)
df_m15 = fetch_rates(connector, "M15", count_m15)
df_h4 = fetch_rates(connector, "H4", count_h4)
finally:
connector.disconnect()
# Optional history slice (mirrors run_real_backtest slice semantics)
for name, col in (("backtest_start", "start"), ("backtest_end", "end")):
val = cfg.get(name)
if val:
key = pd.to_datetime(val)
mask = df_main["time"] >= key if col == "start" else df_main["time"] <= key
df_main = df_main[mask].reset_index(drop=True)
if len(df_main) <= int(cfg["warmup_bars"]) + 50:
raise RuntimeError(f"{tf} history too short after slice: {len(df_main)} bars")
logger.info(f"Building AI lookup on {len(df_m15)} M15 bars...")
ai_lookup = build_ai_lookup(df_m15)
logger.info(f"Building regime plan over {len(df_main)} {tf} bars from H4...")
regime_plan = build_regime_plan(df_main, df_h4, decision_offset_minutes=offset_min)
bt_obj = Backtester(
initial_balance=float(cfg["initial_balance"]),
lot_size=float(cfg["lot_size"]),
min_confluence_score=float(cfg["min_confluence"]),
ai_lookup=ai_lookup,
regime_plan=regime_plan,
cooldown_bars=int(cfg["cooldown_bars"]),
max_hold_bars=int(cfg["max_hold_bars"]),
breakeven_rr=float(cfg["breakeven_rr"]),
atr_trail_multiplier=float(cfg["atr_trail_multiplier"]),
trail_trigger_atr=float(cfg["trail_trigger_atr"]),
)
# Isolate day_trading: disable the other families so their signals can't
# consume the single open slot or pollute the gate for this strategy.
# NOTE: day_trading.enabled is now False in YAML (Step 1 of this fix), and the
# strategy reads that flag from settings — force it back on for the gate.
for name, strat in bt_obj.strategies.items():
if name != "day_trading":
strat.enabled = False
else:
strat.enabled = True
strat.min_confluence_score = float(cfg["min_confluence"])
strat.min_technical_score = float(cfg["min_technical_score"])
metrics = bt_obj.run(df_main, warmup_bars=int(cfg["warmup_bars"]))
return metrics
def main():
import argparse
parser = argparse.ArgumentParser(description="Day-trading prove-before-live gate (H1 frame)")
parser.add_argument("--list", action="store_true", help="print active config and exit")
parser.add_argument("--backtest-start", default=None, help="optional YYYY-MM-DD history slice start")
parser.add_argument("--backtest-end", default=None, help="optional YYYY-MM-DD history slice end")
parser.add_argument(
"--compare-baseline", action="store_true",
help="also run the same setup on the M5 frame for contrast",
)
args = parser.parse_args()
base = active_backtest_params()
base["backtest_start"] = base.get("backtest_start") or args.backtest_start
base["backtest_end"] = base.get("backtest_end") or args.backtest_end
# Confluence floor: day_trading YAML raw minimum vs live moderate-mode override.
modes = get_settings().trading_params.get("trading_modes", {})
mode_cfg = modes.get(get_settings().active_mode, {})
conf_live = int(mode_cfg.get("day_trading_min_confluence", 55))
conf_raw = int(get_settings().trading_params.get("day_trading", {}).get("min_confluence_score", 70))
run_plan = [
(f"day_trading/H1 @ conf=live({conf_live})", _build_config(base, conf_live, "H1")),
(f"day_trading/H1 @ conf=raw({conf_raw})", _build_config(base, conf_raw, "H1")),
]
if args.compare_baseline:
run_plan.append((f"day_trading/M5 @ conf=live({conf_live})", _build_config(base, conf_live, "M5")))
logger.info(f"Day-trading gate — run plan: {[p[0] for p in run_plan]}")
if args.list:
return
for label, cfg in run_plan:
logger.info(f"\n{'='*62}\n▶ Run: {label}\n{'='*62}")
try:
metrics = run_single_config(cfg)
# Keep the results CSV schema stable: strategy/timeframe are run-marker
# keys, not grid columns (append_result aligns by config keys).
csv_cfg = {k: v for k, v in cfg.items() if k not in ("strategy", "timeframe")}
append_result(csv_cfg, metrics)
passed = gate_review(metrics)
print(f"▶ {label} : {'PASS' if passed else 'FAIL'}")
except Exception as e:
import traceback
logger.error(f"Run '{label}' failed: {e}")
traceback.print_exc()
if __name__ == "__main__":
main()
+224 -47
View File
@@ -1,13 +1,20 @@
"""
Backtesting Engine for XAUUSD AI Trading Bot
Backtesting Engine for XAUUSD AI Trading Bot — Live-Parity Edition
Simulates realistic multi-strategy execution against historical price data:
- Zero lookahead bias (evaluates bars sequentially)
- Dynamic ATR-based Stop Loss & Take Profit
- Trailing stop simulation
- Zero lookahead bias (evaluates bars sequentially, trailing-only features)
- Real XGBoost AI prediction per bar (predict_batch lookup, same path as live signal_classifier.predict)
- Real confluence scoring incl. dynamic RANGING/HIGH-vol threshold deltas (mirrors live aggregator)
- Temporal Self-Attention alignment (same AttentionMarketScorer as live)
- Dynamic ATR-based Stop Loss & Take Profit + breakeven + trailing (capped at TP)
- Entry cooldown + max-hold timeout exits
- Realistic spread & slippage modeling
- Confluence score filtering
- Detailed quantitative performance reporting
Known deviations from live (documented): single concurrent position (live allows up to
max_concurrent=4, so concurrency compression understates/overstates risk depending on
correlation); trade_learner mistake-guard bypassed (live weights are garbage — see plan
phase 3); sentiment fixed at NEUTRAL/0 unless overridden.
"""
import math
@@ -37,19 +44,43 @@ class Backtester:
lot_size: float = 0.02,
spread_points: float = 0.25, # ~$0.25 on gold
slippage_points: float = 0.05,
min_confluence_score: float = 65.0,
min_confluence_score: float = 50.0,
trailing_stop_enabled: bool = True,
# ── Live-parity addons ───────────────────────────────────────────
ai_lookup: Optional[pd.DataFrame] = None, # per-bar AI (direction/confidence), positional with df
regime_plan: Optional[pd.DataFrame] = None, # per-bar regime (regime/volatility/adx), positional with df
sentiment: Optional[dict] = None, # {"direction","score"} for aggregation
attention_enabled: bool = True, # mirror live AttentionMarketScorer
cooldown_bars: int = 0, # bars to wait after an exit before same-strategy re-entry
max_hold_bars: int = 0, # 0 = disabled; else time-based exit at close
breakeven_rr: float = 0.0, # 0 = disabled; else move SL to entry at this RR of original SL
atr_trail_multiplier: float = 1.2, # trail distance = multiplier × bar ATR
trail_trigger_atr: float = 1.2, # start trailing after this much unrealized gain (× ATR)
tick: float = 0.01, # XAUUSD price tick
):
self.initial_balance = initial_balance
self.lot_size = lot_size
self.spread = spread_points
self.slippage = slippage_points
self.min_confluence = min_confluence_score
self.min_confluence = min_confluence_score # fallback; live prefers per-strategy property
self.trailing_enabled = trailing_stop_enabled
self.ai_lookup = ai_lookup
self.regime_plan = regime_plan
self.sentiment = sentiment or {"direction": "NEUTRAL", "score": 0.0}
self.attention_enabled = attention_enabled
self.cooldown_bars = cooldown_bars
self.max_hold_bars = max_hold_bars
self.breakeven_rr = breakeven_rr
self.atr_trail_multiplier = atr_trail_multiplier
self.trail_trigger_atr = trail_trigger_atr
self.tick = tick
self.ta = TechnicalAnalyzer()
self.regime_detector = RegimeDetector()
self.aggregator = SignalAggregator()
# Pure config weights for the gate — never the corrupted live adaptive ones
self.aggregator = SignalAggregator(use_adaptive_weights=False)
from ai.attention_scorer import AttentionMarketScorer
self.attention_scorer = AttentionMarketScorer(window=25)
self.strategies = {
"scalping": ScalpingStrategy(),
@@ -109,6 +140,9 @@ class Backtester:
def run(self, df: pd.DataFrame, warmup_bars: int = 200) -> PerformanceMetrics:
"""
Run the backtest across historical bars.
`ai_lookup` and `regime_plan` (if set on the instance) must be positionally
aligned to `df` (same number of rows, same order).
"""
if len(df) <= warmup_bars:
raise ValueError(f"Dataframe length ({len(df)}) must exceed warmup_bars ({warmup_bars})")
@@ -118,23 +152,84 @@ class Backtester:
# Precalculate technical indicators on the full dataset
df_indicators = self.ta.add_all_indicators(df.copy())
# ── AI lookup: time-based (M5 bar → last CLOSED M15 signal) ────────
# Model features at M15 row j use trailing windows only, so the signal
# is known at bar close = open + 15m. Live bot also evaluates on closed
# M15 bars. If no "time" column exists (synthetic tests), fall back to
# positional pairing with the M5 rows.
def _ai_lookup_fn(src: Optional[pd.DataFrame]):
if src is None or len(src) == 0:
return None
src = src.reset_index(drop=True)
if "time" in src.columns:
opens = pd.to_datetime(src["time"]).values
closes = opens + np.timedelta64(15, "m") # M15 close = signal-available time
order = np.argsort(closes)
closes_sorted = closes[order]
dirs = src["direction"].values[order]
confs = src["confidence"].values.astype(float)[order]
def lookup(bar_time):
pos = np.searchsorted(closes_sorted, np.datetime64(bar_time), side="right") - 1
if pos < 0:
return {"direction": "HOLD", "confidence": 0.0}
return {"direction": str(dirs[pos]), "confidence": float(confs[pos])}
return lookup
else:
# positional fallback (tests): assume src rows align with M5 rows
rows = list(zip(src["direction"].astype(str), src["confidence"].astype(float)))
pos_idx = [0]
def lookup(bar_time):
row = rows[min(pos_idx[0], len(rows) - 1)]
pos_idx[0] += 1
return {"direction": row[0], "confidence": row[1]}
return lookup
ai_lookup_fn = _ai_lookup_fn(self.ai_lookup)
def _regime_iter():
if self.regime_plan is not None and len(self.regime_plan) == len(df_indicators):
plan = self.regime_plan.reset_index(drop=True)
for _, row in plan.iterrows():
yield {
"regime": row["regime"],
"volatility": row["volatility"],
"adx": float(row["adx"]) if pd.notna(row["adx"]) else 15.0,
"should_trade": bool(row["should_trade"]) if "should_trade" in row else True,
}
else:
while True:
yield None
regime_gen = _regime_iter()
closed_trades: List[BacktestTrade] = []
open_position: Optional[dict] = None
trade_counter = 0
running_equity = self.initial_balance
last_exit_bar: Dict[str, int] = {name: -10**9 for name in self.strategies}
# Point value for XAUUSD (1 standard lot = 100 oz, so 1 point move = $100 on 1.0 lot, $100 * lot_size)
# Point value for XAUUSD (1 standard lot = 100 oz, 1 point move = $100 on 1.0 lot)
dollar_per_point = 100.0 * self.lot_size
for i in range(warmup_bars, len(df_indicators)):
current_bar = df_indicators.iloc[i]
prev_bar = df_indicators.iloc[i - 1]
sub_df = df_indicators.iloc[:i + 1]
current_high = current_bar["high"]
current_low = current_bar["low"]
current_close = current_bar["close"]
current_high = float(current_bar["high"])
current_low = float(current_bar["low"])
current_close = float(current_bar["close"])
current_time = current_bar.get("time", i)
atr = float(current_bar.get("atr", 3.0))
if not math.isfinite(atr) or atr <= 0:
atr = 3.0
# ── Per-bar exogenous signals (time-aligned) ────────────────
ai_prediction = {"direction": "HOLD", "confidence": 0.0}
if ai_lookup_fn is not None:
ai_prediction = ai_lookup_fn(current_time)
# ── 1. Manage existing open position ───────────────────────
if open_position is not None:
@@ -144,48 +239,76 @@ class Backtester:
entry_price = pos["entry_price"]
sl = pos["stop_loss"]
tp = pos["take_profit"]
sl_orig = pos["stop_loss_original"]
closed = False
exit_price = 0.0
exit_reason = ""
# Check SL and TP within bar
if direction == "BUY":
# Check Stop Loss first (conservative)
# Timeout exit path (smart-exit culture: don't hold forever)
if self.max_hold_bars > 0 and pos["bars_held"] >= self.max_hold_bars:
exit_price = round(current_close, 2)
exit_reason = "TIMEOUT"
closed = True
elif direction == "BUY":
# Stop Loss first (conservative within-bar ordering)
if current_low <= sl:
exit_price = sl - self.slippage
exit_price = round(sl - self.slippage, 2)
exit_reason = "STOP_LOSS"
closed = True
elif current_high >= tp:
exit_price = tp
exit_price = round(tp, 2)
exit_reason = "TAKE_PROFIT"
closed = True
elif self.trailing_enabled:
# Update Trailing Stop if price moved in profit by 1.5 ATR
gain = current_close - entry_price
if gain > 1.5 * atr:
new_sl = current_close - 1.2 * atr
if new_sl > pos["stop_loss"]:
pos["stop_loss"] = new_sl
elif direction == "SELL":
if current_high >= sl:
exit_price = sl + self.slippage
exit_price = round(sl + self.slippage, 2)
exit_reason = "STOP_LOSS"
closed = True
elif current_low <= tp:
exit_price = tp
exit_price = round(tp, 2)
exit_reason = "TAKE_PROFIT"
closed = True
elif self.trailing_enabled:
gain = entry_price - current_close
if gain > 1.5 * atr:
new_sl = current_close + 1.2 * atr
if new_sl < pos["stop_loss"]:
pos["stop_loss"] = new_sl
if not closed:
# ── Breakeven: lock in entry once price reaches breakeven_rr × original SL ──
if self.breakeven_rr > 0 and sl_orig is not None:
sl_distance = abs(entry_price - sl_orig)
if sl_distance > 0:
if direction == "BUY":
gain_now = current_close - entry_price
if gain_now >= sl_distance * self.breakeven_rr:
be = round(entry_price + self.tick, 2)
if be > sl:
sl = be
else:
gain_now = entry_price - current_close
if gain_now >= sl_distance * self.breakeven_rr:
be = round(entry_price - self.tick, 2)
if be < sl:
sl = be
# ── Trailing stop: activate after trail_trigger_atr × ATR profit ──
if self.trailing_enabled:
if direction == "BUY":
gain = current_close - entry_price
if gain > self.trail_trigger_atr * atr:
new_sl = round(current_close - self.atr_trail_multiplier * atr, 2)
new_sl = min(new_sl, round(tp - self.tick, 2)) # never trail past TP
if new_sl > sl:
sl = new_sl
else:
gain = entry_price - current_close
if gain > self.trail_trigger_atr * atr:
new_sl = round(current_close + self.atr_trail_multiplier * atr, 2)
new_sl = max(new_sl, round(tp + self.tick, 2)) # never trail past TP
if new_sl < sl:
sl = new_sl
pos["stop_loss"] = sl
if closed:
# Calculate P&L
# ── Calculate P&L ──────────────────────────────────
if direction == "BUY":
points_diff = exit_price - entry_price
else:
@@ -193,7 +316,7 @@ class Backtester:
pnl_dollars = points_diff * dollar_per_point
pips = points_diff * 10.0 # 1 pip = 0.1 on gold
pnl_pct = (pnl_dollars / self.initial_balance) * 100.0
pnl_pct = (pnl_dollars / running_equity) * 100.0 if running_equity > 0 else 0.0
trade = BacktestTrade(
trade_id=pos["trade_id"],
@@ -203,7 +326,7 @@ class Backtester:
entry_time=pos["entry_time"],
exit_time=current_time,
entry_price=round(entry_price, 2),
exit_price=round(exit_price, 2),
exit_price=exit_price,
lot_size=self.lot_size,
pnl=round(pnl_dollars, 2),
pnl_pips=round(pips, 1),
@@ -213,35 +336,68 @@ class Backtester:
bars_held=pos["bars_held"],
)
closed_trades.append(trade)
running_equity += pnl_dollars
last_exit_bar[pos["strategy"]] = i
open_position = None
# ── 2. Evaluate new entries if no position is open ────────
if open_position is None:
regime = self.regime_detector.analyze(sub_df)
if not regime.should_trade:
regime = next(regime_gen)
should_trade = True
if regime is not None:
should_trade = regime["should_trade"]
else:
regime_obj = self.regime_detector.analyze(sub_df)
should_trade = regime_obj.should_trade
regime = {
"regime": regime_obj.regime.value,
"volatility": regime_obj.volatility_label,
"adx": regime_obj.adx,
"should_trade": should_trade,
"position_modifier": regime_obj.position_size_modifier,
}
if not should_trade:
continue
best_signal = None
best_confluence = None
for strat_name, strat in self.strategies.items():
if not self.regime_detector.is_strategy_recommended(strat_name, regime):
# Entry cooldown: wait cooldown_bars after that strategy's last exit
if self.cooldown_bars > 0 and i - last_exit_bar[strat_name] < self.cooldown_bars:
continue
# Match live Step 6 regime recommendation gate
if not self.regime_detector.is_strategy_recommended(strat_name, self._to_regime_obj(regime, sub_df)):
continue
sig = strat.run(sub_df)
if not sig.is_actionable:
continue
# Confluence check
# Mirror live Step 6: attention on the strategy's own frame
attention_analysis = None
if self.attention_enabled:
attention_analysis = self.attention_scorer.analyze_alignment(
df=sub_df,
signal_direction=sig.direction.value,
)
# Confluence — live passes is_recommended=True hardcoded + real regime keys
confluence = self.aggregator.calculate_confluence(
strategy_signal=sig,
ai_prediction={"direction": "HOLD", "confidence": 0.0},
sentiment={"direction": "NEUTRAL", "score": 0.0},
ai_prediction=ai_prediction,
sentiment=self.sentiment,
regime_analysis={
"is_recommended": True,
"position_modifier": regime.position_size_modifier,
"position_modifier": regime.get("position_modifier", 1.0),
"regime": regime["regime"],
"adx": regime.get("adx", 15.0),
"volatility": regime["volatility"],
},
min_score=self.min_confluence,
attention_analysis=attention_analysis,
min_score=strat.min_confluence_score,
)
if confluence.should_execute:
@@ -250,7 +406,11 @@ class Backtester:
best_confluence = confluence
if best_signal and best_confluence and best_confluence.should_execute:
direction = best_signal.direction
# Store string direction (live stores signal.direction.value) — the exit
# block compares against "BUY"/"SELL" strings. Storing the raw Enum here
# silently disabled every exit: "Enum == str" is always False, so no trade
# ever closed and open_position blocked all subsequent entries.
direction = best_signal.direction.value
raw_price = current_close
# Apply realistic execution spread & slippage
@@ -271,6 +431,7 @@ class Backtester:
"entry_time": current_time,
"entry_price": entry_price,
"stop_loss": sl,
"stop_loss_original": sl,
"take_profit": tp,
"confluence_score": best_confluence.total_score,
"bars_held": 0,
@@ -281,6 +442,22 @@ class Backtester:
self._print_report(metrics)
return metrics
def _to_regime_obj(self, regime: dict, sub_df: pd.DataFrame):
"""Wrap an exogenous regime dict as a RegimeAnalysis for is_strategy_recommended."""
try:
from strategies.regime_detector import MarketRegime, RegimeAnalysis
if not isinstance(regime.get("regime"), str):
return self.regime_detector.analyze(sub_df)
return RegimeAnalysis(
regime=MarketRegime(regime["regime"]),
adx_value=float(regime.get("adx", 15.0)),
volatility_label=regime.get("volatility", "NORMAL"),
position_size_modifier=regime.get("position_modifier", 1.0),
should_trade=regime.get("should_trade", True),
)
except Exception:
return self.regime_detector.analyze(sub_df)
def _print_report(self, m: PerformanceMetrics):
"""Display a formatted quantitative performance report."""
border = "=" * 62
@@ -319,4 +496,4 @@ class Backtester:
lines.append(" (No completed trades executed)")
lines.append(border)
lines.append("")
print("\n".join(lines))
print("\n".join(lines))
+32
View File
@@ -0,0 +1,32 @@
min_confluence,min_technical_score,cooldown_bars,max_hold_bars,breakeven_rr,atr_trail_multiplier,trail_trigger_atr,lot_size,initial_balance,warmup_bars,backtest_start,backtest_end,total_trades,win_rate,profit_factor,expectancy,total_pnl,max_drawdown_pct,sharpe_ratio,avg_bars_held
50,35,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,328,42.07,1.1,0.68,222.48,1.72,-0.64,8.4
50,35,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,328,42.07,1.1,0.68,222.48,1.72,-0.64,8.4
55,35,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,328,42.07,1.1,0.68,222.48,1.72,-0.64,8.4
60,35,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,328,42.07,1.1,0.68,222.48,1.72,-0.64,8.4
65,35,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,328,42.07,1.1,0.68,222.48,1.72,-0.64,8.4
70,35,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,328,42.07,1.1,0.68,222.48,1.72,-0.64,8.4
50,35,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,333,42.04,1.06,0.43,143.35,1.92,-0.86,9.1
55,35,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,151,41.06,0.99,-0.05,-8.23,1.65,-2.25,9.1
60,35,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,22,45.45,1.35,1.95,42.92,0.42,-5.33,7.9
65,35,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
70,35,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0
65,40,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
65,45,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
65,40,0,0,0.5,2.0,1.2,0.02,10000.0,200,,,1,0.0,0.0,-0.07,-0.07,0.0,0.0,4.0
65,40,0,0,0.7,2.0,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
65,40,0,0,1.0,2.0,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
65,40,0,0,0.7,0.8,1.2,0.02,10000.0,200,,,1,100.0,999.0,6.57,6.57,0.0,0.0,8.0
65,40,0,0,0.7,1.0,1.2,0.02,10000.0,200,,,1,100.0,999.0,4.49,4.49,0.0,0.0,8.0
65,40,0,0,0.7,1.5,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
65,40,3,0,0.7,1.5,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
65,40,6,0,0.7,1.5,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
65,40,12,0,0.7,1.5,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
65,40,3,24,0.7,1.5,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
65,40,3,48,0.7,1.5,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
65,40,3,96,0.7,1.5,1.2,0.02,10000.0,200,,,1,100.0,999.0,22.39,22.39,0.0,0.0,9.0
55.0,35.0,0,0,0.7,1.5,1.2,0.02,10000.0,200,,,1172,52.65,2.11,5.84,6846.84,2.03,9.14,8.3
60.0,35.0,0,0,0.7,0.8,1.2,0.02,10000.0,200,,,365,70.68,3.3,8.88,3241.71,0.61,8.31,6.9
60.0,35.0,0,0,0.7,1.5,1.2,0.02,10000.0,200,,,356,65.45,3.63,10.14,3610.64,0.55,8.55,7.9
50.0,35.0,0,0,0.0,2.0,1.2,0.02,10000.0,200,,,2214,46.75,1.5,3.48,7694.4,2.22,5.33,9.8
55.0,35,0,0,0.7,1.5,1.2,0.02,10000.0,200,,,72,70.83,3.45,47.91,3449.48,2.25,3.32,14.3
70.0,35,0,0,0.7,1.5,1.2,0.02,10000.0,200,,,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0
1 min_confluence min_technical_score cooldown_bars max_hold_bars breakeven_rr atr_trail_multiplier trail_trigger_atr lot_size initial_balance warmup_bars backtest_start backtest_end total_trades win_rate profit_factor expectancy total_pnl max_drawdown_pct sharpe_ratio avg_bars_held
2 50 35 0 0 0.0 2.0 1.2 0.02 10000.0 200 328 42.07 1.1 0.68 222.48 1.72 -0.64 8.4
3 50 35 0 0 0.0 2.0 1.2 0.02 10000.0 200 328 42.07 1.1 0.68 222.48 1.72 -0.64 8.4
4 55 35 0 0 0.0 2.0 1.2 0.02 10000.0 200 328 42.07 1.1 0.68 222.48 1.72 -0.64 8.4
5 60 35 0 0 0.0 2.0 1.2 0.02 10000.0 200 328 42.07 1.1 0.68 222.48 1.72 -0.64 8.4
6 65 35 0 0 0.0 2.0 1.2 0.02 10000.0 200 328 42.07 1.1 0.68 222.48 1.72 -0.64 8.4
7 70 35 0 0 0.0 2.0 1.2 0.02 10000.0 200 328 42.07 1.1 0.68 222.48 1.72 -0.64 8.4
8 50 35 0 0 0.0 2.0 1.2 0.02 10000.0 200 333 42.04 1.06 0.43 143.35 1.92 -0.86 9.1
9 55 35 0 0 0.0 2.0 1.2 0.02 10000.0 200 151 41.06 0.99 -0.05 -8.23 1.65 -2.25 9.1
10 60 35 0 0 0.0 2.0 1.2 0.02 10000.0 200 22 45.45 1.35 1.95 42.92 0.42 -5.33 7.9
11 65 35 0 0 0.0 2.0 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
12 70 35 0 0 0.0 2.0 1.2 0.02 10000.0 200 0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
13 65 40 0 0 0.0 2.0 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
14 65 45 0 0 0.0 2.0 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
15 65 40 0 0 0.5 2.0 1.2 0.02 10000.0 200 1 0.0 0.0 -0.07 -0.07 0.0 0.0 4.0
16 65 40 0 0 0.7 2.0 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
17 65 40 0 0 1.0 2.0 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
18 65 40 0 0 0.7 0.8 1.2 0.02 10000.0 200 1 100.0 999.0 6.57 6.57 0.0 0.0 8.0
19 65 40 0 0 0.7 1.0 1.2 0.02 10000.0 200 1 100.0 999.0 4.49 4.49 0.0 0.0 8.0
20 65 40 0 0 0.7 1.5 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
21 65 40 3 0 0.7 1.5 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
22 65 40 6 0 0.7 1.5 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
23 65 40 12 0 0.7 1.5 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
24 65 40 3 24 0.7 1.5 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
25 65 40 3 48 0.7 1.5 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
26 65 40 3 96 0.7 1.5 1.2 0.02 10000.0 200 1 100.0 999.0 22.39 22.39 0.0 0.0 9.0
27 55.0 35.0 0 0 0.7 1.5 1.2 0.02 10000.0 200 1172 52.65 2.11 5.84 6846.84 2.03 9.14 8.3
28 60.0 35.0 0 0 0.7 0.8 1.2 0.02 10000.0 200 365 70.68 3.3 8.88 3241.71 0.61 8.31 6.9
29 60.0 35.0 0 0 0.7 1.5 1.2 0.02 10000.0 200 356 65.45 3.63 10.14 3610.64 0.55 8.55 7.9
30 50.0 35.0 0 0 0.0 2.0 1.2 0.02 10000.0 200 2214 46.75 1.5 3.48 7694.4 2.22 5.33 9.8
31 55.0 35 0 0 0.7 1.5 1.2 0.02 10000.0 200 72 70.83 3.45 47.91 3449.48 2.25 3.32 14.3
32 70.0 35 0 0 0.7 1.5 1.2 0.02 10000.0 200 0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
+225
View File
@@ -0,0 +1,225 @@
"""
Real-Data Backtest Runner — the "prove it before you deploy it" gate.
Fetches real XAUUSD history from MT5 (M5 bars for strategy/exit simulation, M15 for the
XGBoost model's per-bar signal, H4 for regime), builds live-parity AI + regime lookups,
then runs the simulation for the *current* `backtest:` section of config/trading_params.yaml.
Usage:
python -m backtesting.run_real_backtest # run with current YAML backtest section
python -m backtesting.run_real_backtest --list # print the active backtest config, no run
Every run appends one row (config → metrics) to backtesting/results/grid_results.csv so the
parameter grid stays comparable across iterations. The gate (see plan) accepts a row only if
expectancy > 0 AND profit_factor >= 1.2 AND max_drawdown_pct <= 15 AND total_trades >= 60.
"""
import os
import sys
import argparse
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
import pandas as pd
from loguru import logger
from core.mt5_connector import MT5Connector
from analysis.technical import TechnicalAnalyzer
from strategies.regime_detector import RegimeDetector
from ai.signal_classifier import SignalClassifier
from backtesting.backtester import Backtester
from backtesting.performance import PerformanceMetrics
from config.settings import get_settings
RESULTS_DIR = Path(__file__).resolve().parent / "results"
RESULTS_CSV = RESULTS_DIR / "grid_results.csv"
# ── Data fetching ──────────────────────────────────────────────────────────
def fetch_rates(connector: MT5Connector, timeframe: str, count: int) -> pd.DataFrame:
df = connector.get_rates(timeframe=timeframe, count=count)
if df is None or df.empty:
raise RuntimeError(f"Failed to fetch {len(df) if df is not None else 0} candles on {timeframe}")
# Ensure a plain 'time' column exists + normalize datetimes to tz-naive UTC
if "time" not in df.columns:
df = df.reset_index().rename(columns={"index": "time"})
times = pd.to_datetime(df["time"])
if getattr(times.dtype, "tz", None) is not None:
times = times.dt.tz_convert("UTC").dt.tz_localize(None)
df = df.assign(time=times)
return df.sort_values("time").reset_index(drop=True)
def build_ai_lookup(df_m15: pd.DataFrame) -> pd.DataFrame:
"""Per-M15-bar AI signal via the real model (same pipeline as live predict())."""
classifier = SignalClassifier()
if not classifier.load_model():
raise RuntimeError("XGBoost model not loaded — run scripts/train_ai.py first")
lookup = classifier.predict_batch(df_m15)
lookup["time"] = df_m15["time"].values
return lookup
def build_regime_plan(df_m5: pd.DataFrame, df_h4: pd.DataFrame) -> pd.DataFrame:
"""
Map each M5 decision bar (close time = open + 5m) to the last CLOSED H4 regime.
Uses only closed H4 bars (close time <= M5 close time) → zero lookahead.
"""
ta = TechnicalAnalyzer()
h4_ind = ta.add_all_indicators(df_h4.copy())
detector = RegimeDetector()
h4_closes, regimes, vols, adxs, should_trades = [], [], [], [], []
for idx in range(len(h4_ind)):
# Feed only bars up to and including this H4 bar (indicators precomputed)
ra = detector.analyze(h4_ind.iloc[: idx + 1])
h4_closes.append(df_h4["time"].iloc[idx] + pd.Timedelta(hours=4)) # bar close time
regimes.append(ra.regime.value)
vols.append(ra.volatility_label)
adxs.append(ra.adx)
should_trades.append(ra.should_trade)
closes_arr = np.array(sorted(h4_closes))
decision_times = pd.to_datetime(df_m5["time"]) + pd.Timedelta(minutes=5)
def _lookup(series, fallback):
series_arr = np.array(series)
out = []
for t in decision_times:
pos = np.searchsorted(closes_arr, t.to_pydatetime(), side="right") - 1
out.append(series_arr[pos] if pos >= 0 else fallback)
return out
return pd.DataFrame({
"regime": _lookup(regimes, "RANGING"),
"volatility": _lookup(vols, "NORMAL"),
"adx": [float(a) for a in _lookup(adxs, 15.0)],
"should_trade": [bool(s) for s in _lookup(should_trades, True)],
})
# ── Config ─────────────────────────────────────────────────────────────────
def active_backtest_params() -> dict:
settings = get_settings()
bt = dict(settings.trading_params.get("backtest", {}))
# Defaults mirror live moderate scalping (see trading_params.yaml)
bt.setdefault("min_confluence", 50)
bt.setdefault("min_technical_score", 35)
bt.setdefault("cooldown_bars", 0)
bt.setdefault("max_hold_bars", 0)
bt.setdefault("breakeven_rr", 0.0)
bt.setdefault("atr_trail_multiplier", 2.0)
bt.setdefault("trail_trigger_atr", 1.2)
bt.setdefault("lot_size", 0.02)
bt.setdefault("initial_balance", 10000.0)
bt.setdefault("warmup_bars", 200)
bt.setdefault("backtest_start", None) # optional "YYYY-MM-DD" to slice history
bt.setdefault("backtest_end", None)
return bt
def run_single_config(bt: dict) -> PerformanceMetrics:
"""Fetch data, build lookups, run the backtester for one config -> metrics."""
connector = MT5Connector()
if not connector.connect():
raise RuntimeError("Failed to connect to MT5. Ensure MT5 is running and logged in.")
try:
df_m5 = fetch_rates(connector, "M5", 5200)
df_m15 = fetch_rates(connector, "M15", 1800)
df_h4 = fetch_rates(connector, "H4", 200)
finally:
connector.disconnect()
# Optional history slice (e.g. train on older data, validate on newer)
for name, col in (("backtest_start", "start"), ("backtest_end", "end")):
val = bt.get(name)
if val:
key = pd.to_datetime(val)
mask = df_m5["time"] >= key if col == "start" else df_m5["time"] <= key
df_m5 = df_m5[mask].reset_index(drop=True)
if len(df_m5) <= int(bt["warmup_bars"]) + 50:
raise RuntimeError(f"M5 history too short after slice: {len(df_m5)} bars")
logger.info(f"Building AI lookup on {len(df_m15)} M15 bars...")
ai_lookup = build_ai_lookup(df_m15)
logger.info(f"Building regime plan over {len(df_m5)} M5 bars from H4...")
regime_plan = build_regime_plan(df_m5, df_h4)
strategies = None
bt_obj = Backtester(
initial_balance=float(bt["initial_balance"]),
lot_size=float(bt["lot_size"]),
min_confluence_score=float(bt["min_confluence"]),
ai_lookup=ai_lookup,
regime_plan=regime_plan,
cooldown_bars=int(bt["cooldown_bars"]),
max_hold_bars=int(bt["max_hold_bars"]),
breakeven_rr=float(bt["breakeven_rr"]),
atr_trail_multiplier=float(bt["atr_trail_multiplier"]),
trail_trigger_atr=float(bt["trail_trigger_atr"]),
)
# Grid injection: strategy-level thresholds
for strategy in bt_obj.strategies.values():
strategy.min_confluence_score = float(bt["min_confluence"])
strategy.min_technical_score = float(bt["min_technical_score"])
bt_obj.min_confluence = float(bt["min_confluence"])
metrics = bt_obj.run(df_m5, warmup_bars=int(bt["warmup_bars"]))
return metrics
def append_result(config: dict, metrics: PerformanceMetrics):
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
row = {
**config,
"total_trades": metrics.total_trades,
"win_rate": metrics.win_rate,
"profit_factor": metrics.profit_factor,
"expectancy": metrics.expectancy,
"total_pnl": metrics.total_pnl,
"max_drawdown_pct": metrics.max_drawdown_pct,
"sharpe_ratio": metrics.sharpe_ratio,
"avg_bars_held": metrics.avg_bars_held,
}
file_exists = RESULTS_CSV.exists()
df = pd.DataFrame([row])
df.to_csv(RESULTS_CSV, mode="a", header=not file_exists, index=False)
logger.info(f"📄 Grid result appended: {RESULTS_CSV}")
def gate_review(metrics: PerformanceMetrics) -> bool:
passed = (
metrics.expectancy > 0
and metrics.profit_factor >= 1.2
and metrics.max_drawdown_pct <= 15.0
and metrics.total_trades >= 60
)
status = "✅ GATE PASSED — this config may be considered for live deployment" if passed else "❌ GATE FAILED"
logger.info(f"{status}")
return passed
def main():
parser = argparse.ArgumentParser(description="Real-data backtest with live parity")
parser.add_argument("--list", action="store_true", help="print active backtest config and exit")
args = parser.parse_args()
bt = active_backtest_params()
logger.info(f"Active backtest config: {bt}")
if args.list:
return
metrics = run_single_config(bt)
append_result(bt, metrics)
gate_review(metrics)
if __name__ == "__main__":
main()
+24 -11
View File
@@ -1,8 +1,8 @@
# ============================================
# XAUUSD AI Trading Bot - Environment Variables
# XAUUSD AI 交易机器人 - 环境变量
# ============================================
# Copy this file to .env and fill in your values
# NEVER commit the .env file to version control!
# 将此文件复制为 .env 并填入你的配置值
# 切勿将 .env 文件提交到版本控制!
# --- MetaTrader 5 / Exness ---
MT5_LOGIN=your_mt5_login_number
@@ -10,23 +10,36 @@ MT5_PASSWORD=your_mt5_password
MT5_SERVER=Exness-MT5Real
MT5_PATH=C:\Program Files\MetaTrader 5\terminal64.exe
# --- Google Gemini AI ---
GEMINI_API_KEY=your_gemini_api_key
# --- OpenAI 兼容大模型 (OpenAI / OpenRouter / DeepSeek / Moonshot / Qwen / Ollama...) ---
# 只需将 AI_BASE_URL 指向任意兼容 OpenAI 的 /v1 端点即可切换服务商。
AI_API_KEY=your_openai_compatible_api_key
AI_BASE_URL=https://openrouter.ai/api/v1
AI_MODEL=openrouter/auto
# AI_VISION_MODEL — 可选;留空 = 复用 AI_MODEL
AI_VISION_MODEL=
# AI_MODEL_FAILOVER — 可选;逗号分隔的备用模型列表
AI_MODEL_FAILOVER=
# --- Finnhub (Financial News) ---
# 向后兼容:如果 AI_API_KEY 为空,机器人将回退使用旧的
# GEMINI_API_KEY 值。要通过 OpenAI 兼容端点使用 Google Gemini:
# AI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
# (或保留下方原生密钥,配合 /v1/chat/completions 模型名称使用)
GEMINI_API_KEY=you_can_leave_this_blank
# --- Finnhub (财经新闻) ---
FINNHUB_API_KEY=your_finnhub_api_key
# --- Alpha Vantage (News & Data) ---
# --- Alpha Vantage (新闻与数据) ---
ALPHA_VANTAGE_API_KEY=your_alpha_vantage_api_key
# --- Telegram Bot ---
# --- Telegram 机器人 ---
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
TELEGRAM_CHAT_ID=your_telegram_chat_id
# --- Dashboard ---
# --- 仪表盘 ---
DASHBOARD_SECRET_KEY=generate_a_random_secret_key_here
DASHBOARD_USERNAME=admin
DASHBOARD_PASSWORD=your_dashboard_password
# --- Database ---
DATABASE_URL=sqlite:///./data/trading_bot.db
# --- 数据库 ---
DATABASE_URL=sqlite:///./data/trading_bot.db
+51 -6
View File
@@ -9,9 +9,11 @@ import os
from pathlib import Path
from typing import Any
from typing import Annotated
import yaml
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, NoDecode
# ── Project Paths ──────────────────────────────────────────────────────────
@@ -35,6 +37,21 @@ _COMMON_CONFIG = {
}
def _strip_env_comment(value: object) -> str:
"""Strip an inline '# ...' comment (and surrounding whitespace) from a raw
.env value. Some dotenv parsers treat '#' after whitespace as an inline
comment, others do not — make behaviour deterministic here so a value like
'AI_MODEL=foo # comment' never leaks the comment text into settings."""
if value is None:
return ""
text = str(value).strip()
# Cut at the first whitespace-padded '#' that is not part of a URL/path.
marker = text.find(" #")
if marker != -1:
text = text[:marker].strip()
return text
def load_trading_params() -> dict:
"""Load trading parameters from YAML config file."""
params_path = CONFIG_DIR / "trading_params.yaml"
@@ -69,13 +86,41 @@ class MT5Settings(BaseSettings):
model_config = _COMMON_CONFIG
class GeminiSettings(BaseSettings):
"""Google Gemini AI settings."""
class LLMSettings(BaseSettings):
"""OpenAI-compatible LLM settings.
api_key: str = Field(default="", alias="GEMINI_API_KEY")
model: str = Field(default="gemini-3.5-flash-lite", alias="GEMINI_MODEL")
Works with any provider exposing an OpenAI-compatible /v1 chat API:
OpenAI, OpenRouter, DeepSeek, Moonshot/Kimi, Qwen/DashScope, Ollama, etc.
Switch provider purely by changing AI_BASE_URL / AI_MODEL.
If AI_API_KEY is empty, it falls back to the legacy GEMINI_API_KEY value
(Google's OpenAI-compatible endpoint is
https://generativelanguage.googleapis.com/v1beta/openai/).
"""
api_key: str = Field(default="", alias="AI_API_KEY")
gemini_api_key: str = Field(default="", alias="GEMINI_API_KEY")
base_url: str = Field(default="https://api.openai.com/v1", alias="AI_BASE_URL")
model: str = Field(default="gpt-4o-mini", alias="AI_MODEL")
vision_model: str = Field(default="", alias="AI_VISION_MODEL") # empty = reuse model
model_failover: Annotated[list[str], NoDecode] = Field(
default_factory=list, alias="AI_MODEL_FAILOVER"
) # comma-separated backup models; NoDecode avoids pydantic-settings'
# built-in JSON decode of the raw env string (fails on empty values)
max_tokens: int = 2048
temperature: float = 0.3 # Low temp for factual analysis
timeout: float = 12.0 # Per-request timeout in seconds
@field_validator("vision_model", mode="before")
@classmethod
def strip_comment_vision(cls, v):
return _strip_env_comment(v)
@field_validator("model_failover", mode="before")
@classmethod
def parse_failover(cls, v):
if isinstance(v, str):
return [p.strip() for p in v.split(",") if _strip_env_comment(p)]
return v or []
model_config = _COMMON_CONFIG
@@ -127,7 +172,7 @@ class Settings(BaseSettings):
# Sub-settings
mt5: MT5Settings = MT5Settings()
gemini: GeminiSettings = GeminiSettings()
llm: LLMSettings = LLMSettings()
news: NewsSettings = NewsSettings()
telegram: TelegramSettings = TelegramSettings()
dashboard: DashboardSettings = DashboardSettings()
+31 -3
View File
@@ -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
@@ -117,6 +117,15 @@ scalping:
- london
- new_york
- london_ny_overlap
# day_trading RE-ENABLED 2026-10-21 after passing the prove-before-live gate
# (backtesting/backtest_day_trading_gate.py, H1 frame, 2025-12-03..2026-10-21):
# conf=live(55) -> 72 trades, WR 70.8%, PF 3.45, exp +$47.91, maxDD 2.25% PASS
# conf=raw(70) -> 0 trades (threshold so high no signal ever fires) FAIL
# NOTE: `min_confluence_score: 70` below is the raw spec; the LIVE threshold is
# always the trading_modes.<mode>.day_trading_min_confluence override (moderate=55,
# the gated value). 70 is inert - no mode uses it, and the gate proved it dead.
# Disclosed divergences: backtest trails on H1-ATR (frame ATR), live bot trails on
# M5-ATR (tighter); mistake guard base now follows the strategy's own params.
day_trading:
enabled: true
timeframes:
@@ -224,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
View File
@@ -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}")
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XAUUSD AI Trading Station — Autonomous Institutional Command</title>
<meta name="description" content="Institutional-grade real-time AI Gold trading station powered by Gemini 3.6 Flash, MetaTrader 5 IPC, and algorithmic confluence.">
<meta name="description" content="Institutional-grade real-time AI Gold trading station powered by OpenAI-compatible LLM, MetaTrader 5 IPC, and algorithmic confluence.">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
+25
View File
@@ -691,8 +691,33 @@ def get_all_adaptive_weights() -> dict[str, dict]:
"win_count": r.win_count,
"loss_count": r.loss_count,
"total_pnl": r.total_pnl,
"updated_at": r.updated_at,
}
for r in records
}
finally:
session.close()
def update_adaptive_multiplier(component: str, multiplier: float) -> None:
"""Update only the persisted weight/multiplier of an adaptive component.
Unlike save_or_update_adaptive_weight this does NOT touch win/loss counts
or total_pnl — it is used by multiplier time-decay so a stale penalty can
drift back toward 1.0 without fabricating trade history.
"""
session = get_session()
try:
record = session.query(AdaptiveWeight).filter(AdaptiveWeight.component == component).first()
if record is None:
return # nothing persisted yet → nothing to decay
record.weight = multiplier
record.multiplier = multiplier
record.updated_at = datetime.now(timezone.utc)
session.commit()
except Exception as e:
session.rollback()
logger.error(f"Failed to update adaptive multiplier for {component}: {e}")
raise
finally:
session.close()
+58 -10
View File
@@ -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}")
+17
View File
@@ -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:
+102
View File
@@ -19,6 +19,108 @@ if hasattr(sys.stderr, "reconfigure"):
from loguru import logger
# ── Windows startup-freeze fix ─────────────────────────────────────────────
# asyncio.run() → ProactorEventLoop.__init__ calls socket.socketpair(). On
# Windows Python 3.12 that goes through _fallback_socketpair(), which does a
# loopback (127.0.0.1) TCP connect/accept dance. Under the run_bot.bat console
# path, security software filters that loopback handshake → accept() blocks
# FOREVER → the bot hangs before start() ever runs (2 init lines on console,
# log file stays empty, process idle at 0 CPU). Windows 10+ supports AF_UNIX
# sockets, which need no loopback TCP at all, so build the self-pipe pair over
# AF_UNIX and fall back to the stock socketpair if AF_UNIX is unavailable.
if sys.platform == "win32":
import ctypes as _ctypes
import os as _os
import socket as _socket_mod
import tempfile as _tempfile
# Python builds (incl. python.org 3.12) don't expose socket.AF_UNIX on
# Windows even though Winsock has supported it since Win10 17063, so we
# call ws2_32 directly. socket.socket(fileno=N) on Windows interprets N as
# a raw SOCKET handle (proven empirically), so the pair wraps cleanly.
_ws2 = _ctypes.WinDLL("ws2_32", use_last_error=True)
_ws2.socket.argtypes = [_ctypes.c_int, _ctypes.c_int, _ctypes.c_int]
_ws2.socket.restype = _ctypes.c_size_t
_ws2.bind.argtypes = [_ctypes.c_size_t, _ctypes.c_void_p, _ctypes.c_int]
_ws2.bind.restype = _ctypes.c_int
_ws2.listen.argtypes = [_ctypes.c_size_t, _ctypes.c_int]
_ws2.listen.restype = _ctypes.c_int
_ws2.connect.argtypes = [_ctypes.c_size_t, _ctypes.c_void_p, _ctypes.c_int]
_ws2.connect.restype = _ctypes.c_int
_ws2.accept.argtypes = [_ctypes.c_size_t, _ctypes.c_void_p, _ctypes.POINTER(_ctypes.c_int)]
_ws2.accept.restype = _ctypes.c_size_t
_ws2.closesocket.argtypes = [_ctypes.c_size_t]
_ws2.closesocket.restype = _ctypes.c_int
class _SockAddrUn(_ctypes.Structure):
_fields_ = [
("sun_family", _ctypes.c_ushort),
("sun_path", _ctypes.c_char * 260), # Windows uses MAX_PATH
]
_AF_UNIX = 1 # Winsock2.h: AF_UNIX == 1
_SOCK_STREAM = 1
_INVALID_SOCKET = _ctypes.c_size_t(-1).value
def _unix_socketpair():
path = _os.path.join(
_tempfile.gettempdir(),
f"xau_sp_{_os.getpid()}_{_os.urandom(4).hex()}.sock",
)
server = _ws2.socket(_AF_UNIX, _SOCK_STREAM, 0)
if server == _INVALID_SOCKET:
raise _ctypes.OSError(_ctypes.get_last_error(), "socket(AF_UNIX)")
client = _INVALID_SOCKET
accepted = _INVALID_SOCKET
ok = False
try:
addr = _SockAddrUn()
addr.sun_family = _AF_UNIX
path_b = path.encode("utf-8")
if len(path_b) >= 260:
raise ValueError("AF_UNIX path too long")
addr.sun_path = path_b + b"\x00"
addr_len = _ctypes.sizeof(_ctypes.c_ushort) + len(path_b) + 1
if _ws2.bind(server, _ctypes.byref(addr), addr_len) != 0:
raise _ctypes.OSError(_ctypes.get_last_error(), "bind AF_UNIX")
if _ws2.listen(server, 1) != 0:
raise _ctypes.OSError(_ctypes.get_last_error(), "listen AF_UNIX")
client = _ws2.socket(_AF_UNIX, _SOCK_STREAM, 0)
if client == _INVALID_SOCKET:
raise _ctypes.OSError(_ctypes.get_last_error(), "socket(AF_UNIX) client")
if _ws2.connect(client, _ctypes.byref(addr), addr_len) != 0:
raise _ctypes.OSError(_ctypes.get_last_error(), "connect AF_UNIX")
accepted = _ws2.accept(server, None, None)
if accepted == _INVALID_SOCKET:
raise _ctypes.OSError(_ctypes.get_last_error(), "accept AF_UNIX")
# Ownership of both raw handles transfers to the Python sockets.
result = (_socket_mod.socket(fileno=accepted), _socket_mod.socket(fileno=client))
ok = True
return result
finally:
if server != _INVALID_SOCKET:
_ws2.closesocket(server)
if not ok:
for h in (accepted, client):
if h != _INVALID_SOCKET:
_ws2.closesocket(h)
try:
_os.unlink(path)
except OSError:
pass # unique temp name → leftovers never collide
_orig_socketpair = _socket_mod.socketpair
def _safe_socketpair(*args, **kwargs):
try:
return _unix_socketpair()
except Exception:
# Rare fallback (pre-17063 Windows without AF_UNIX) — same
# behavior as before the patch.
return _orig_socketpair(*args, **kwargs)
_socket_mod.socketpair = _safe_socketpair
def parse_args():
parser = argparse.ArgumentParser(
+117 -134
View File
@@ -1,12 +1,14 @@
"""
News Analyzer — FinBERT + Gemini Sentiment Analysis
News Analyzer — FinBERT + OpenAI-compatible LLM Sentiment Analysis
Two-tier analysis:
1. FinBERT: Fast, local sentiment scoring (bullish/bearish/neutral)
2. Gemini API: Deep analysis — impact prediction, risk assessment
2. LLM API (OpenAI-compatible, configurable provider): Deep analysis —
impact prediction, risk assessment
"""
import json
import threading
from typing import Optional
from loguru import logger
@@ -17,36 +19,60 @@ from config.settings import get_settings
class NewsAnalyzer:
"""
Analyzes financial news using FinBERT for fast sentiment
and Gemini API for deeper market impact analysis.
and the configured OpenAI-compatible LLM for deeper market impact analysis.
"""
def __init__(self):
self.settings = get_settings()
self._finbert_pipeline = None
self._finbert_checked = False
self._gemini_client = None
self._gemini_cooldown_until = 0.0
self._finbert_loading = False
self._finbert_ready = threading.Event()
self._llm_cooldown_until = 0.0
def _get_finbert(self):
"""Lazy-load the FinBERT sentiment pipeline."""
if self._finbert_pipeline is not None:
return self._finbert_pipeline
if self._finbert_checked:
return None
def _warm_finbert(self):
"""Start loading FinBERT in a daemon thread so the event loop never blocks.
self._finbert_checked = True
Root cause fix: `from transformers import pipeline` takes 15-30s on this
machine (Python 3.12 importlib.metadata scans every file of every
installed package; huge site-packages + Windows Defender per-stat cost).
It used to run on the asyncio main thread → the whole bot froze for that
long on the first news article. Now the load happens off the loop and
the lexicon fallback is used until it's ready.
"""
if self._finbert_loading or self._finbert_checked:
return
self._finbert_loading = True
def _load():
try:
self._finbert_pipeline = self._build_finbert()
finally:
self._finbert_checked = True
self._finbert_loading = False
self._finbert_ready.set()
threading.Thread(target=_load, name="finbert-warmup", daemon=True).start()
def _build_finbert(self):
"""Build the FinBERT pipeline (the slow part). Runs in a worker thread."""
try:
import os
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
# Mainland China cannot reach huggingface.co directly (WinError 10060
# connection timeout). Route downloads through the official mirror
# https://hf-mirror.com unless the user set a real HF_ENDPOINT /
# proxy in their system environment (setdefault never overrides it).
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
from transformers import pipeline
self._finbert_pipeline = pipeline(
pipeline_ = pipeline(
"sentiment-analysis",
model="ProsusAI/finbert",
top_k=None,
)
logger.info("FinBERT model loaded successfully")
return self._finbert_pipeline
return pipeline_
except ImportError:
logger.info("FinBERT (transformers) not installed — using built-in financial lexicon analyzer")
return None
@@ -54,25 +80,17 @@ class NewsAnalyzer:
logger.warning(f"Could not load FinBERT: {e} — using financial lexicon fallback")
return None
def _get_gemini(self):
"""Lazy-load the Gemini client."""
if self._gemini_client is None:
try:
from google import genai
try:
from google.genai import models
models.Models._logged_afc_warning = True
except Exception:
pass
def _get_finbert(self):
"""Return the FinBERT pipeline if ready, else None (lexicon fallback).
self._gemini_client = genai.Client(
api_key=self.settings.gemini.api_key
)
logger.info("Gemini client initialized")
except Exception as e:
logger.warning(f"Failed to initialize Gemini: {e}")
return None
return self._gemini_client
Never blocks: kicks off the background load if needed and returns None
while it's still warming up.
"""
if self._finbert_pipeline is not None:
return self._finbert_pipeline
if not self._finbert_checked and not self._finbert_loading:
self._warm_finbert()
return None
def _analyze_with_lexicon(self, text: str) -> dict:
"""
@@ -213,11 +231,12 @@ class NewsAnalyzer:
"analysis": analysis_desc,
}
async def analyze_with_gemini(self, headline: str, summary: str = "") -> dict:
async def analyze_with_llm(self, headline: str, summary: str = "") -> dict:
"""
Deep analysis using Gemini API.
Deep analysis using the configured OpenAI-compatible LLM provider.
Asks Gemini to analyze the news impact on XAUUSD.
Falls back to the financial lexicon analyzer when the LLM is
unavailable, rate-limited (60s cooldown), or not configured.
Returns:
Dict with 'sentiment', 'score', 'impact_level',
@@ -225,13 +244,15 @@ class NewsAnalyzer:
"""
import time
now = time.time()
if now < self._gemini_cooldown_until:
return self._fallback_lexicon_analysis(headline, summary, reason="Gemini Cooldown")
from ai.llm_client import LLMError, get_llm_client
client = self._get_gemini()
if client is None:
return self._fallback_lexicon_analysis(headline, summary, reason="Gemini Unavailable")
now = time.time()
if now < self._llm_cooldown_until:
return self._fallback_lexicon_analysis(headline, summary, reason="LLM Cooldown")
llm_settings = self.settings.llm
if not (llm_settings.api_key or llm_settings.gemini_api_key):
return self._fallback_lexicon_analysis(headline, summary, reason="LLM Not Configured")
prompt = f"""You are an expert Gold (XAUUSD) market analyst. Analyze this news article and determine its potential impact on the XAUUSD price.
@@ -255,99 +276,58 @@ Important context:
- Gold falls on: rate hike expectations, strong USD, risk-on sentiment, low inflation
"""
import asyncio
try:
result = await get_llm_client().complete_json(prompt)
except LLMError as e:
err_msg = e.message
status = e.status
if str(status) == "429" or "429" in err_msg or "quota" in err_msg.lower():
self._llm_cooldown_until = time.time() + 60.0
logger.info("LLM API rate limit reached — temporarily switching to financial lexicon analyzer (60s cooldown)")
else:
logger.warning(f"LLM analysis fallback (temporary issue: {err_msg[:90]}...)")
return self._fallback_lexicon_analysis(headline, summary, reason="Lexicon Fallback")
# Candidate model failover list to handle 503 capacity spikes automatically
candidate_models = [self.settings.gemini.model, "gemini-3.5-flash-lite", "gemini-3.5-flash"]
# Remove duplicates while preserving order
candidate_models = list(dict.fromkeys(candidate_models))
if not isinstance(result, dict):
logger.warning("LLM analysis returned non-dict result — lexicon fallback")
return self._fallback_lexicon_analysis(headline, summary, reason="Lexicon Fallback")
last_error = None
# Strict validation and boundary clamping for AI-derived fields
try:
score = float(result.get("score", 0.0))
except (ValueError, TypeError):
score = 0.0
score = max(-1.0, min(1.0, score))
for model_name in candidate_models:
try:
def _call_gemini_sync(m_name=model_name):
return client.models.generate_content(
model=m_name,
contents=prompt,
config={
"temperature": self.settings.gemini.temperature,
"max_output_tokens": self.settings.gemini.max_tokens,
},
)
sentiment = str(result.get("sentiment", "NEUTRAL")).strip().upper()
if sentiment not in ("BULLISH", "BEARISH", "NEUTRAL"):
sentiment = "NEUTRAL"
response = await asyncio.wait_for(
asyncio.to_thread(_call_gemini_sync),
timeout=12.0
)
text = response.text.strip()
impact = str(result.get("impact_level", "LOW")).strip().upper()
if impact not in ("HIGH", "MEDIUM", "LOW"):
impact = "LOW"
# Robust JSON extraction from potential markdown or introductory text
import re
json_match = re.search(r'\{[\s\S]*\}', text)
if json_match:
result = json.loads(json_match.group(0))
else:
result = json.loads(text)
direction = str(result.get("expected_direction", "FLAT")).strip().upper()
if direction not in ("UP", "DOWN", "FLAT"):
direction = "FLAT"
# Strict validation and boundary clamping for AI-derived fields
try:
score = float(result.get("score", 0.0))
except (ValueError, TypeError):
score = 0.0
score = max(-1.0, min(1.0, score))
result["score"] = score
result["sentiment"] = sentiment
result["impact_level"] = impact
result["expected_direction"] = direction
sentiment = str(result.get("sentiment", "NEUTRAL")).strip().upper()
if sentiment not in ("BULLISH", "BEARISH", "NEUTRAL"):
sentiment = "NEUTRAL"
impact = str(result.get("impact_level", "LOW")).strip().upper()
if impact not in ("HIGH", "MEDIUM", "LOW"):
impact = "LOW"
direction = str(result.get("expected_direction", "FLAT")).strip().upper()
if direction not in ("UP", "DOWN", "FLAT"):
direction = "FLAT"
result["score"] = score
result["sentiment"] = sentiment
result["impact_level"] = impact
result["expected_direction"] = direction
logger.info(
f"Gemini analysis ({model_name}): {sentiment} "
f"(score={score:.2f}, impact={impact})"
)
return result
except Exception as e:
last_error = e
err_str = str(e)
if "503" in err_str or "UNAVAILABLE" in err_str or "404" in err_str:
logger.debug(f"Gemini model {model_name} unavailable ({err_str[:60]}), trying next candidate...")
continue
else:
break
err_msg = str(last_error) if last_error else "Unknown error"
if self.settings.gemini.api_key:
err_msg = err_msg.replace(self.settings.gemini.api_key, "***GEMINI_KEY***")
if "429" in err_msg or "RESOURCE_EXHAUSTED" in err_msg or "quota" in err_msg.lower():
import time
self._gemini_cooldown_until = time.time() + 60.0
logger.info("Gemini API rate limit reached — temporarily switching to financial lexicon analyzer (60s cooldown)")
else:
logger.warning(f"Gemini analysis fallback (temporary issue: {err_msg[:90]}...)")
return self._fallback_lexicon_analysis(headline, summary, reason="Lexicon Fallback")
logger.info(
f"LLM analysis ({llm_settings.model}): {sentiment} "
f"(score={score:.2f}, impact={impact})"
)
return result
async def analyze_article(self, article: dict) -> dict:
"""
Full two-tier analysis of a news article.
1. Quick FinBERT sentiment
2. Deep Gemini analysis (if article seems important)
2. Deep LLM analysis (if article seems important)
"""
headline = article.get("headline", "")
summary = article.get("summary", "")
@@ -355,26 +335,27 @@ Important context:
# Tier 1: FinBERT (fast baseline)
finbert_result = self.analyze_with_finbert(headline + ". " + summary)
# Tier 2: Gemini (deep financial macro reasoning)
gemini_result = None
if self.settings.gemini.api_key:
gemini_result = await self.analyze_with_gemini(headline, summary)
# Tier 2: LLM (deep financial macro reasoning)
llm_settings = self.settings.llm
llm_result = None
if llm_settings.api_key or llm_settings.gemini_api_key:
llm_result = await self.analyze_with_llm(headline, summary)
elif finbert_result["confidence"] > 0.5 and finbert_result["sentiment"] != "NEUTRAL":
gemini_result = await self.analyze_with_gemini(headline, summary)
llm_result = await self.analyze_with_llm(headline, summary)
else:
gemini_result = {
llm_result = {
"sentiment": finbert_result["sentiment"],
"score": finbert_result["score"],
"impact_level": "LOW",
"analysis": "Gemini API key not configured",
"analysis": "LLM API key not configured",
}
# Combine scores (weighted average: 40% FinBERT, 60% Gemini)
# Combine scores (weighted average: 40% FinBERT, 60% LLM)
finbert_score = finbert_result["score"]
gemini_score = gemini_result.get("score", 0)
llm_score = llm_result.get("score", 0)
if gemini_result.get("impact_level") != "LOW":
combined_score = finbert_score * 0.4 + gemini_score * 0.6
if llm_result.get("impact_level") != "LOW":
combined_score = finbert_score * 0.4 + llm_score * 0.6
else:
combined_score = finbert_score
@@ -391,6 +372,8 @@ Important context:
"sentiment": final_sentiment,
"combined_score": combined_score,
"finbert": finbert_result,
"gemini": gemini_result,
"impact_level": gemini_result.get("impact_level", "LOW"),
# Key kept as "gemini" for downstream compatibility (core/bot.py,
# dashboard/app.py, database columns gemini_score/gemini_analysis).
"gemini": llm_result,
"impact_level": llm_result.get("impact_level", "LOW"),
}
+11 -13
View File
@@ -572,7 +572,8 @@ class TelegramNotifier:
sentiment_score = crud.get_recent_sentiment(hours=24)
sent_emoji = "📈 BULLISH" if sentiment_score > 0.1 else ("📉 BEARISH" if sentiment_score < -0.1 else "➡️ NEUTRAL")
ai_model_name = getattr(self.settings.gemini, "model", "Gemini AI")
llm = self.settings.llm
ai_model_name = llm.model or llm.base_url or "OpenAI-compatible LLM"
msg = (
f"📰 <b>MARKET NEWS & AI SENTIMENT</b>\n"
f"{'━' * 28}\n\n"
@@ -1959,17 +1960,15 @@ class TelegramNotifier:
"""Analyze uploaded candlestick chart screenshot via AI Vision."""
if not update.message or not update.message.photo:
return
msg = await update.message.reply_text("🔍 <b>Analyzing Gold chart screenshot via Gemini Vision AI...</b>", parse_mode="HTML")
msg = await update.message.reply_text("🔍 <b>Analyzing Gold chart screenshot via AI Vision...</b>", parse_mode="HTML")
try:
photo = update.message.photo[-1]
photo_file = await context.bot.get_file(photo.file_id)
photo_bytes = await photo_file.download_as_bytearray()
try:
from google import genai
from google.genai import types
client = genai.Client(api_key=self.settings.gemini.api_key)
image_part = types.Part.from_bytes(data=bytes(photo_bytes), mime_type="image/jpeg")
from ai.llm_client import LLMError, get_llm_client
prompt = (
"You are a master XAUUSD Gold technical analyst. Analyze this chart image carefully:\n"
"1. Identify visible candlestick pattern & trend direction (Bullish / Bearish / Ranging).\n"
@@ -1977,19 +1976,18 @@ class TelegramNotifier:
"3. Read visible indicators (RSI, MACD, EMAs) if present.\n"
"4. Give a clear Trade Signal Recommendation: BUY, SELL, or HOLD, with suggested SL & TP."
)
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=[image_part, prompt]
analysis_text = await get_llm_client().analyze_image(
bytes(photo_bytes), prompt, mime_type="image/jpeg"
)
analysis_text = response.text or "Chart analysis generated successfully."
analysis_text = analysis_text or "Chart analysis generated successfully."
result_msg = (
f"📈 <b>AI VISION CHART ANALYSIS REPORT</b>\n"
f"{'━' * 28}\n\n"
f"{html.escape(analysis_text)}\n\n"
f"⚡ <i>Generated by Gemini Vision Engine for XAUUSD Bot</i>"
f"⚡ <i>Generated by AI Vision Engine for XAUUSD Bot</i>"
)
except Exception as e:
logger.warning(f"Gemini vision call failed, using fallback chart analyzer: {e}")
except LLMError as e: # also guards LLMError thrown by analyze_image
logger.warning(f"LLM vision call failed, using fallback chart analyzer: {e.message}")
result_msg = (
f"📈 <b>AI VISION CHART ANALYSIS REPORT</b>\n"
f"{'━' * 28}\n\n"
+2 -1
View File
@@ -23,7 +23,8 @@ joblib>=1.3
# NLP / Sentiment
transformers>=4.30
torch>=2.0
google-genai>=1.0
# Unified LLM chat (any OpenAI-compatible provider: OpenAI, OpenRouter, DeepSeek, Moonshot, Qwen, Ollama...)
openai>=1.0
# News APIs
httpx>=0.24
+18 -3
View File
@@ -73,16 +73,25 @@ class BaseStrategy(ABC):
self.enabled = params.get("enabled", True)
self._last_active_mode: Optional[str] = None
self._min_confluence_score_override: Optional[float] = None
self._min_technical_score_override: Optional[float] = None
self._default_sessions = params.get("active_sessions", [])
self._last_signal: Optional[TradingSignal] = None
def _check_mode_sync(self):
"""Ensure overrides are reset if the trading mode changes."""
"""Ensure overrides are reset if the trading mode changes.
Only clear on a genuine mode *transition*. `_last_active_mode` starts
as None, so naively treating None != current_mode as a change wiped
overrides set before the first property read — e.g. the backtester's
grid injection of min_confluence_score silently fell back to the YAML
default (every threshold produced identical results).
"""
try:
from config.settings import get_settings
current_mode = get_settings().active_mode
if getattr(self, "_last_active_mode", None) != current_mode:
self._last_active_mode = current_mode
prev = getattr(self, "_last_active_mode", None)
self._last_active_mode = current_mode
if prev is not None and prev != current_mode:
self._min_confluence_score_override = None
except Exception:
pass
@@ -135,6 +144,8 @@ class BaseStrategy(ABC):
@property
def min_technical_score(self) -> float:
"""Threshold for the strategy itself to emit a directional signal instead of HOLD."""
if self._min_technical_score_override is not None:
return self._min_technical_score_override
try:
from config.settings import get_settings
settings = get_settings()
@@ -148,6 +159,10 @@ class BaseStrategy(ABC):
pass
return 35.0
@min_technical_score.setter
def min_technical_score(self, val: float):
self._min_technical_score_override = val
@abstractmethod
def analyze(self, df: pd.DataFrame) -> dict:
"""
+16 -11
View File
@@ -43,10 +43,11 @@ class SignalAggregator:
The confluence score determines whether a trade should be executed.
"""
def __init__(self):
def __init__(self, use_adaptive_weights: bool = True):
settings = get_settings()
weights = settings.confluence_weights
self.use_adaptive_weights = use_adaptive_weights
self.technical_weight = weights.get("technical_weight", 40)
self.ai_weight = weights.get("ai_prediction_weight", 25)
self.sentiment_weight = weights.get("sentiment_weight", 20)
@@ -83,16 +84,20 @@ class SignalAggregator:
Returns:
ConfluenceResult with final score and execution decision.
"""
# Dynamically synchronize with self-learning adaptive weights
try:
from ai.trade_learner import trade_learner
cw = trade_learner.get_confluence_weights()
self.technical_weight = cw.get("technical", self.technical_weight)
self.ai_weight = cw.get("ai_prediction", self.ai_weight)
self.sentiment_weight = cw.get("sentiment", self.sentiment_weight)
self.regime_weight = cw.get("regime", self.regime_weight)
except Exception:
pass
# Dynamically synchronize with self-learning adaptive weights.
# Backtesting uses use_adaptive_weights=False so the gate measures the
# pure config weights — live garbage adaptive weights (leak #3) would
# otherwise contaminate every backtest confluence score.
if self.use_adaptive_weights:
try:
from ai.trade_learner import trade_learner
cw = trade_learner.get_confluence_weights()
self.technical_weight = cw.get("technical", self.technical_weight)
self.ai_weight = cw.get("ai_prediction", self.ai_weight)
self.sentiment_weight = cw.get("sentiment", self.sentiment_weight)
self.regime_weight = cw.get("regime", self.regime_weight)
except Exception:
pass
reasons = list(strategy_signal.reasons)
# ── 1. Technical Score (from strategy) ───────────────────────────
+1 -1
View File
@@ -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