Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a7b0111ad | ||
|
|
f38fa944ea | ||
|
|
7e50a0fef6 | ||
|
|
886de825fa | ||
|
|
d9b0b19c02 | ||
|
|
661fd8d941 | ||
|
|
239f4108df |
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
@@ -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
|
||||
|
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -40,6 +40,8 @@ class TradeExecutor:
|
||||
ai_confidence: float = 0.0,
|
||||
regime: str = "",
|
||||
risk_modifier: float = 1.0,
|
||||
ai_score: float = 0.0,
|
||||
regime_score: float = 0.0,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Execute a trading signal after full risk validation.
|
||||
@@ -52,6 +54,8 @@ class TradeExecutor:
|
||||
ai_confidence: AI confidence level.
|
||||
regime: Current market regime.
|
||||
risk_modifier: Position size modifier from regime.
|
||||
ai_score: AI prediction contribution to confluence (root cause #3 attribution).
|
||||
regime_score: Regime alignment contribution to confluence.
|
||||
|
||||
Returns:
|
||||
Trade result dict or None if rejected.
|
||||
@@ -75,6 +79,8 @@ class TradeExecutor:
|
||||
confluence_score=confluence_score,
|
||||
technical_score=signal.technical_score,
|
||||
sentiment_score=sentiment_score,
|
||||
ai_score=ai_score,
|
||||
regime_score=regime_score,
|
||||
was_executed=False,
|
||||
rejection_reason=reason,
|
||||
price_at_signal=signal.entry_price,
|
||||
@@ -92,6 +98,8 @@ class TradeExecutor:
|
||||
confluence_score=confluence_score,
|
||||
technical_score=signal.technical_score,
|
||||
sentiment_score=sentiment_score,
|
||||
ai_score=ai_score,
|
||||
regime_score=regime_score,
|
||||
was_executed=False,
|
||||
rejection_reason=reason,
|
||||
price_at_signal=signal.entry_price,
|
||||
@@ -125,6 +133,8 @@ class TradeExecutor:
|
||||
confluence_score=confluence_score,
|
||||
technical_score=signal.technical_score,
|
||||
sentiment_score=sentiment_score,
|
||||
ai_score=ai_score,
|
||||
regime_score=regime_score,
|
||||
was_executed=False,
|
||||
rejection_reason=validation_reason,
|
||||
price_at_signal=signal.entry_price,
|
||||
@@ -154,6 +164,8 @@ class TradeExecutor:
|
||||
confluence_score=confluence_score,
|
||||
technical_score=signal.technical_score,
|
||||
sentiment_score=sentiment_score,
|
||||
ai_score=ai_score,
|
||||
regime_score=regime_score,
|
||||
was_executed=False,
|
||||
rejection_reason=f"Execution error: {error_msg}",
|
||||
price_at_signal=signal.entry_price,
|
||||
@@ -188,6 +200,8 @@ class TradeExecutor:
|
||||
confluence_score=confluence_score,
|
||||
technical_score=signal.technical_score,
|
||||
sentiment_score=sentiment_score,
|
||||
ai_score=ai_score,
|
||||
regime_score=regime_score,
|
||||
was_executed=True,
|
||||
price_at_signal=actual_price,
|
||||
)
|
||||
@@ -235,7 +249,7 @@ class TradeExecutor:
|
||||
commission = 0.0
|
||||
closed_at = None
|
||||
|
||||
crud.close_trade(
|
||||
closed_trade = crud.close_trade(
|
||||
ticket=ticket,
|
||||
exit_price=exit_price,
|
||||
profit=profit,
|
||||
@@ -248,20 +262,43 @@ class TradeExecutor:
|
||||
)
|
||||
try:
|
||||
from ai.trade_learner import trade_learner
|
||||
trade_learner.on_trade_closed({
|
||||
"ticket": ticket,
|
||||
"strategy": position.get("comment", "scalping").replace("close_", ""),
|
||||
"direction": position.get("type", "BUY"),
|
||||
"profit": profit,
|
||||
"regime": "RANGING",
|
||||
"confluence_score": 75.0,
|
||||
"sentiment_score": 0.0,
|
||||
})
|
||||
# Root cause #3 fix: feed the self-learning engine the REAL
|
||||
# context from the DB trades row. The old code hardcoded
|
||||
# regime=RANGING / confluence=75 / sentiment=0, so every
|
||||
# lesson was misclassified and strat_scalping decayed to 0.527.
|
||||
trade_learner.on_trade_closed(
|
||||
self._learner_context(closed_trade, profit)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"TradeLearner hook warning for #{ticket}: {e}")
|
||||
|
||||
return success
|
||||
|
||||
def _learner_context(self, trade, profit: float) -> dict:
|
||||
"""Build an honest TradeLearner context from the DB trades row.
|
||||
|
||||
Shared by close_trade and sync_positions so both paths feed the
|
||||
self-learning engine the same truthful inputs (root cause #3 fix):
|
||||
strategy, direction, profit, regime, confluence, sentiment, AI call.
|
||||
"""
|
||||
from database.models import Trade as _Trade # local: avoid import cycle
|
||||
if trade is None:
|
||||
return {"ticket": 0, "strategy": "scalping", "direction": "BUY",
|
||||
"profit": profit, "regime": "RANGING",
|
||||
"confluence_score": 0.0, "sentiment_score": 0.0,
|
||||
"ai_prediction": "", "ai_confidence": 0.0}
|
||||
return {
|
||||
"ticket": getattr(trade, "ticket", 0),
|
||||
"strategy": getattr(trade, "strategy", "scalping") or "scalping",
|
||||
"direction": getattr(trade, "order_type", "BUY") or "BUY",
|
||||
"profit": profit,
|
||||
"regime": getattr(trade, "regime", "") or "RANGING",
|
||||
"confluence_score": float(getattr(trade, "confluence_score", 0.0) or 0.0),
|
||||
"sentiment_score": float(getattr(trade, "sentiment_score", 0.0) or 0.0),
|
||||
"ai_prediction": getattr(trade, "ai_prediction", "") or "",
|
||||
"ai_confidence": float(getattr(trade, "ai_confidence", 0.0) or 0.0),
|
||||
}
|
||||
|
||||
def close_partial(self, ticket: int, close_pct: float = 50.0) -> bool:
|
||||
"""Close a percentage of a position."""
|
||||
positions = self.mt5.get_open_positions()
|
||||
@@ -329,6 +366,17 @@ class TradeExecutor:
|
||||
"commission": commission,
|
||||
"reason": reason,
|
||||
})
|
||||
# Root cause #3 fix: externally-closed positions also feed
|
||||
# the self-learning engine with the same honest DB context.
|
||||
try:
|
||||
from ai.trade_learner import trade_learner
|
||||
trade_learner.on_trade_closed(
|
||||
self._learner_context(trade, profit)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"TradeLearner hook warning for externally-closed #{trade.ticket}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process externally closed position #{trade.ticket}: {e}")
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class TrailingStopManager:
|
||||
self.breakeven_rr = params.get("breakeven_trigger_rr", 1.0)
|
||||
self.partial_close_pct = params.get("partial_close_pct", 50)
|
||||
self.atr_trail_multiplier = params.get("atr_trail_multiplier", 2.0)
|
||||
self.trail_trigger_atr = params.get("trail_trigger_atr", 1.2)
|
||||
|
||||
# Track which positions have had breakeven applied
|
||||
self._breakeven_applied: set[int] = set()
|
||||
@@ -107,11 +108,24 @@ class TrailingStopManager:
|
||||
)
|
||||
|
||||
# ── 2. ATR Trailing Stop ─────────────────────────────────────────
|
||||
# Backtest parity (gate winner): trail only AFTER the position shows
|
||||
# trail_trigger_atr × ATR unrealized gain — no trailing from entry.
|
||||
if pos_type == "BUY":
|
||||
gain = current_price - entry_price
|
||||
else:
|
||||
gain = entry_price - current_price
|
||||
trailing_armed = gain > current_atr * self.trail_trigger_atr
|
||||
if not trailing_armed:
|
||||
return # Below trigger: never chase the entry-level trail
|
||||
|
||||
trail_distance = current_atr * self.atr_trail_multiplier
|
||||
|
||||
if pos_type == "BUY":
|
||||
# Trail up: new SL = current_price - (ATR * multiplier)
|
||||
proposed_sl = round(current_price - trail_distance, 2)
|
||||
# Never trail past TP (backtest parity)
|
||||
tick = 0.01 # XAUUSD price tick
|
||||
proposed_sl = min(proposed_sl, round(current_tp - tick, 2))
|
||||
|
||||
# Only move SL up, never down
|
||||
if proposed_sl > current_sl and proposed_sl < current_price:
|
||||
@@ -126,6 +140,9 @@ class TrailingStopManager:
|
||||
elif pos_type == "SELL":
|
||||
# Trail down: new SL = current_price + (ATR * multiplier)
|
||||
proposed_sl = round(current_price + trail_distance, 2)
|
||||
# Never trail past TP (backtest parity)
|
||||
tick = 0.01 # XAUUSD price tick
|
||||
proposed_sl = max(proposed_sl, round(current_tp + tick, 2))
|
||||
|
||||
# Only move SL down (for shorts), never up
|
||||
if proposed_sl < current_sl and proposed_sl > current_price:
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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) ───────────────────────────
|
||||
|
||||
@@ -142,7 +142,7 @@ def test_strategy_dynamic_confluence_adaptation():
|
||||
|
||||
# Switch to Moderate -> override MUST clear!
|
||||
settings.set_active_mode("moderate")
|
||||
assert scalp.min_confluence_score == 50.0, "Scalping override leaked into moderate mode!"
|
||||
assert scalp.min_confluence_score == 55.0, "Scalping override leaked into moderate mode!"
|
||||
assert scalp.min_technical_score == 35.0
|
||||
assert day.min_confluence_score == 55.0
|
||||
assert swing.min_confluence_score == 60.0
|
||||
|
||||
Reference in New Issue
Block a user