992 lines
45 KiB
Python
992 lines
45 KiB
Python
"""
|
||
AHAD QUANT V11 — AI Forex Trading Bot
|
||
All Pro features: Ensemble model, HMM Regime, Paper Mode,
|
||
Session Scanner, Grid Bot, DCA Bot, Auto-Retrain.
|
||
|
||
Supported brokers: OANDA (default), MetaTrader 5, ccxt Forex CFD.
|
||
Default pairs: EURUSD, GBPUSD, USDJPY, AUDUSD + 20 more.
|
||
|
||
Usage:
|
||
1. cp .env.example .env # add OANDA_API_KEY / MT5 credentials
|
||
2. python download_data.py # download Forex historical data
|
||
3. python train.py # train ensemble model on Forex data
|
||
4. python ahad_quant.py # start trading
|
||
|
||
Paper mode (no real money):
|
||
PAPER_MODE=true python ahad_quant.py
|
||
"""
|
||
|
||
import sys, os
|
||
|
||
# ── Fix encodage Windows (cp1252 → UTF-8) ────────────────────────────────────
|
||
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
if sys.stderr.encoding and sys.stderr.encoding.lower() != "utf-8":
|
||
sys.stderr.reconfigure(encoding="utf-8")
|
||
|
||
# ── Logging (FIX BUG-RL-04 : log.info() du RL agent était avalé) ─────────────
|
||
# rl_agent.py utilise logging.getLogger("RLAgent").info(...) — sans basicConfig,
|
||
# le root logger reste au niveau WARNING par défaut et ces messages ne
|
||
# sortaient JAMAIS sur stdout/stderr, donc jamais dans web_ui.py non plus.
|
||
import logging
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="[%(name)s] %(message)s",
|
||
stream=sys.stdout,
|
||
)
|
||
|
||
# ── Dependency check ─────────────────────────────────────────────────────────
|
||
_REQUIRED = {
|
||
"lightgbm": "lightgbm",
|
||
"numpy": "numpy",
|
||
"dotenv": "python-dotenv",
|
||
}
|
||
import importlib.util as _ilu
|
||
_missing = [p for m, p in _REQUIRED.items() if not _ilu.find_spec(m)]
|
||
if _missing:
|
||
print(f"[ERROR] Missing: {', '.join(_missing)}")
|
||
print(f"[ERROR] Run: pip install {' '.join(_missing)}")
|
||
sys.exit(1)
|
||
|
||
if not os.path.exists(".env") and not os.environ.get("EXCHANGE"):
|
||
print("[ERROR] No .env file. Run: cp .env.example .env")
|
||
sys.exit(1)
|
||
|
||
import os, pickle, time, traceback, threading
|
||
import numpy as np
|
||
import requests
|
||
import lightgbm as lgb
|
||
|
||
import config
|
||
from features import build_features, FEATURE_NAMES
|
||
from risk_manager import RiskManager
|
||
from exchange_adapter import ExchangeAdapter, get_exchange
|
||
import unified_brain
|
||
|
||
# ── RL Agent (optionnel) ──────────────────────────────────────────────────────
|
||
_RL_READY = False
|
||
try:
|
||
from rl_agent import get_rl_agent, PositionState, reset_rl_agent
|
||
_RL_READY = True
|
||
except ImportError:
|
||
pass
|
||
|
||
# ── MT5 Bridge (mode CSV) ─────────────────────────────────────────────────────
|
||
HAS_MT5_BRIDGE = False
|
||
if config.MT5_BRIDGE_ENABLED or config.EXCHANGE.lower() == "mt5":
|
||
try:
|
||
from mt5_bridge import MT5Bridge, TradeReport
|
||
HAS_MT5_BRIDGE = True
|
||
except ImportError:
|
||
print("[WARNING] MT5Bridge non trouvé — vérifiez mt5_bridge/mt5_bridge.py")
|
||
|
||
# ── Optional Pro modules ──────────────────────────────────────────────────────
|
||
|
||
try:
|
||
from regime_detector import GaussianHMM
|
||
HAS_REGIME = True
|
||
except ImportError:
|
||
HAS_REGIME = False
|
||
|
||
try:
|
||
from paper_trader import PaperTrader
|
||
HAS_PAPER = True
|
||
except ImportError:
|
||
HAS_PAPER = False
|
||
config.PAPER_MODE = False
|
||
|
||
try:
|
||
from pump_scanner import create_pump_scanner_from_config
|
||
HAS_PUMP = True
|
||
except ImportError:
|
||
HAS_PUMP = False
|
||
|
||
try:
|
||
from grid_bot import GridBot
|
||
HAS_GRID = True
|
||
except ImportError:
|
||
HAS_GRID = False
|
||
|
||
try:
|
||
from dca_bot import DCABot
|
||
HAS_DCA = True
|
||
except ImportError:
|
||
HAS_DCA = False
|
||
|
||
try:
|
||
from auto_retrain import AutoRetrainer
|
||
HAS_RETRAIN = True
|
||
except ImportError:
|
||
HAS_RETRAIN = False
|
||
|
||
# ── Apprentissage Continu V7 (optionnel — fail-safe) ─────────────────────────
|
||
_CONTINUOUS_LEARNING = False
|
||
_buffer = None
|
||
_learner = None
|
||
_monitor = None
|
||
try:
|
||
if getattr(config, "CONTINUOUS_LEARNING_ENABLED", False):
|
||
from experience_buffer import get_experience_buffer
|
||
from online_learner import OnlineLearner
|
||
from performance_monitor import PerformanceMonitor, MonitorThread
|
||
_buffer = get_experience_buffer(
|
||
max_size = getattr(config, "EXPERIENCE_BUFFER_MAX_SIZE", 10000),
|
||
auto_load = True,
|
||
)
|
||
_learner = OnlineLearner(_buffer)
|
||
_monitor = PerformanceMonitor(_buffer)
|
||
_CONTINUOUS_LEARNING = True
|
||
import logging as _cl_log
|
||
_cl_log.getLogger("ahad_quant").info(
|
||
f"[CL] Apprentissage continu activé — buffer: {_buffer.total_trades} trades"
|
||
)
|
||
except Exception as _cl_err:
|
||
import logging as _cl_log
|
||
_cl_log.getLogger("ahad_quant").warning(
|
||
f"[CL] Apprentissage continu désactivé (erreur import) : {_cl_err}"
|
||
)
|
||
|
||
# ── Apprentissage Continu V7 — fonctions utilitaires module-level ────────────
|
||
|
||
def _build_entry_context(
|
||
coin: str,
|
||
signal: str,
|
||
confidence: float,
|
||
features,
|
||
price: float,
|
||
ml_probas=None,
|
||
rl_action=None,
|
||
rl_agreed=None,
|
||
regime=None,
|
||
) -> dict:
|
||
"""
|
||
Construit le dictionnaire de contexte au moment de l'ouverture d'un trade.
|
||
Stocké dans self._entry_context[coin] et récupéré à la fermeture pour
|
||
construire l'expérience complète dans le buffer.
|
||
"""
|
||
import time as _time
|
||
ctx = {
|
||
"coin": coin,
|
||
"signal": signal,
|
||
"confidence": confidence,
|
||
"price": price,
|
||
"timestamp": _time.time(),
|
||
}
|
||
if ml_probas is not None:
|
||
ctx["ml_probas"] = ml_probas
|
||
if rl_action is not None:
|
||
ctx["rl_action"] = rl_action
|
||
if rl_agreed is not None:
|
||
ctx["rl_agreed"] = rl_agreed
|
||
if regime is not None:
|
||
ctx["regime"] = regime
|
||
# Sauvegarder les features sous forme de liste (JSON-sérialisable).
|
||
# Accepte soit une seule ligne de features déjà extraite (1D — c'est ce
|
||
# que fournit unified_brain.Decision.features), soit un batch (2D,
|
||
# ancien format) dont on prend la dernière ligne.
|
||
if features is not None:
|
||
try:
|
||
if hasattr(features, "iloc"):
|
||
ctx["features"] = features.iloc[-1].tolist()
|
||
else:
|
||
arr = np.asarray(features)
|
||
ctx["features"] = arr[-1].tolist() if arr.ndim == 2 else arr.tolist()
|
||
except Exception:
|
||
ctx["features"] = None
|
||
return ctx
|
||
|
||
|
||
def _inject_closed_trade(coin: str, pnl: float, outcome: str, entry_ctx: dict) -> None:
|
||
"""
|
||
Appelée à chaque fermeture de trade (paper ou live).
|
||
[FIX] Découplage buffer / apprentissage :
|
||
- Le buffer et la calibration seuil tournent TOUJOURS (même si
|
||
CONTINUOUS_LEARNING_ENABLED=False) — les mauvais trades s'accumulent
|
||
sans interruption, indépendamment de la décision de remplacement modèle.
|
||
- Le warm-start LGB et le RL replay restent contrôlés par le flag.
|
||
Fail-safe : toute exception est attrapée, le bot continue normalement.
|
||
"""
|
||
try:
|
||
import time as _time
|
||
import logging as _cl_log
|
||
_log = _cl_log.getLogger("ahad_quant")
|
||
|
||
experience = {
|
||
"coin": coin,
|
||
"pnl": pnl,
|
||
"outcome": outcome, # "WIN" | "LOSS" | "TIMEOUT"
|
||
"close_time": _time.time(),
|
||
**{k: v for k, v in entry_ctx.items() if k != "coin"},
|
||
}
|
||
|
||
# ── Accumulation buffer : TOUJOURS actif ──────────────────────────
|
||
# Même si CONTINUOUS_LEARNING_ENABLED=False, on stocke chaque trade
|
||
# pour que le prochain cycle (manuel ou automatique) ait un buffer
|
||
# plein et puisse apprendre des erreurs passées.
|
||
if _buffer is not None:
|
||
_buffer.add(experience)
|
||
_log.debug(f"[CL-BUFFER] Trade stocké : {coin} {outcome} PnL={pnl:+.2f}")
|
||
|
||
# ── Calibration seuil : TOUJOURS active ───────────────────────────
|
||
# MIN_CONFIDENCE s'ajuste après chaque trade, sans attendre un cycle
|
||
# de retrain. C'est la correction immédiate la plus légère qui soit.
|
||
if _learner is not None:
|
||
new_conf = _learner.calibrate_threshold(recent_n=50)
|
||
_log.debug(f"[CL-CALIB] conf seuil → {new_conf:.3f}")
|
||
|
||
# ── Monitor drift : actif si learner disponible ────────────────────
|
||
if _monitor is not None and _CONTINUOUS_LEARNING:
|
||
status = _monitor.check()
|
||
if status in ("DANGER", "EMERGENCY"):
|
||
_log.warning(f"[CL] Monitor : niveau {status} — vérifier les performances")
|
||
|
||
except Exception as _cl_exc:
|
||
import logging as _cl_log
|
||
_cl_log.getLogger("ahad_quant").warning(f"[CL] _inject_closed_trade erreur (non-bloquant) : {_cl_exc}")
|
||
|
||
|
||
# ── Deep Learning inference (TFT + TransformerGRU) ────────────────────────────
|
||
# Relocalisée dans ensemble_core.py (utilisée par unified_brain.py) — voir ce
|
||
# module pour le détail du bug #1 que cette centralisation corrige. ahad_quant.py
|
||
# n'a plus besoin d'importer torch/TFT/TGRU directement.
|
||
|
||
|
||
# ── Banner ────────────────────────────────────────────────────────────────────
|
||
|
||
def _banner():
|
||
mode = "📄 PAPER MODE — Forex (aucun argent réel)" if config.PAPER_MODE else "💰 LIVE FOREX MODE"
|
||
print("\n" + "=" * 60)
|
||
print(" AHAD QUANT V11 — AI Forex Trading Bot")
|
||
print(f" {mode}")
|
||
print("=" * 60)
|
||
broker_label = f"{config.EXCHANGE} (MT5 CSV Bridge)" if config.MT5_BRIDGE_ENABLED else config.EXCHANGE
|
||
print(f" Broker: {broker_label}")
|
||
print(f" Ensemble: {'✓' if config.USE_ENSEMBLE else '✗'} (LightGBM+XGBoost+RF)")
|
||
print(f" HMM Regime: {'✓' if config.USE_REGIME_FILTER else '✗'}")
|
||
print(f" Session Scanner: {'✓' if config.PUMP_SCANNER_ENABLED else '✗'}")
|
||
print(f" Grid Bot: {'✓' if config.GRID_BOT_ENABLED else '✗'}")
|
||
print(f" DCA Bot: {'✓' if config.DCA_BOT_ENABLED else '✗'}")
|
||
print(f" Auto-Retrain: {'✓' if config.AUTO_RETRAIN_ENABLED else '✗'}")
|
||
# FIX BUG-RL-04 : le statut RL n'apparaissait jamais dans le banner.
|
||
# On force le chargement ici (au lieu d'attendre le 1er signal) pour
|
||
# avoir une confirmation immédiate et visible au démarrage.
|
||
rl_status = "✗ (désactivé — USE_RL_AGENT=false)"
|
||
if not _RL_READY:
|
||
rl_status = "✗ (module rl_agent.py introuvable / ImportError)"
|
||
elif config.USE_RL_AGENT:
|
||
try:
|
||
if get_rl_agent().is_ready():
|
||
rl_status = "✓ (chargé — mode filter)"
|
||
else:
|
||
rl_status = "✗ (échec de chargement — voir logs [RLAgent] ci-dessus)"
|
||
except Exception as _rl_banner_err:
|
||
rl_status = f"✗ (exception au chargement : {_rl_banner_err})"
|
||
print(f" RL Agent: {rl_status}")
|
||
print("-" * 60 + "\n")
|
||
|
||
_banner()
|
||
|
||
# ── Model loading & ensemble prediction ──────────────────────────────────────
|
||
# Relocalisées dans unified_brain.py (UnifiedBrain._load_ml / .decide()), qui
|
||
# délègue le ML à ensemble_core.py — la source UNIQUE de vérité pour
|
||
# l'inférence d'ensemble, partagée avec rl_env.py et rl_agent.py. Corrige le
|
||
# point #1 du diagnostic : l'ancienne version ici pouvait lever une
|
||
# ValueError non-attrapée (model_data["scaler"].transform() avec un nombre
|
||
# de features incohérent) si has_dl=True mais l'historique trop court pour
|
||
# la branche DL — ensemble_core gère ce cas en interne, sans jamais
|
||
# remonter d'exception.
|
||
|
||
# ── Bot live state (écrit dans bot_state.json à chaque loop) ──────────────────
|
||
_BOT_STATE: dict = {
|
||
"regime": 1, "regime_label": "NORMAL",
|
||
"risk": {}, "pump": {}, "timestamp": None,
|
||
"consecutive_losses": 0, "circuit_breaker_until": 0,
|
||
"daily_pnl": 0.0, "daily_pnl_pct": 0.0, "equity": 0.0,
|
||
}
|
||
_BOT_STATE_LOCK = threading.Lock()
|
||
|
||
_REGIME_LABELS = {0: "CALM", 1: "NORMAL", 2: "VOLATILE"}
|
||
|
||
# _detect_regime() relocalisée dans unified_brain.UnifiedBrain._detect_regime
|
||
# (même implémentation HMM exacte, juste portée par l'instance du brain au
|
||
# lieu d'un état module-level global). _scan_entries() met à jour
|
||
# _BOT_STATE["regime"]/["regime_label"] lui-même après chaque decide(),
|
||
# pour garder unified_brain.py découplé de l'état du bot.
|
||
|
||
|
||
# ── Main bot class ────────────────────────────────────────────────────────────
|
||
|
||
class AHAD QUANT:
|
||
|
||
def __init__(self):
|
||
# Paper mode
|
||
self.paper: PaperTrader | None = None
|
||
if config.PAPER_MODE:
|
||
if not HAS_PAPER:
|
||
print("[ERROR] paper_trader.py not found")
|
||
sys.exit(1)
|
||
self.paper = PaperTrader(config.PAPER_INITIAL_BALANCE)
|
||
print("[PAPER] Paper trading active — no real orders will be placed")
|
||
|
||
# Temporal exit tracking — LOOKAHEAD=3 candles (3h)
|
||
# Coin → unix timestamp of entry. Used to close positions that exceed
|
||
# the model's prediction horizon regardless of TP/SL status.
|
||
self._entry_times: dict[str, float] = {}
|
||
self._entry_context: dict = {} # CL V7 — contexte ouverture par coin
|
||
|
||
# Cerveau unifié ML + RL + régime — source unique pour toute décision
|
||
# de trading (voir unified_brain.py). Remplace l'ancien
|
||
# self.model_data brut chargé via load_model().
|
||
self.brain = unified_brain.get_brain()
|
||
if not self.brain.is_ready():
|
||
if config.PAPER_MODE:
|
||
print("[PAPER] No model found — paper mode will use random signals for testing")
|
||
else:
|
||
print("\n" + "=" * 55)
|
||
print(" MODEL NOT FOUND")
|
||
print(" Run: python train.py")
|
||
print(" Or set PAPER_MODE=true to simulate without a model")
|
||
print("=" * 55 + "\n")
|
||
_ml_path = config.ENSEMBLE_MODEL_PATH if config.USE_ENSEMBLE else config.MODEL_PATH
|
||
raise FileNotFoundError(f"Model not found at {_ml_path}")
|
||
|
||
# Exchange connection OU MT5 Bridge
|
||
self.mt5_bridge: "MT5Bridge | None" = None # type: ignore[name-defined]
|
||
|
||
if config.MT5_BRIDGE_ENABLED or config.EXCHANGE.lower() == "mt5":
|
||
# ── Mode MT5 CSV Bridge ───────────────────────────────────────────
|
||
if not HAS_MT5_BRIDGE:
|
||
print("[ERROR] MT5_BRIDGE_ENABLED=true mais mt5_bridge.py introuvable")
|
||
print("[ERROR] Vérifiez mt5_bridge/mt5_bridge.py")
|
||
sys.exit(1)
|
||
if not config.MT5_FILES_PATH:
|
||
print("[ERROR] MT5_FILES_PATH manquant dans .env")
|
||
print("[ERROR] Ex: MT5_FILES_PATH=C:/Users/NOM/AppData/.../MQL5/Files")
|
||
sys.exit(1)
|
||
|
||
self.exchange = None # type: ignore[assignment]
|
||
self.mt5_bridge = MT5Bridge(
|
||
files_path=config.MT5_FILES_PATH,
|
||
# poll_interval géré en interne par _PollingWatcher (défaut 200ms)
|
||
)
|
||
|
||
# Callback : rapport reçu de l'EA (ordre ouvert ou fermé)
|
||
def _on_report(report: "TradeReport"): # type: ignore[name-defined]
|
||
icon = "✅" if report.status == "CLOSED" else ("🔴" if report.status == "ERROR" else "📋")
|
||
profit_str = f" | PnL: {report.profit:+.2f}" if report.profit else ""
|
||
msg = (f"{icon} [{report.status}] {report.signal_id} | "
|
||
f"ticket #{report.ticket}{profit_str}")
|
||
print(f" [MT5] {msg}")
|
||
|
||
self.mt5_bridge.on_report_received(_on_report)
|
||
self.mt5_bridge.start()
|
||
print(f"[MT5] Bridge démarré → {config.MT5_FILES_PATH}")
|
||
else:
|
||
# ── Mode normal ccxt/OANDA ────────────────────────────────────────
|
||
self.exchange: ExchangeAdapter = get_exchange(config.EXCHANGE)
|
||
self.exchange.connect()
|
||
|
||
# Risk manager
|
||
self.risk = RiskManager()
|
||
|
||
# Leverage — skipped en paper mode et en mode MT5 (géré côté EA)
|
||
if not config.PAPER_MODE and self.exchange is not None:
|
||
for coin in config.COINS:
|
||
try: self.exchange.set_leverage(coin, config.LEVERAGE)
|
||
except Exception: pass
|
||
|
||
# Pump scanner
|
||
self._pump_scanner = None
|
||
if config.PUMP_SCANNER_ENABLED and HAS_PUMP:
|
||
try:
|
||
self._pump_scanner = create_pump_scanner_from_config()
|
||
if self._pump_scanner:
|
||
self._pump_scanner.start()
|
||
print("[PUMP] Pump scanner started")
|
||
except Exception as e:
|
||
print(f"[PUMP] Failed to start: {e}")
|
||
|
||
# Grid bot
|
||
self._grid_bot = None
|
||
if config.GRID_BOT_ENABLED and HAS_GRID:
|
||
try:
|
||
self._grid_bot = GridBot(self.exchange)
|
||
t = threading.Thread(target=self._grid_bot.start, daemon=True)
|
||
t.start()
|
||
print(f"[GRID] Grid bot started ({config.GRID_STRATEGY})")
|
||
except Exception as e:
|
||
print(f"[GRID] Failed to start: {e}")
|
||
|
||
# DCA bot
|
||
self._dca_bot = None
|
||
if config.DCA_BOT_ENABLED and HAS_DCA:
|
||
try:
|
||
self._dca_bot = DCABot(self.exchange)
|
||
t = threading.Thread(target=self._dca_bot.start, daemon=True)
|
||
t.start()
|
||
print(f"[DCA] DCA bot started ({config.DCA_STRATEGY})")
|
||
except Exception as e:
|
||
print(f"[DCA] Failed to start: {e}")
|
||
|
||
# Auto-retrain
|
||
self._retrainer = None
|
||
if HAS_RETRAIN and config.AUTO_RETRAIN_ENABLED:
|
||
self._retrainer = AutoRetrainer(notify_fn=lambda msg: print(f"[RETRAIN] {msg}"))
|
||
self._retrainer.start()
|
||
print(f"[RETRAIN] Auto-retrain every {config.AUTO_RETRAIN_INTERVAL_HOURS}h")
|
||
|
||
print("\nAHAD QUANT initialised successfully\n")
|
||
|
||
# ── Position management ───────────────────────────────────────────────────
|
||
|
||
def _sync_positions(self):
|
||
if config.PAPER_MODE:
|
||
return # paper handles own state
|
||
if self.mt5_bridge is not None:
|
||
# En mode MT5, on se fie au status.csv écrit par l'EA
|
||
# Les positions sont trackées via les signaux en attente
|
||
return
|
||
positions = self.exchange.get_positions()
|
||
for pos in positions:
|
||
coin = pos["coin"]
|
||
if coin not in self.risk.open_positions:
|
||
self.risk.register_open(coin, pos["side"], pos["entry"], abs(pos["size"]))
|
||
active = {p["coin"] for p in positions}
|
||
for coin in list(self.risk.open_positions):
|
||
if coin not in active:
|
||
self.risk.open_positions.pop(coin, None)
|
||
|
||
def _check_exits(self):
|
||
if self.mt5_bridge is not None:
|
||
# En mode MT5, l'EA gère les SL/TP nativement.
|
||
# Les fermetures sont rapportées via on_report_received (callback).
|
||
# Pas d'action nécessaire ici.
|
||
return
|
||
if config.PAPER_MODE and self.paper:
|
||
book_cache = {}
|
||
for coin in list(self.paper.positions):
|
||
try:
|
||
book = self.exchange.get_orderbook(coin)
|
||
price = book["mid"]
|
||
except Exception:
|
||
continue
|
||
|
||
# ── Temporal exit : LOOKAHEAD candles → config.MAX_HOLD_CANDLES × 1h ────
|
||
# check_sl_tp() already handles this via opened_at timestamp,
|
||
# but _entry_times covers the case where state was loaded from
|
||
# disk before this session started (no opened_at in memory).
|
||
elapsed = time.time() - self._entry_times.get(coin, time.time())
|
||
if config.TIMEOUT_ENABLED and elapsed >= config.MAX_HOLD_CANDLES * 3600:
|
||
reason = "timeout"
|
||
else:
|
||
reason = self.paper.check_sl_tp(coin, price)
|
||
|
||
if reason:
|
||
result = self.paper.close_position(coin, price, reason)
|
||
self._entry_times.pop(coin, None)
|
||
pnl = result.get("pnl", 0)
|
||
sign = "+" if pnl >= 0 else ""
|
||
label = '🛑 SL' if reason=='sl' else ('✅ TP' if reason=='tp' else '⏱ TIMEOUT')
|
||
print(f" [EXIT] {label} {coin} | PnL: {sign}{pnl:.2f} USDT [Paper]")
|
||
# ── Apprentissage Continu V7 — POINT 2 (fermeture paper) ──
|
||
_inject_closed_trade(
|
||
coin=coin, pnl=pnl,
|
||
outcome="WIN" if pnl > 0 else ("TIMEOUT" if reason=="timeout" else "LOSS"),
|
||
entry_ctx=self._entry_context.pop(coin, {}),
|
||
)
|
||
return
|
||
|
||
for coin in list(self.risk.open_positions):
|
||
try:
|
||
book = self.exchange.get_orderbook(coin)
|
||
price = book["mid"]
|
||
except Exception:
|
||
continue
|
||
|
||
# ── Temporal exit : LOOKAHEAD candles → config.MAX_HOLD_CANDLES × 1h ─────
|
||
elapsed = time.time() - self._entry_times.get(coin, time.time())
|
||
if config.TIMEOUT_ENABLED and elapsed >= config.MAX_HOLD_CANDLES * 3600:
|
||
exit_reason = "timeout"
|
||
else:
|
||
exit_reason = self.risk.check_exit(coin, price)
|
||
|
||
if exit_reason:
|
||
pos = self.risk.open_positions[coin]
|
||
result = self.exchange.close_position(coin)
|
||
if result.get("success"):
|
||
pnl = self.risk.register_close(coin, price)
|
||
self._entry_times.pop(coin, None)
|
||
sign = "+" if pnl >= 0 else ""
|
||
label = "🛑 SL" if exit_reason == "sl" else ("✅ TP" if exit_reason == "tp" else "⏱ TIMEOUT")
|
||
msg = (f"{label} {coin} | "
|
||
f"{pos['side'].upper()} | PnL: {sign}{pnl:.2f} USDT")
|
||
print(f" [EXIT] {msg}")
|
||
|
||
def _scan_entries(self):
|
||
if not self.brain.is_ready():
|
||
return
|
||
|
||
equity = (self.paper.get_balance() if config.PAPER_MODE
|
||
else self._mt5_get_equity() if self.mt5_bridge is not None
|
||
else self.exchange.get_balance())
|
||
if equity <= 0:
|
||
return
|
||
|
||
# Quick slot check — si déjà plein, inutile de scanner
|
||
n_positions = (len(self.paper.positions) if config.PAPER_MODE
|
||
else self._mt5_get_n_positions() if self.mt5_bridge is not None
|
||
else len(self.risk.open_positions))
|
||
if n_positions >= config.MAX_POSITIONS:
|
||
return
|
||
|
||
# ── Risk controls — appliqués identiquement en paper ET live ────────────
|
||
if config.PAPER_MODE:
|
||
# Daily loss limit (paper)
|
||
if equity > 0 and (self.paper.daily_pnl / equity) <= -config.MAX_DAILY_LOSS_PCT:
|
||
print(f" [PAPER RISK] Daily loss limit hit ({config.MAX_DAILY_LOSS_PCT*100:.1f}%)")
|
||
return
|
||
# Circuit breaker (paper)
|
||
if time.time() < self.paper.circuit_breaker_until:
|
||
remaining = int(self.paper.circuit_breaker_until - time.time())
|
||
print(f" [PAPER RISK] Circuit breaker active ({remaining}s left)")
|
||
return
|
||
elif self.mt5_bridge is not None:
|
||
# En mode MT5, le risk manager n'est pas alimenté en continu.
|
||
# Vérification basique : equity > 0 suffit (déjà vérifiée au-dessus).
|
||
pass
|
||
else:
|
||
can_open, reason = self.risk.can_open(equity)
|
||
if not can_open:
|
||
print(f" [RISK] {reason}")
|
||
return
|
||
|
||
# Paire de référence pour la corrélation (remplace BTC en Forex)
|
||
# EURUSD est la paire dominante — utilisée comme proxy de sentiment global
|
||
_ref_pair = "EURUSD"
|
||
try:
|
||
if self.mt5_bridge is not None:
|
||
btc_candles = None # pas d'exchange dispo pour les candles de référence
|
||
else:
|
||
btc_candles = self.exchange.get_candles(_ref_pair, "1h", 200)
|
||
except Exception:
|
||
btc_candles = None
|
||
|
||
# ── Collecte tous les signaux, tri par confiance (= comportement backtest) ──
|
||
candidates = []
|
||
coin_decisions: dict[str, unified_brain.Decision] = {}
|
||
if self.mt5_bridge is not None:
|
||
# En mode MT5 : on considère les paires sans signal en attente
|
||
# Bug #22b fix : get_open_signals() peut retourner des objets Signal
|
||
# (dataclass/namedtuple) OU des dicts selon la version de mt5_bridge.
|
||
# On gère les deux cas pour éviter le TypeError "not subscriptable".
|
||
def _get_pair(s):
|
||
try:
|
||
return s["pair"] # dict
|
||
except (TypeError, KeyError):
|
||
return getattr(s, "pair", "") # dataclass / namedtuple
|
||
# On stocke les paires SANS suffixe pour comparaison avec config.COINS
|
||
_suffix = config.MT5_SYMBOL_SUFFIX
|
||
pending_pairs = {
|
||
_get_pair(s).removesuffix(_suffix)
|
||
for s in self.mt5_bridge.get_open_signals()
|
||
}
|
||
open_set = pending_pairs
|
||
else:
|
||
open_set = (set(self.paper.positions) if config.PAPER_MODE
|
||
else set(self.risk.open_positions))
|
||
|
||
for coin in config.COINS:
|
||
if coin in open_set:
|
||
continue
|
||
|
||
try:
|
||
if self.mt5_bridge is not None:
|
||
# ── Mode MT5 : données via yfinance (gratuit, sans clé API) ──
|
||
import yfinance as yf
|
||
ticker_sym = f"{coin}=X"
|
||
ticker = yf.Ticker(ticker_sym)
|
||
df = ticker.history(period="30d", interval="1h",
|
||
auto_adjust=True, prepost=False)
|
||
if df is None or df.empty or len(df) < 50:
|
||
continue
|
||
candles = [
|
||
{
|
||
"t": int(ts.timestamp() * 1000),
|
||
"o": float(row["Open"]),
|
||
"h": float(row["High"]),
|
||
"l": float(row["Low"]),
|
||
"c": float(row["Close"]),
|
||
"v": float(row.get("Volume", 0)),
|
||
}
|
||
for ts, row in df.iterrows()
|
||
]
|
||
candles.sort(key=lambda x: x["t"])
|
||
candles = candles[-200:] # garder les 200 dernières bougies
|
||
else:
|
||
candles = self.exchange.get_candles(coin, "1h", 200)
|
||
except Exception:
|
||
continue
|
||
if not candles or len(candles) < 50:
|
||
continue
|
||
|
||
try:
|
||
funding = self.exchange.get_funding_rate(coin) if self.exchange else 0.0
|
||
except Exception:
|
||
funding = 0.0
|
||
|
||
signal, confidence = None, None # placeholders, écrasés ci-dessous
|
||
pos_state = PositionState(
|
||
in_position = coin in (
|
||
self.paper.positions if config.PAPER_MODE
|
||
else self.risk.open_positions
|
||
),
|
||
direction = 0,
|
||
balance_ratio = 1.0,
|
||
) if _RL_READY else None
|
||
|
||
decision = self.brain.decide(
|
||
candles, btc_candles=btc_candles, funding=funding,
|
||
position_state=pos_state, pair=coin,
|
||
)
|
||
signal, confidence = decision.signal, decision.confidence
|
||
if signal == "neutral":
|
||
continue
|
||
|
||
# Réplique l'ancien effet de bord de _detect_regime() : l'état
|
||
# global du bot reflète le régime détecté au dernier scan.
|
||
with _BOT_STATE_LOCK:
|
||
_BOT_STATE["regime"] = decision.regime
|
||
_BOT_STATE["regime_label"] = decision.regime_label
|
||
|
||
if decision.rl_used and decision.rl_signal != decision.ml_signal:
|
||
print(f"[RL-FILTER] {coin}: ML={decision.ml_signal}({decision.ml_confidence:.2f}) "
|
||
f"→ RL={decision.rl_signal}({decision.rl_confidence:.2f})")
|
||
|
||
try:
|
||
if self.mt5_bridge is not None:
|
||
# En mode MT5, self.exchange est None — prix depuis le dernier candle yfinance
|
||
price = candles[-1]["c"]
|
||
else:
|
||
book = self.exchange.get_orderbook(coin)
|
||
price = book["mid"]
|
||
except Exception:
|
||
continue
|
||
|
||
coin_decisions[coin] = decision
|
||
candidates.append((confidence, coin, signal, price))
|
||
|
||
# Trier par confiance décroissante — les meilleurs signaux passent en premier
|
||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||
|
||
for confidence, coin, signal, price in candidates:
|
||
# Re-vérifier les slots disponibles à chaque itération
|
||
if self.mt5_bridge is not None:
|
||
n_open = self._mt5_get_n_positions()
|
||
elif config.PAPER_MODE:
|
||
n_open = len(self.paper.positions)
|
||
else:
|
||
n_open = len(self.risk.open_positions)
|
||
|
||
if n_open >= config.MAX_POSITIONS:
|
||
break
|
||
|
||
if not config.PAPER_MODE and self.mt5_bridge is None:
|
||
can_open, _ = self.risk.can_open(equity)
|
||
if not can_open:
|
||
break
|
||
|
||
qty = self.risk.calc_quantity(equity, price)
|
||
msg = (f"🚀 OPEN {signal.upper()} `{coin}` | "
|
||
f"Price: {price:.4f} | Conf: {confidence:.1%}")
|
||
|
||
if config.PAPER_MODE:
|
||
result = self.paper.open_position(
|
||
coin, signal, price, qty,
|
||
sl_pct=config.STOP_LOSS_PCT,
|
||
tp_pct=config.TAKE_PROFIT_PCT,
|
||
)
|
||
if result.get("success"):
|
||
self._entry_times[coin] = time.time()
|
||
print(f" [PAPER TRADE] {msg}")
|
||
# ── CL V7 — sauvegarde contexte ouverture ──
|
||
decision = coin_decisions.get(coin)
|
||
self._entry_context[coin] = _build_entry_context(
|
||
coin=coin, signal=signal, confidence=confidence,
|
||
features=(decision.features if decision else None),
|
||
ml_probas=(decision.ml_proba if decision else None),
|
||
rl_action=(decision.rl_action if decision else None),
|
||
rl_agreed=(decision.rl_agreed if decision else None),
|
||
regime=(decision.regime_label if decision else None),
|
||
price=price,
|
||
)
|
||
elif self.mt5_bridge is not None:
|
||
# ── Mode MT5 : écriture dans signals.csv ─────────────────────
|
||
# Bug #23 fix : ajouter le suffixe broker (ex ".m") au symbole.
|
||
# config.COINS contient "USDJPY", le broker attend "USDJPY.m".
|
||
mt5_pair = coin + config.MT5_SYMBOL_SUFFIX
|
||
lot = self._calc_lot_mt5(qty)
|
||
sl_pips = self._pct_to_pips(coin, price, config.STOP_LOSS_PCT)
|
||
tp_pips = self._pct_to_pips(coin, price, config.TAKE_PROFIT_PCT)
|
||
action = "BUY" if signal == "long" else "SELL"
|
||
sig = self.mt5_bridge.write_signal(
|
||
pair=mt5_pair,
|
||
action=action,
|
||
lot=lot,
|
||
sl_pips=sl_pips,
|
||
tp_pips=tp_pips,
|
||
confidence=confidence,
|
||
)
|
||
if sig:
|
||
self._entry_times[coin] = time.time()
|
||
print(f" [MT5 SIGNAL] {msg} | lot={lot} SL={sl_pips}p TP={tp_pips}p → {sig.signal_id} (symbol={mt5_pair})")
|
||
decision = coin_decisions.get(coin)
|
||
self._entry_context[coin] = _build_entry_context(
|
||
coin=coin, signal=signal, confidence=confidence,
|
||
features=(decision.features if decision else None),
|
||
ml_probas=(decision.ml_proba if decision else None),
|
||
rl_action=(decision.rl_action if decision else None),
|
||
rl_agreed=(decision.rl_agreed if decision else None),
|
||
regime=(decision.regime_label if decision else None),
|
||
price=price,
|
||
)
|
||
else:
|
||
side_str = "buy" if signal == "long" else "sell"
|
||
result = self.exchange.place_market_order(coin, side_str, qty)
|
||
if result.get("success"):
|
||
self.risk.register_open(coin, signal, price, qty)
|
||
self._entry_times[coin] = time.time()
|
||
print(f" [TRADE] {msg}")
|
||
decision = coin_decisions.get(coin)
|
||
self._entry_context[coin] = _build_entry_context(
|
||
coin=coin, signal=signal, confidence=confidence,
|
||
features=(decision.features if decision else None),
|
||
ml_probas=(decision.ml_proba if decision else None),
|
||
rl_action=(decision.rl_action if decision else None),
|
||
rl_agreed=(decision.rl_agreed if decision else None),
|
||
regime=(decision.regime_label if decision else None),
|
||
price=price,
|
||
)
|
||
|
||
time.sleep(0.5)
|
||
|
||
# ── MT5 helpers ───────────────────────────────────────────────────────────
|
||
|
||
def _write_bot_state(self, equity: float = 0.0, daily_pnl: float = 0.0) -> None:
|
||
"""Écrit l'état live du bot dans bot_state.json (lu par web_ui.py)."""
|
||
try:
|
||
risk_data = {}
|
||
if not config.PAPER_MODE and self.mt5_bridge is None and hasattr(self, "risk"):
|
||
r = self.risk
|
||
cb_remaining = max(0, int(r.circuit_breaker_until - time.time())) if time.time() < r.circuit_breaker_until else 0
|
||
used_margin = sum(p["entry"] * p["qty"] for p in r.open_positions.values()) if equity > 0 else 0
|
||
risk_data = {
|
||
"circuit_breaker_active": time.time() < r.circuit_breaker_until,
|
||
"circuit_breaker_remaining_s": cb_remaining,
|
||
"consecutive_losses": r.consecutive_losses,
|
||
"daily_pnl": round(r.daily_pnl, 2),
|
||
"daily_pnl_pct": round(r.daily_pnl / equity * 100, 2) if equity > 0 else 0,
|
||
"margin_used": round(used_margin, 2),
|
||
"margin_used_pct": round(used_margin / equity * 100, 1) if equity > 0 else 0,
|
||
"daily_loss_limit_pct": config.MAX_DAILY_LOSS_PCT * 100,
|
||
"max_margin_usage_pct": config.MAX_MARGIN_USAGE * 100,
|
||
"open_positions": len(r.open_positions),
|
||
"max_positions": config.MAX_POSITIONS,
|
||
}
|
||
elif config.PAPER_MODE and self.paper:
|
||
p = self.paper
|
||
cb_remaining = max(0, int(p.circuit_breaker_until - time.time())) if hasattr(p, "circuit_breaker_until") and time.time() < p.circuit_breaker_until else 0
|
||
risk_data = {
|
||
"circuit_breaker_active": hasattr(p, "circuit_breaker_until") and time.time() < p.circuit_breaker_until,
|
||
"circuit_breaker_remaining_s": cb_remaining,
|
||
"consecutive_losses": getattr(p, "consecutive_losses", 0),
|
||
"daily_pnl": round(getattr(p, "daily_pnl", 0), 2),
|
||
"daily_pnl_pct": round(getattr(p, "daily_pnl", 0) / equity * 100, 2) if equity > 0 else 0,
|
||
"margin_used": 0, "margin_used_pct": 0,
|
||
"daily_loss_limit_pct": config.MAX_DAILY_LOSS_PCT * 100,
|
||
"max_margin_usage_pct": config.MAX_MARGIN_USAGE * 100,
|
||
"open_positions": len(getattr(p, "positions", {})),
|
||
"max_positions": config.MAX_POSITIONS,
|
||
}
|
||
|
||
pump_data = {}
|
||
if self._pump_scanner is not None:
|
||
ps = self._pump_scanner
|
||
open_pumps = {}
|
||
try:
|
||
open_pumps = {k: {
|
||
"side": v.side if hasattr(v, "side") else "?",
|
||
"entry": round(v.entry_price if hasattr(v, "entry_price") else 0, 5),
|
||
"score": round(v.signal.confidence if hasattr(v, "signal") and v.signal else 0, 3),
|
||
} for k, v in ps.pump_positions.items()}
|
||
except Exception:
|
||
pass
|
||
pump_data = {
|
||
"active": True,
|
||
"open_positions": open_pumps,
|
||
"open_count": len(open_pumps),
|
||
"daily_pnl": round(getattr(ps, "_daily_pump_pnl", 0), 2),
|
||
}
|
||
|
||
with _BOT_STATE_LOCK:
|
||
_BOT_STATE["equity"] = round(equity, 2)
|
||
_BOT_STATE["daily_pnl"] = round(daily_pnl, 2)
|
||
_BOT_STATE["risk"] = risk_data
|
||
_BOT_STATE["pump"] = pump_data
|
||
_BOT_STATE["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||
state_copy = dict(_BOT_STATE)
|
||
|
||
state_path = os.path.join(os.path.dirname(__file__), "bot_state.json")
|
||
with open(state_path, "w") as f:
|
||
json.dump(state_copy, f, indent=2)
|
||
except Exception as _bse:
|
||
pass # Non-bloquant
|
||
|
||
def _mt5_get_equity(self) -> float:
|
||
"""Retourne l'équité du compte MT5 depuis status.csv (0 si indisponible)."""
|
||
if self.mt5_bridge is None:
|
||
return 0.0
|
||
try:
|
||
status = self.mt5_bridge.read_status()
|
||
return status.equity if status else 0.0
|
||
except Exception:
|
||
return 0.0
|
||
|
||
def _mt5_get_n_positions(self) -> int:
|
||
"""Retourne le nombre de positions ouvertes côté MT5."""
|
||
if self.mt5_bridge is None:
|
||
return 0
|
||
try:
|
||
status = self.mt5_bridge.read_status()
|
||
if status:
|
||
return status.open_positions
|
||
# Fallback : compte les signaux en attente (sans rapport de clôture)
|
||
return len(self.mt5_bridge.get_open_signals())
|
||
except Exception:
|
||
return 0
|
||
|
||
def _calc_lot_mt5(self, qty: float) -> float:
|
||
"""Convertit une quantité en unités vers des lots MT5 (1 lot = 100 000 unités)."""
|
||
lot = qty / config.UNITS_PER_LOT
|
||
lot = max(config.MIN_LOT_SIZE, min(config.MAX_LOT_SIZE, lot))
|
||
return round(lot, 2)
|
||
|
||
def _pct_to_pips(self, pair: str, price: float, pct: float) -> int:
|
||
"""
|
||
Convertit un pourcentage de move en nombre de pips.
|
||
Paires JPY : 1 pip = 0.01 → facteur 100
|
||
Autres : 1 pip = 0.0001 → facteur 10 000
|
||
Note : pair peut être le nom de base (USDJPY) ou avec suffixe (USDJPY.m).
|
||
"""
|
||
base = pair.removesuffix(config.MT5_SYMBOL_SUFFIX)
|
||
factor = 100 if base.endswith("JPY") else 10_000
|
||
return max(1, int(price * pct * factor))
|
||
|
||
# ── Main loop ─────────────────────────────────────────────────────────────
|
||
|
||
def run(self):
|
||
print("=" * 60)
|
||
print(f"AHAD QUANT — Main loop | {config.EXCHANGE} | "
|
||
f"{'Paper' if config.PAPER_MODE else 'Live'}")
|
||
print(f"Scanning {len(config.COINS)} pairs every {config.MAIN_LOOP_SECONDS}s")
|
||
print("=" * 60 + "\n")
|
||
|
||
day_reset_hour = -1
|
||
|
||
while True:
|
||
try:
|
||
loop_start = time.time()
|
||
|
||
# ── V26 : contrôles Web UI (fichiers flag) ──────────────────
|
||
if os.path.exists(os.path.join(os.path.dirname(__file__), ".stopped")):
|
||
print("[AHAD QUANT] ⛔ Arrêt demandé via Web UI (.stopped) — arrêt propre.")
|
||
break
|
||
if os.path.exists(os.path.join(os.path.dirname(__file__), ".paused")):
|
||
print("[AHAD QUANT] ⏸ Bot en pause (Web UI) — scan suspendu, exits actifs.")
|
||
self._check_exits()
|
||
time.sleep(max(1, config.MAIN_LOOP_SECONDS))
|
||
continue
|
||
# ────────────────────────────────────────────────────────────
|
||
|
||
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
||
hour = int(time.strftime("%H"))
|
||
|
||
# Reset daily PnL at midnight
|
||
if hour == 0 and hour != day_reset_hour:
|
||
if config.PAPER_MODE and self.paper:
|
||
self.paper.reset_daily_pnl()
|
||
elif hasattr(self.risk, "reset_daily"):
|
||
self.risk.reset_daily()
|
||
day_reset_hour = hour
|
||
|
||
if config.PAPER_MODE and self.paper:
|
||
equity = self.paper.get_balance()
|
||
n_pos = len(self.paper.positions)
|
||
daily = self.paper.daily_pnl
|
||
elif self.mt5_bridge is not None:
|
||
status = self.mt5_bridge.read_status()
|
||
equity = status.equity if status else 0.0
|
||
n_pos = status.open_positions if status else self._mt5_get_n_positions()
|
||
daily = status.daily_pnl if status else 0.0
|
||
else:
|
||
equity = self.exchange.get_balance()
|
||
n_pos = len(self.risk.open_positions)
|
||
daily = self.risk.daily_pnl
|
||
|
||
sign = "+" if daily >= 0 else ""
|
||
icon = "📄" if config.PAPER_MODE else ("🔗" if self.mt5_bridge else "💰")
|
||
print(f"[{now}] {icon} "
|
||
f"${equity:,.2f} | Pos: {n_pos}/{config.MAX_POSITIONS} | "
|
||
f"Daily: {sign}${daily:,.2f}")
|
||
|
||
if not config.PAPER_MODE:
|
||
self._sync_positions()
|
||
self.brain.maybe_reload() # hot-reload ML+RL si un ré-entraînement a eu lieu (corrige #4)
|
||
self._check_exits()
|
||
self._scan_entries()
|
||
self._write_bot_state(equity, daily)
|
||
|
||
elapsed = time.time() - loop_start
|
||
time.sleep(max(1, config.MAIN_LOOP_SECONDS - elapsed))
|
||
|
||
except KeyboardInterrupt:
|
||
print("\nShutting down...")
|
||
if config.PAPER_MODE and self.paper:
|
||
print(self.paper.summary())
|
||
if self._retrainer:
|
||
self._retrainer.stop()
|
||
break
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] {e}")
|
||
traceback.print_exc()
|
||
time.sleep(30)
|
||
|
||
|
||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
print(r"""
|
||
____ ___ __ __
|
||
/ __ \___ ___ ____ / | / /___ / /_ ____ _
|
||
/ / / / _ \/ _ \/ __ \/ /| | / / __ \/ __ \/ __ `/
|
||
/ /_/ / __/ __/ /_/ / ___ |/ / /_/ / / / / /_/ /
|
||
/_____/\___/\___/ .___/_/ |_/_/ .___/_/ /_/\__,_/
|
||
/_/ /_/
|
||
PRO Edition
|
||
""")
|
||
try:
|
||
bot = AHAD QUANT()
|
||
bot.run()
|
||
except KeyboardInterrupt:
|
||
print("\nShutdown complete.")
|
||
except FileNotFoundError as e:
|
||
print(f"\n[ERROR] {e}")
|
||
print("Run: python train.py")
|
||
time.sleep(30)
|
||
sys.exit(1)
|
||
except Exception as e:
|
||
print(f"\n[ERROR] {e}")
|
||
traceback.print_exc()
|
||
time.sleep(30)
|
||
sys.exit(1)
|