Files
ahad-quant/paper_trader.py
2026-06-25 14:00:20 +03:00

212 lines
8.3 KiB
Python

"""
AHAD QUANT — Paper Trader
Simule les ordres Forex sans argent réel.
Persiste l'état dans paper_state.json pour que web_ui.py puisse le lire.
Interface attendue par ahad_quant.py :
PaperTrader(initial_balance)
.positions dict { pair: {side, entry, qty, sl, tp, margin, opened_at} }
.daily_pnl float
.total_pnl float
.peak_equity float
.consecutive_losses int
.circuit_breaker_until float (timestamp)
.get_balance() → float
.open_position(pair, side, price, qty, sl_pct, tp_pct) → {"success": bool, ...}
.close_position(pair, price, reason) → {"pnl": float, ...}
.check_sl_tp(pair, price) → "sl" | "tp" | None
.reset_daily_pnl()
.summary() → str
"""
import json
import os
import time
from datetime import datetime, timezone
from pathlib import Path
import config
_STATE_FILE = Path(os.path.dirname(__file__)) / "paper_state.json"
class PaperTrader:
"""Moteur de paper trading Forex — persistance JSON."""
def __init__(self, initial_balance: float = 10_000.0):
self.initial_balance = initial_balance
self._load_state()
# ── Persistance ────────────────────────────────────────────────────────────
def _load_state(self):
"""Charge paper_state.json ou initialise un état vierge."""
try:
with open(_STATE_FILE) as f:
s = json.load(f)
self._balance = float(s.get("balance", self.initial_balance))
self.positions = s.get("positions", {})
self._trades = s.get("trades", [])
self.daily_pnl = float(s.get("daily_pnl", 0.0))
self.total_pnl = float(s.get("total_pnl", 0.0))
self.peak_equity = float(s.get("peak_equity", self._balance))
self.daily_losses = int(s.get("daily_losses", 0))
self.consecutive_losses = int(s.get("consecutive_losses", 0))
self.circuit_breaker_until = float(s.get("circuit_breaker_until", 0.0))
except (FileNotFoundError, json.JSONDecodeError, KeyError):
self._balance = self.initial_balance
self.positions = {}
self._trades = []
self.daily_pnl = 0.0
self.total_pnl = 0.0
self.peak_equity = self.initial_balance
self.daily_losses = 0
self.consecutive_losses = 0
self.circuit_breaker_until = 0.0
self._save_state()
def _save_state(self):
"""Écrit paper_state.json — lu par web_ui.py."""
state = {
"balance": round(self._balance, 5),
"positions": self.positions,
"trades": self._trades[-500:], # garder les 500 derniers
"daily_pnl": round(self.daily_pnl, 5),
"total_pnl": round(self.total_pnl, 5),
"peak_equity": round(self.peak_equity, 5),
"daily_losses": self.daily_losses,
"consecutive_losses": self.consecutive_losses,
"circuit_breaker_until": self.circuit_breaker_until,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
tmp = str(_STATE_FILE) + ".tmp"
with open(tmp, "w") as f:
json.dump(state, f, indent=2)
os.replace(tmp, _STATE_FILE) # écriture atomique
# ── Interface publique ─────────────────────────────────────────────────────
def get_balance(self) -> float:
"""Équité courante (balance + PnL non réalisé des positions ouvertes)."""
return round(self._balance, 5)
def open_position(
self,
pair: str,
side: str, # "long" | "short"
price: float,
qty: float,
sl_pct: float = config.STOP_LOSS_PCT,
tp_pct: float = config.TAKE_PROFIT_PCT,
) -> dict:
"""Ouvre une position paper. Retourne {"success": bool, "msg": str}."""
if pair in self.positions:
return {"success": False, "msg": f"{pair} already open"}
side = side.lower()
margin = price * qty / config.LEVERAGE
if margin > self._balance * config.MAX_MARGIN_USAGE:
return {"success": False, "msg": "Insufficient margin"}
mult = 1 if side == "long" else -1
sl = round(price * (1 - mult * sl_pct), 6)
tp = round(price * (1 + mult * tp_pct), 6)
self.positions[pair] = {
"side": side,
"entry": round(price, 6),
"qty": round(qty, 6),
"sl": sl,
"tp": tp,
"margin": round(margin, 5),
"opened_at": time.time(),
}
self._balance -= margin # réserve la marge
self._save_state()
return {"success": True, "pair": pair, "side": side,
"price": price, "qty": qty, "sl": sl, "tp": tp}
def close_position(self, pair: str, price: float, reason: str = "manual") -> dict:
"""Ferme une position et comptabilise le PnL."""
pos = self.positions.pop(pair, None)
if not pos:
return {"success": False, "pnl": 0.0, "msg": "No position"}
mult = 1 if pos["side"] == "long" else -1
pnl = round(mult * (price - pos["entry"]) * pos["qty"] * config.LEVERAGE, 5)
fee = round(pos["entry"] * pos["qty"] * config.FEE_RATE, 5)
net = round(pnl - fee, 5)
self._balance += pos["margin"] + net
self.daily_pnl += net
self.total_pnl += net
self.peak_equity = max(self.peak_equity, self._balance)
if net < 0:
self.consecutive_losses += 1
self.daily_losses += 1
if self.daily_losses >= config.CIRCUIT_BREAKER_LOSSES:
self.circuit_breaker_until = time.time() + config.CIRCUIT_BREAKER_COOLDOWN
else:
self.consecutive_losses = 0
trade = {
"pair": pair,
"side": pos["side"],
"entry": pos["entry"],
"exit": round(price, 6),
"qty": pos["qty"],
"pnl": net,
"fee": fee,
"reason": reason,
"closed_at": datetime.now(timezone.utc).isoformat(),
}
self._trades.append(trade)
self._save_state()
return {"success": True, "pnl": net, "reason": reason, "trade": trade}
def check_sl_tp(self, pair: str, price: float):
"""Retourne 'sl', 'tp' ou None selon le prix actuel."""
pos = self.positions.get(pair)
if not pos:
return None
side = pos["side"]
sl, tp = pos["sl"], pos["tp"]
if side == "long":
if price <= sl:
return "sl"
if price >= tp:
return "tp"
else: # short
if price >= sl:
return "sl"
if price <= tp:
return "tp"
return None
def reset_daily_pnl(self):
"""Appelé par ahad_quant.py à minuit pour réinitialiser les stats journalières."""
self.daily_pnl = 0.0
self.daily_losses = 0
# Le circuit breaker journalier expire aussi
if self.circuit_breaker_until < time.time():
self.circuit_breaker_until = 0.0
self._save_state()
def summary(self) -> str:
"""Résumé texte affiché dans les logs du bot."""
wins = sum(1 for t in self._trades if t.get("pnl", 0) > 0)
total = len(self._trades)
wr = f"{wins/total*100:.1f}%" if total else "—"
return (
f"[PAPER] Balance: ${self._balance:,.2f} | "
f"Total PnL: {'+' if self.total_pnl >= 0 else ''}{self.total_pnl:.2f} | "
f"Daily: {'+' if self.daily_pnl >= 0 else ''}{self.daily_pnl:.2f} | "
f"Win rate: {wr} ({total} trades) | "
f"Positions: {len(self.positions)}"
)