215 lines
8.2 KiB
Python
215 lines
8.2 KiB
Python
"""
|
||||
|
|
AHAD QUANT — Risk Manager (Free Version)
|
|||
|
|
Handles position sizing, stop-loss/take-profit, daily loss tracking,
|
|||
|
|
and circuit breaker logic.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import time
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
|
|||
|
|
import config
|
|||
|
|
from config import FEE_RATE
|
|||
|
|
|
|||
|
|
|
|||
|
|
class RiskManager:
|
|||
|
|
"""Enforces risk rules for every trade decision."""
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
self.daily_pnl: float = 0.0
|
|||
|
|
self._last_reset_date: str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|||
|
|
self.consecutive_losses: int = 0
|
|||
|
|
self.circuit_breaker_until: float = 0.0
|
|||
|
|
self.open_positions: dict[str, dict] = {} # coin -> position info
|
|||
|
|
self._initial_equity: float = 0.0 # mémorisé au premier trade pour compound=False
|
|||
|
|
|
|||
|
|
# ─── Daily reset ────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _check_daily_reset(self) -> None:
|
|||
|
|
"""Reset daily counters at UTC midnight."""
|
|||
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|||
|
|
if today != self._last_reset_date:
|
|||
|
|
self.daily_pnl = 0.0
|
|||
|
|
self._last_reset_date = today
|
|||
|
|
|
|||
|
|
# ─── Can we open a new trade? ───────────────────────────────────────
|
|||
|
|
|
|||
|
|
def can_open(self, equity: float) -> tuple[bool, str]:
|
|||
|
|
"""
|
|||
|
|
Check whether opening a new position is allowed.
|
|||
|
|
|
|||
|
|
Returns
|
|||
|
|
-------
|
|||
|
|
(allowed, reason)
|
|||
|
|
"""
|
|||
|
|
self._check_daily_reset()
|
|||
|
|
|
|||
|
|
# Circuit breaker active?
|
|||
|
|
if time.time() < self.circuit_breaker_until:
|
|||
|
|
remaining = int(self.circuit_breaker_until - time.time())
|
|||
|
|
return False, f"Circuit breaker active ({remaining}s left)"
|
|||
|
|
|
|||
|
|
# Max positions reached?
|
|||
|
|
if len(self.open_positions) >= config.MAX_POSITIONS:
|
|||
|
|
return False, f"Max positions reached ({config.MAX_POSITIONS})"
|
|||
|
|
|
|||
|
|
# Max simultaneous margin usage?
|
|||
|
|
if equity > 0:
|
|||
|
|
used_margin = sum(
|
|||
|
|
p["entry"] * p["qty"] for p in self.open_positions.values()
|
|||
|
|
)
|
|||
|
|
if used_margin / equity >= config.MAX_MARGIN_USAGE:
|
|||
|
|
return False, f"Max margin usage reached ({config.MAX_MARGIN_USAGE*100:.0f}% of equity)"
|
|||
|
|
|
|||
|
|
# Daily loss limit hit?
|
|||
|
|
if equity > 0 and (self.daily_pnl / equity) <= -config.MAX_DAILY_LOSS_PCT:
|
|||
|
|
return False, f"Daily loss limit hit ({config.MAX_DAILY_LOSS_PCT*100:.1f}%)"
|
|||
|
|
|
|||
|
|
return True, "OK"
|
|||
|
|
|
|||
|
|
# ─── Position sizing ────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def calc_position_size(self, equity: float, price: float) -> float:
|
|||
|
|
"""
|
|||
|
|
Calculate the notional position size in USD.
|
|||
|
|
|
|||
|
|
If COMPOUND_ENABLED, sizes off current equity (compounding).
|
|||
|
|
Otherwise uses a fixed fraction of the initial equity captured at the first call.
|
|||
|
|
Capped at CAPITAL_CAP_PER_TRADE to prevent unrealistic compound growth.
|
|||
|
|
"""
|
|||
|
|
if self._initial_equity == 0.0:
|
|||
|
|
self._initial_equity = equity # mémorise le capital de départ
|
|||
|
|
base = equity if config.COMPOUND_ENABLED else self._initial_equity
|
|||
|
|
notional = base * config.RISK_PER_TRADE * config.LEVERAGE
|
|||
|
|
notional = min(notional, config.CAPITAL_CAP_PER_TRADE)
|
|||
|
|
return round(notional, 2)
|
|||
|
|
|
|||
|
|
def calc_quantity(self, equity: float, price: float) -> float:
|
|||
|
|
"""Calculate the asset quantity for the trade."""
|
|||
|
|
notional = self.calc_position_size(equity, price)
|
|||
|
|
qty = notional / price
|
|||
|
|
return qty
|
|||
|
|
|
|||
|
|
# ─── Stop-loss & take-profit ────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def calc_sl_tp(self, entry_price: float, side: str) -> tuple[float, float]:
|
|||
|
|
"""
|
|||
|
|
Calculate fixed stop-loss and take-profit prices.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
entry_price : float
|
|||
|
|
side : str — "long" or "short"
|
|||
|
|
|
|||
|
|
Returns
|
|||
|
|
-------
|
|||
|
|
(stop_loss_price, take_profit_price)
|
|||
|
|
"""
|
|||
|
|
if side == "long":
|
|||
|
|
sl = entry_price * (1 - config.STOP_LOSS_PCT)
|
|||
|
|
tp = entry_price * (1 + config.TAKE_PROFIT_PCT)
|
|||
|
|
else:
|
|||
|
|
sl = entry_price * (1 + config.STOP_LOSS_PCT)
|
|||
|
|
tp = entry_price * (1 - config.TAKE_PROFIT_PCT)
|
|||
|
|
return round(sl, 6), round(tp, 6)
|
|||
|
|
|
|||
|
|
# ─── Position tracking ──────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def register_open(self, coin: str, side: str, entry_price: float, qty: float) -> None:
|
|||
|
|
"""Record a new open position."""
|
|||
|
|
sl, tp = self.calc_sl_tp(entry_price, side)
|
|||
|
|
self.open_positions[coin] = {
|
|||
|
|
"side": side,
|
|||
|
|
"entry": entry_price,
|
|||
|
|
"qty": qty,
|
|||
|
|
"sl": sl,
|
|||
|
|
"tp": tp,
|
|||
|
|
"opened_at": time.time(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def register_close(self, coin: str, exit_price: float) -> float:
|
|||
|
|
"""
|
|||
|
|
Record a position close and update P&L tracking.
|
|||
|
|
|
|||
|
|
Returns the realised P&L in USD.
|
|||
|
|
"""
|
|||
|
|
if coin not in self.open_positions:
|
|||
|
|
return 0.0
|
|||
|
|
|
|||
|
|
pos = self.open_positions.pop(coin)
|
|||
|
|
if pos["side"] == "long":
|
|||
|
|
raw_pnl = (exit_price - pos["entry"]) * pos["qty"]
|
|||
|
|
else:
|
|||
|
|
raw_pnl = (pos["entry"] - exit_price) * pos["qty"]
|
|||
|
|
|
|||
|
|
# Subtract fees on both entry and exit (2 sides)
|
|||
|
|
notional_entry = pos["entry"] * pos["qty"]
|
|||
|
|
notional_exit = exit_price * pos["qty"]
|
|||
|
|
fees = FEE_RATE * notional_entry + FEE_RATE * notional_exit
|
|||
|
|
pnl = raw_pnl - fees
|
|||
|
|
|
|||
|
|
self.daily_pnl += pnl
|
|||
|
|
|
|||
|
|
# Track consecutive losses for circuit breaker
|
|||
|
|
if pnl < 0:
|
|||
|
|
self.consecutive_losses += 1
|
|||
|
|
if self.consecutive_losses >= config.CIRCUIT_BREAKER_LOSSES:
|
|||
|
|
self.circuit_breaker_until = time.time() + config.CIRCUIT_BREAKER_COOLDOWN
|
|||
|
|
self.consecutive_losses = 0
|
|||
|
|
else:
|
|||
|
|
self.consecutive_losses = 0
|
|||
|
|
|
|||
|
|
return round(pnl, 2)
|
|||
|
|
|
|||
|
|
# ─── Daily reset (explicit) ─────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def reset_daily(self) -> None:
|
|||
|
|
"""
|
|||
|
|
Réinitialise le PnL quotidien à minuit.
|
|||
|
|
Appelé explicitement par ahad_quant.py dans la boucle principale.
|
|||
|
|
"""
|
|||
|
|
self.daily_pnl = 0.0
|
|||
|
|
self._last_reset_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|||
|
|
|
|||
|
|
# ─── SL/TP check ───────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def check_exit(self, coin: str, current_price: float) -> str | None:
|
|||
|
|
"""
|
|||
|
|
Check if a position should be closed due to SL, TP, or timeout.
|
|||
|
|
|
|||
|
|
Returns "sl", "tp", "timeout", or None.
|
|||
|
|
"""
|
|||
|
|
if coin not in self.open_positions:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
pos = self.open_positions[coin]
|
|||
|
|
|
|||
|
|
# Temporal exit : MAX_HOLD_CANDLES × 1h
|
|||
|
|
if config.TIMEOUT_ENABLED:
|
|||
|
|
elapsed = time.time() - pos.get("opened_at", time.time())
|
|||
|
|
if elapsed >= config.MAX_HOLD_CANDLES * 3600:
|
|||
|
|
return "timeout"
|
|||
|
|
|
|||
|
|
if pos["side"] == "long":
|
|||
|
|
if current_price <= pos["sl"]:
|
|||
|
|
return "sl"
|
|||
|
|
if current_price >= pos["tp"]:
|
|||
|
|
return "tp"
|
|||
|
|
else:
|
|||
|
|
if current_price >= pos["sl"]:
|
|||
|
|
return "sl"
|
|||
|
|
if current_price <= pos["tp"]:
|
|||
|
|
return "tp"
|
|||
|
|
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# ─── Summary ────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def summary(self) -> str:
|
|||
|
|
"""Retourne un résumé lisible de l'état du risk manager."""
|
|||
|
|
return (
|
|||
|
|
f"RiskManager | Open: {len(self.open_positions)} | "
|
|||
|
|
f"Daily PnL: {self.daily_pnl:+.2f} | "
|
|||
|
|
f"Consecutive losses: {self.consecutive_losses} | "
|
|||
|
|
f"CB until: {self.circuit_breaker_until:.0f}"
|
|||
|
|
)
|