Download execution/risk.py from raghava4u/Trading-Bot-M20: direct link, hf CLI and curl.
- Browser
- Download file 12 kB
-
https://huggingface.co/raghava4u/Trading-Bot-M20/resolve/main/execution/risk.py
- Command line
-
hf download hf://raghava4u/Trading-Bot-M20/execution/risk.py
-
curl -L -o risk.py https://huggingface.co/raghava4u/Trading-Bot-M20/resolve/main/execution/risk.py
12 kB
| """ | |
| execution/risk.py β Position sizing, drawdown checks, daily limits, circuit breakers. | |
| All risk checks run in strict order before any order submission. | |
| """ | |
| from __future__ import annotations | |
| import datetime | |
| import logging | |
| import threading | |
| from zoneinfo import ZoneInfo | |
| _ET = ZoneInfo("America/New_York") | |
| import config | |
| from contracts import OrderRequest, FinalScore | |
| from execution import broker | |
| from data import storage | |
| from signals.technical import compute_atr | |
| logger = logging.getLogger("trading_system.risk") | |
| # ββ Circuit Breaker State ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _state_lock = threading.Lock() | |
| daily_realized_pnl: float = 0.0 | |
| daily_unrealized_pnl: float = 0.0 | |
| daily_open_equity: float = 0.0 | |
| consecutive_losses: int = 0 | |
| paused_until: datetime.datetime | None = None | |
| half_size_mode: bool = False | |
| profit_locked: bool = False | |
| _last_reset_date: datetime.date | None = None | |
| def reset_daily_state(opening_equity: float): | |
| global daily_open_equity, daily_realized_pnl, daily_unrealized_pnl | |
| global consecutive_losses, half_size_mode, paused_until, profit_locked | |
| daily_open_equity = opening_equity | |
| # Load persisted state if restarting mid-day | |
| today = datetime.datetime.now(_ET).strftime("%Y-%m-%d") | |
| persisted_date = storage.get_kv("risk_state_date") | |
| if persisted_date == today: | |
| # Mid-day restart β restore state | |
| daily_realized_pnl = float(storage.get_kv("risk_daily_realized") or 0) | |
| consecutive_losses = int(storage.get_kv("risk_consec_losses") or 0) | |
| half_size_mode = (storage.get_kv("risk_half_size") == "true") | |
| profit_locked = (storage.get_kv("risk_profit_locked") == "true") | |
| logger.info( | |
| "Restored risk state from DB: realized=$%.2f, consec_losses=%d, half_size=%s", | |
| daily_realized_pnl, consecutive_losses, half_size_mode, | |
| ) | |
| else: | |
| # New day β reset | |
| daily_realized_pnl = 0.0 | |
| consecutive_losses = 0 | |
| half_size_mode = False | |
| profit_locked = False | |
| storage.set_kv("risk_state_date", today) | |
| daily_unrealized_pnl = 0.0 | |
| paused_until = None | |
| def update_pnl(realized: float, unrealized: float): | |
| global daily_realized_pnl, daily_unrealized_pnl | |
| with _state_lock: | |
| daily_realized_pnl = realized | |
| daily_unrealized_pnl = unrealized | |
| storage.set_kv("risk_daily_realized", str(realized)) | |
| def record_trade_result(profit: float): | |
| """Record the result of a closed trade for streak tracking.""" | |
| global consecutive_losses, half_size_mode | |
| with _state_lock: | |
| if profit < 0: | |
| consecutive_losses += 1 | |
| else: | |
| consecutive_losses = 0 | |
| half_size_mode = False | |
| storage.set_kv("risk_consec_losses", str(consecutive_losses)) | |
| storage.set_kv("risk_half_size", str(half_size_mode).lower()) | |
| # ββ Position Sizing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_position_size( | |
| equity: float, | |
| atr_value: float, | |
| symbol: str, | |
| current_price: float = 0.0, | |
| allow_overnight: bool = False, | |
| ) -> tuple[float, float, bool]: | |
| """Compute position size based on risk parameters. | |
| Args: | |
| equity: Current account equity | |
| atr_value: Current ATR(14) on 5m bars | |
| symbol: Stock symbol (for fractionable check) | |
| current_price: Current price (for notional cap) | |
| allow_overnight: Whether position might be held overnight | |
| Returns: (qty, stop_distance, is_fractional) | |
| """ | |
| # Guard: reject if risk % exceeds hard cap | |
| risk_pct = min(config.RISK_PER_TRADE_PCT, 3.0) | |
| dollar_risk = equity * (risk_pct / 100) | |
| stop_distance = 1.0 * atr_value # for swing: atr_value already = 2x daily ATR | |
| if stop_distance <= 0: | |
| logger.warning("ATR is zero for %s, cannot size position", symbol) | |
| return 0.0, 0.0, False | |
| qty = dollar_risk / stop_distance | |
| # Cap notional value β distribute capital across MAX_OPEN_POSITIONS | |
| if current_price > 0: | |
| # Allow up to 1/MAX_OPEN_POSITIONS of equity per position. | |
| # A configurable floor keeps tiny accounts tradable without forcing | |
| # oversized positions when the user intentionally runs many slots. | |
| per_position_pct = max( | |
| 1.0 / max(config.MAX_OPEN_POSITIONS, 1), | |
| config.MIN_POSITION_NOTIONAL_PCT, | |
| ) | |
| max_notional = equity * per_position_pct | |
| max_qty = max_notional / current_price | |
| if qty > max_qty: | |
| logger.info( | |
| "%s: capping qty from %.2f to %.2f (notional cap $%.0f, %.0f%% of equity)", | |
| symbol, qty, max_qty, max_notional, per_position_pct * 100, | |
| ) | |
| qty = max_qty | |
| # Half size after 3 consecutive losses (but NOT for overnight β swing needs full risk) | |
| if half_size_mode: | |
| qty *= 0.5 | |
| is_fractional = qty != int(qty) or qty < 1 | |
| if is_fractional and qty < 1: | |
| # Check if symbol supports fractional shares | |
| try: | |
| if not broker.is_fractionable(symbol): | |
| logger.warning( | |
| "qty too small and symbol not fractionable: %s (qty=%.4f)", | |
| symbol, qty, | |
| ) | |
| return 0.0, stop_distance, False | |
| except Exception as e: | |
| logger.error("Failed to check fractionable for %s: %s", symbol, e) | |
| return 0.0, stop_distance, False | |
| return qty, stop_distance, is_fractional | |
| # ββ Risk Check Pipeline βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_risk_checks( | |
| symbol: str, | |
| final_score: FinalScore, | |
| open_position_count: int, | |
| open_positions: dict[str, dict], | |
| alert_callback=None, | |
| ) -> tuple[bool, str | None]: | |
| """Run all risk checks in order. Returns (allowed, skip_reason). | |
| Check order: | |
| 1. SAFE MODE | |
| 2. Daily loss circuit breaker | |
| 3. Equity drop 5% circuit breaker | |
| 4. 3 consecutive losses β pause | |
| 5. Daily profit lock | |
| 6. Max open positions | |
| 7. PDT check (delegated to pdt_tracker) | |
| 8. Overnight time check | |
| """ | |
| now = datetime.datetime.now(_ET) # ET | |
| # 1. SAFE MODE | |
| if broker.safe_mode_active: | |
| return False, "safe_mode_active" | |
| # 1b. Short selling guard β independent of signal filter | |
| if not config.ALLOW_SHORT and final_score.decision == "SELL": | |
| # Check if we actually hold this symbol | |
| if symbol not in open_positions or not open_positions.get(symbol): | |
| return False, "short_selling_blocked" | |
| # 2. Daily loss circuit breaker | |
| total_pnl = daily_realized_pnl + daily_unrealized_pnl | |
| if total_pnl <= -config.MAX_DAILY_LOSS_USD: | |
| msg = f"Daily loss limit hit: ${total_pnl:.2f} <= -${config.MAX_DAILY_LOSS_USD}" | |
| logger.critical(msg) | |
| if alert_callback: | |
| alert_callback(f"π CIRCUIT BREAKER: {msg}") | |
| try: | |
| broker.cancel_all_orders() | |
| broker.close_all_positions() | |
| except Exception as e: | |
| logger.error("Failed to close on circuit breaker: %s", e) | |
| return False, "daily_loss_limit" | |
| # 3. Equity drop 5% | |
| if daily_open_equity > 0: | |
| current_equity = daily_open_equity + total_pnl | |
| drop_pct = (daily_open_equity - current_equity) / daily_open_equity * 100 | |
| if drop_pct >= 5.0: | |
| msg = f"Equity dropped {drop_pct:.1f}% from daily open" | |
| logger.critical(msg) | |
| if alert_callback: | |
| alert_callback(f"π CIRCUIT BREAKER: {msg}") | |
| try: | |
| broker.cancel_all_orders() | |
| broker.close_all_positions() | |
| except Exception as e: | |
| logger.error("Failed to close on equity drop: %s", e) | |
| return False, "equity_drop_5pct" | |
| # 4. Consecutive losses β 30 min pause at 50% size | |
| global paused_until, half_size_mode | |
| if consecutive_losses >= 3: | |
| if paused_until is None: | |
| paused_until = datetime.datetime.now( | |
| datetime.timezone.utc | |
| ) + datetime.timedelta(minutes=30) | |
| half_size_mode = True | |
| msg = f"3 consecutive losses. Pausing 30 min, resuming at 50% size." | |
| logger.warning(msg) | |
| if alert_callback: | |
| alert_callback(f"β οΈ {msg}") | |
| if datetime.datetime.now(datetime.timezone.utc) < paused_until: | |
| return False, "consecutive_loss_pause" | |
| # 5. Daily profit lock | |
| if (config.LOCK_PROFITS_AT_TARGET and | |
| daily_realized_pnl >= config.TARGET_DAILY_PROFIT_USD): | |
| global profit_locked | |
| profit_locked = True | |
| return False, "profit_target_locked" | |
| # 6. Max open positions | |
| if open_position_count >= config.MAX_OPEN_POSITIONS: | |
| return False, "max_positions_reached" | |
| # 6b. Max daily trades | |
| today_str = now.strftime("%Y-%m-%d") | |
| daily_trade_count = storage.count_trades_today(today_str) | |
| if daily_trade_count >= config.MAX_DAILY_TRADES: | |
| return False, f"max_daily_trades_reached ({daily_trade_count}/{config.MAX_DAILY_TRADES})" | |
| # 7. PDT check | |
| from execution.pdt_tracker import can_day_trade | |
| side = "buy" if final_score.decision == "BUY" else "sell" | |
| pdt_ok, pdt_reason = can_day_trade(symbol, side, open_positions, alert_callback) | |
| if not pdt_ok: | |
| return False, pdt_reason | |
| # 8. Overnight check | |
| if not config.ALLOW_OVERNIGHT_POSITIONS: | |
| hour, minute = now.hour, now.minute | |
| if hour >= 15 and minute >= 45: | |
| return False, "after_1545_no_new_orders" | |
| return True, None | |
| # ββ Per-Position Loss Guardian βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def check_position_loss_limits( | |
| portfolio, | |
| alert_callback=None, | |
| ) -> list[str]: | |
| """Close any position whose unrealized loss exceeds MAX_LOSS_PER_POSITION_USD. | |
| Returns list of symbols that were force-closed. | |
| """ | |
| max_loss = config.MAX_LOSS_PER_POSITION_USD | |
| closed = [] | |
| for sym, pos in list(portfolio.positions.items()): | |
| unrealized = pos.get("unrealized_pl", 0) | |
| if unrealized <= -max_loss: | |
| logger.warning( | |
| "π %s unrealized P&L $%.2f exceeds -$%.2f limit β closing", | |
| sym, unrealized, max_loss, | |
| ) | |
| try: | |
| if config.DRY_RUN: | |
| from execution.order_manager import close_virtual_position | |
| close_virtual_position(sym) | |
| else: | |
| broker.close_position(sym) | |
| closed.append(sym) | |
| record_trade_result(unrealized) | |
| portfolio.record_close( | |
| symbol=sym, | |
| realized_pnl=unrealized, | |
| entry_price=pos.get("entry_price", 0), | |
| exit_price=pos.get("current_price", 0), | |
| qty=pos.get("qty", 0), | |
| ) | |
| if alert_callback: | |
| alert_callback( | |
| f"π LOSS CUT: {sym} closed at ${unrealized:.2f} " | |
| f"(limit: -${max_loss:.2f})" | |
| ) | |
| except Exception as e: | |
| logger.error("Failed to close losing position %s: %s", sym, e) | |
| return closed | |