File: //proc/self/root/opt/trading-bot/risk_engine.py
import logging
from datetime import datetime, timezone, timedelta
from config import settings
from db import SessionLocal
from models import FeedHeartbeat, PortfolioState, Position, PnlHistory
logger = logging.getLogger(__name__)
class RiskEngine:
def __init__(self):
self.startup_time = datetime.now(timezone.utc)
def check_stale_data(self, strategy_name: str = "") -> bool:
"""Returns True if data is fresh, False if stale. Allows grace period at startup.
Only checks feeds relevant to the strategy type."""
now = datetime.now(timezone.utc)
# Grace period for startup (300 seconds)
if (now - self.startup_time).total_seconds() < 300:
logger.info("RiskEngine in startup grace period. Skipping stale data checks.")
return True
# Determine which feeds this strategy cares about
is_equity = strategy_name.startswith("Equity")
with SessionLocal() as session:
heartbeats = session.query(FeedHeartbeat).all()
if not heartbeats:
logger.warning("No feed heartbeats found in DB.")
return False
for hb in heartbeats:
# Skip irrelevant feeds
if is_equity and hb.feed_name == "crypto_market":
continue
if not is_equity and hb.feed_name == "equity_market":
continue
hb_time = hb.last_updated
if hb_time.tzinfo is None:
hb_time = hb_time.replace(tzinfo=timezone.utc)
if (now - hb_time).total_seconds() > settings.STALE_DATA_THRESHOLD:
logger.error(f"Feed {hb.feed_name} is stale. Last updated: {hb_time}")
return False
return True
def check_exposure_limits(self, current_exposure: float, new_trade_usd: float) -> bool:
"""Checks if a new trade would exceed exposure limits."""
if abs(current_exposure + new_trade_usd) > settings.MAX_EXPOSURE_USD:
logger.warning(f"Trade would exceed MAX_EXPOSURE_USD: {current_exposure + new_trade_usd} > {settings.MAX_EXPOSURE_USD}")
return False
return True
def check_daily_loss_limit(self) -> bool:
"""Returns True if within loss limits, False if exceeded."""
with SessionLocal() as session:
# Simple check: sum realized PnL for today
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
pnl_records = session.query(PnlHistory).filter(PnlHistory.ts >= today_start).all()
total_realized = sum((p.realized_pnl or 0.0) for p in pnl_records)
if total_realized < -settings.DAILY_LOSS_LIMIT_USD:
logger.error(f"Daily loss limit hit: {total_realized} < -{settings.DAILY_LOSS_LIMIT_USD}")
return False
return True
def check_negative_balance(self, strategy_name: str = "") -> bool:
"""Returns True if balance is healthy, False if cash or equity is negative."""
if not strategy_name:
return True
with SessionLocal() as session:
latest = session.query(PortfolioState).filter_by(strategy_name=strategy_name).order_by(PortfolioState.ts.desc()).first()
if not latest:
return True # No state yet, assume initial $10k
if float(latest.cash) < 0:
logger.error(f"KILL SWITCH: {strategy_name} has negative cash: ${float(latest.cash):.2f}")
return False
if float(latest.equity) < 0:
logger.error(f"KILL SWITCH: {strategy_name} has negative equity: ${float(latest.equity):.2f}")
return False
return True
def is_trading_allowed(self, strategy_name: str = "") -> bool:
"""Main safety check before any trade."""
if not self.check_negative_balance(strategy_name):
return False
if not self.check_stale_data(strategy_name):
return False
if not self.check_daily_loss_limit():
return False
return True
def get_drawdown_throttle(self, current_drawdown_pct: float) -> float:
"""Returns a multiplier (0-1) for sizing based on drawdown."""
if current_drawdown_pct >= settings.MAX_DRAWDOWN_PCT:
logger.warning(f"Drawdown limit hit ({current_drawdown_pct}%). Throttling sizing to 0.")
return 0.0
elif current_drawdown_pct >= settings.MAX_DRAWDOWN_PCT / 2:
# Reduce sizing by half if we are halfway to the limit
return 0.5
return 1.0