Files
2026-06-25 14:00:20 +03:00

992 lines
45 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)