Files

215 lines
8.2 KiB
Python
Raw Permalink Normal View History

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