raghava4u's picture
Upload folder using huggingface_hub
d53dc44 verified
Raw History Blame Contribute Delete
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