Files

992 lines
45 KiB
Python
Raw Permalink Normal View History

2026-06-25 14:00:20 +03:00
"""
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)