5353)
5454logger = logging .getLogger ("LiveBot" )
5555
56- # --- Configuration ---
57- TOP_N_STOCKS = 10
58- OPTIMIZER_METHOD = "hrp"
59- MAX_POSITION_WEIGHT = 0.30
60- MAX_PORTFOLIO_VAR_95 = 0.06
61- MAX_DRAWDOWN_LIMIT = 0.15
62- STOP_LOSS_PCT = 0.05 # 5% trailing stop-loss from entry price (fallback if ATR unavailable)
63- TAKE_PROFIT_PCT = 0.15 # 15% take-profit from entry price (fallback if ATR unavailable)
64- ATR_SL_MULTIPLIER = 2.0 # Stop-loss at 2x ATR below fill price
65- ATR_TP_MULTIPLIER = 3.0 # Take-profit at 3x ATR above fill price (1.5:1 R:R ratio)
66- SLEEP_AFTER_TRADE_HOURS = 12
67- SLEEP_MARKET_CLOSED_HOURS = 1
68- ORDER_POLL_INTERVAL_SECS = 2 # How often to poll for fill status
69- ORDER_POLL_TIMEOUT_SECS = 60 # Max time to wait for a fill
56+ # --- Configuration (all overridable via environment variables) ---
57+
58+
59+ def _env_float (key : str , default : float ) -> float :
60+ """Read a float from env, falling back to default."""
61+ val = os .getenv (key )
62+ if val is None :
63+ return default
64+ try :
65+ return float (val )
66+ except ValueError :
67+ logger .warning (f"Invalid float for { key } ={ val !r} , using default { default } " )
68+ return default
69+
70+
71+ def _env_int (key : str , default : int ) -> int :
72+ """Read an int from env, falling back to default."""
73+ val = os .getenv (key )
74+ if val is None :
75+ return default
76+ try :
77+ return int (val )
78+ except ValueError :
79+ logger .warning (f"Invalid int for { key } ={ val !r} , using default { default } " )
80+ return default
81+
82+
83+ TOP_N_STOCKS = _env_int ("TOP_N_STOCKS" , 10 )
84+ OPTIMIZER_METHOD = os .getenv ("OPTIMIZER_METHOD" , "hrp" )
85+ MAX_POSITION_WEIGHT = _env_float ("MAX_POSITION_WEIGHT" , 0.30 )
86+ MAX_PORTFOLIO_VAR_95 = _env_float ("MAX_PORTFOLIO_VAR_95" , 0.06 )
87+ MAX_DRAWDOWN_LIMIT = _env_float ("MAX_DRAWDOWN_LIMIT" , 0.15 )
88+ STOP_LOSS_PCT = _env_float ("STOP_LOSS_PCT" , 0.05 )
89+ TAKE_PROFIT_PCT = _env_float ("TAKE_PROFIT_PCT" , 0.15 )
90+ ATR_SL_MULTIPLIER = _env_float ("ATR_SL_MULTIPLIER" , 2.0 )
91+ ATR_TP_MULTIPLIER = _env_float ("ATR_TP_MULTIPLIER" , 3.0 )
92+ SLEEP_AFTER_TRADE_HOURS = _env_int ("SLEEP_AFTER_TRADE_HOURS" , 12 )
93+ SLEEP_MARKET_CLOSED_HOURS = _env_int ("SLEEP_MARKET_CLOSED_HOURS" , 1 )
94+ ORDER_POLL_INTERVAL_SECS = _env_int ("ORDER_POLL_INTERVAL_SECS" , 2 )
95+ ORDER_POLL_TIMEOUT_SECS = _env_int ("ORDER_POLL_TIMEOUT_SECS" , 60 )
7096TERMINAL_ORDER_STATES = {
7197 "filled" ,
72- "partially_filled" ,
7398 "canceled" ,
7499 "cancelled" ,
75100 "expired" ,
79104# --- Rebalancing schedule (Phase 1: Cost Reduction) ---
80105# Weekly rebalancing reduces transaction costs by ~74% vs daily.
81106# Wednesday chosen: avoids Monday/Friday effects, mid-week liquidity is higher.
82- REBALANCE_DAY = 2 # 0=Mon, 1=Tue, 2=Wed, 3=Thu, 4=Fri
83- REBALANCE_FREQUENCY = " weekly" # "daily" or "weekly"
107+ REBALANCE_DAY = _env_int ( "REBALANCE_DAY" , 2 ) # 0=Mon, 1=Tue, 2=Wed, 3=Thu, 4=Fri
108+ REBALANCE_FREQUENCY = os . getenv ( "REBALANCE_FREQUENCY" , " weekly") # "daily" or "weekly"
84109
85110# --- Alerting (Fix #37) ---
86111ALERT_WEBHOOK_URL = os .getenv ("ALERT_WEBHOOK_URL" ) # Slack/Discord/generic webhook
@@ -398,6 +423,37 @@ def _save_bot_state(state: dict) -> None:
398423 logger .warning (f"Could not save bot state: { e } " )
399424
400425
426+ EQUITY_HISTORY_FILE = Path ("data/equity_history.json" )
427+
428+
429+ def _append_equity_history (equity : float ) -> None :
430+ """Append an equity snapshot to the equity history file (R3-E-12 fix).
431+
432+ The dashboard reads this file to display the equity curve.
433+ Each entry is {date, equity}.
434+ """
435+ try :
436+ EQUITY_HISTORY_FILE .parent .mkdir (parents = True , exist_ok = True )
437+ records = []
438+ if EQUITY_HISTORY_FILE .exists ():
439+ with open (EQUITY_HISTORY_FILE , "r" ) as f :
440+ records = json .load (f )
441+ records .append (
442+ {
443+ "date" : datetime .now (tz = ZoneInfo ("America/New_York" )).isoformat (),
444+ "equity" : round (equity , 2 ),
445+ }
446+ )
447+ # Keep last 2000 entries to avoid unbounded growth
448+ records = records [- 2000 :]
449+ tmp = EQUITY_HISTORY_FILE .with_suffix (".tmp" )
450+ with open (tmp , "w" ) as f :
451+ json .dump (records , f )
452+ tmp .replace (EQUITY_HISTORY_FILE )
453+ except Exception as e :
454+ logger .warning (f"Could not append equity history: { e } " )
455+
456+
401457def _has_traded_today (broker : AlpacaBroker ) -> bool :
402458 """Check if the bot has already submitted orders today.
403459
@@ -406,23 +462,19 @@ def _has_traded_today(broker: AlpacaBroker) -> bool:
406462
407463 C2 fix: created_at may be a datetime or string — normalize to string.
408464 M1 fix: use UTC date to match Alpaca's UTC timestamps.
465+ R3-E-13 fix: use ``after`` param to scope query to today's orders only,
466+ avoiding the 500-order pagination limit.
409467 """
410468 try :
411469 from datetime import timezone
412470
413- # Get all orders (including closed)
414- orders = broker .list_orders (status = "all" )
415-
416- # M1 fix: use UTC date to match Alpaca order timestamps
471+ # R3-E-13 fix: scope query to today's orders using ``after`` parameter
417472 today_utc = datetime .now (tz = timezone .utc ).strftime ("%Y-%m-%d" )
473+ after_ts = f"{ today_utc } T00:00:00Z"
474+ orders = broker .list_orders (status = "all" , after = after_ts )
475+
418476 executed_today = [
419- o
420- for o in orders
421- if o .status not in ("canceled" , "cancelled" , "expired" )
422- and o .order_id
423- and hasattr (o , "created_at" )
424- and o .created_at
425- and str (o .created_at ).startswith (today_utc ) # C2 fix: str() handles datetime
477+ o for o in orders if o .status not in ("canceled" , "cancelled" , "expired" ) and o .order_id
426478 ]
427479
428480 if executed_today :
@@ -460,7 +512,7 @@ def _initialize_risk_engine(broker: AlpacaBroker, risk_manager: RiskManager) ->
460512 try :
461513 import yfinance as yf
462514
463- data = yf .download (symbols , period = "1y" , interval = "1d" , progress = False )
515+ data = yf .download (symbols , period = "1y" , interval = "1d" , progress = False , auto_adjust = True )
464516 if data is not None and len (data ) > 0 :
465517 close_raw = data ["Close" ]
466518 close : pd .DataFrame = (
@@ -573,6 +625,32 @@ def run_trading_cycle(
573625 for ticker , w in sorted (target_weights .items (), key = lambda x : - x [1 ]):
574626 logger .info (f" { ticker } : { w :.2%} " )
575627
628+ # Feature drift detection — compare inference features to training reference.
629+ # Informational only during paper trading: log and alert but don't block.
630+ import python .alpha .predict as _predict_mod
631+
632+ drift_report = _predict_mod ._last_drift_report
633+ if drift_report is not None :
634+ drifted_features = [k for k , v in drift_report .items () if v ["drifted" ]]
635+ total_features = len (drift_report )
636+ if drifted_features :
637+ drift_pct = len (drifted_features ) / total_features * 100
638+ logger .warning (
639+ f"DRIFT ALERT: { len (drifted_features )} /{ total_features } features "
640+ f"({ drift_pct :.0f} %) show distribution drift: { drifted_features } "
641+ )
642+ # High-severity PSI (>0.2 indicates major shift)
643+ severe = [f for f in drifted_features if drift_report [f ].get ("psi" , 0 ) > 0.2 ]
644+ if severe :
645+ logger .warning (f" High PSI (>0.2) features: { severe } " )
646+ _send_alert (
647+ f"[LiveBot] Feature drift detected: { len (drifted_features )} /{ total_features } "
648+ f"features drifted. High-PSI: { severe or 'none' } . "
649+ f"Model predictions may be degraded."
650+ )
651+ else :
652+ logger .info (f"Drift check passed: 0/{ total_features } features drifted" )
653+
576654 # Apply regime-based exposure adjustment (Phase 2: Risk Management)
577655 # H-CAUTION fix: track whether caution mode is active to prevent renorm undoing it
578656 in_caution_mode = False
@@ -635,6 +713,13 @@ def run_trading_cycle(
635713 bridge .equity = account .equity
636714 bridge .cash = account .cash
637715
716+ # R3-E-1 fix: reset ALL bridge positions before syncing from broker.
717+ # Without this, positions closed via SL/TP between cycles remain as
718+ # ghost entries in the bridge, causing spurious sell orders.
719+ for ticker in list (bridge .positions .keys ()):
720+ bridge .positions [ticker ].quantity = 0.0
721+ bridge .positions [ticker ].avg_cost = 0.0
722+
638723 # Sync current positions from broker into the bridge
639724 current_positions = broker .list_positions ()
640725 for pos in current_positions :
@@ -791,12 +876,13 @@ def run_trading_cycle(
791876 # C1/C-OCO-1 fix: submit SL+TP as OCO pair (one-cancels-other).
792877 # When SL fills, TP is automatically cancelled (and vice versa),
793878 # preventing orphaned orders from creating naked short positions.
879+ # R3-E-8 fix: remove redundant limit_price on parent —
880+ # OCO legs define their own prices via take_profit/stop_loss.
794881 oco_order = BrokerOrder (
795882 symbol = entry ["symbol" ],
796883 side = "sell" ,
797884 qty = sl_tp_qty ,
798885 order_type = "limit" ,
799- limit_price = tp_price ,
800886 time_in_force = "gtc" ,
801887 order_class = "oco" ,
802888 take_profit_limit_price = tp_price ,
@@ -992,7 +1078,9 @@ def _handle_sigterm(signum, frame):
9921078 # Create execution bridge once — persists equity curve, P&L, and weight
9931079 # history across cycles. Each cycle syncs positions/equity from broker.
9941080 account = broker .get_account ()
995- bridge = ExecutionBridge (risk_manager = risk_manager , initial_capital = account .equity )
1081+ bridge = ExecutionBridge (
1082+ risk_manager = risk_manager , initial_capital = account .equity , commission_rate = 0.0
1083+ )
9961084 _bridge_ref [0 ] = bridge # C3 fix: make bridge accessible to SIGTERM handler
9971085
9981086 try :
@@ -1026,10 +1114,17 @@ def _handle_sigterm(signum, frame):
10261114 # Re-initialize risk engine each cycle to capture new positions
10271115 _initialize_risk_engine (broker , risk_manager )
10281116
1029- # Check portfolio-level risk before trading
1030- # Get current position weights from broker
1117+ # R3-E-3 fix: sync risk_manager.current_weights from broker
1118+ # so trade-size, leverage, and sector checks use actual positions
10311119 positions = broker .list_positions ()
10321120 account = broker .get_account ()
1121+ if positions and account .equity > 0 :
1122+ risk_manager .current_weights = pd .Series (
1123+ {p .symbol : p .market_value / account .equity for p in positions }
1124+ )
1125+
1126+ # Check portfolio-level risk before trading
1127+ # Get current position weights from broker
10331128 if positions and account .equity > 0 :
10341129 weights = pd .Series (
10351130 {p .symbol : p .market_value / account .equity for p in positions }
@@ -1105,21 +1200,31 @@ def _handle_sigterm(signum, frame):
11051200 )
11061201
11071202 # Persist state after trading cycle (P1-6)
1203+ # R3-E-7 fix: persist equity_peak for drawdown tracking across restarts
1204+ current_equity = bridge .equity
1205+ prev_state = _load_bot_state ()
1206+ equity_peak = max (
1207+ current_equity ,
1208+ prev_state .get ("equity_peak" , current_equity ),
1209+ )
11081210 _save_bot_state (
11091211 {
11101212 "last_trade_date" : datetime .now (
11111213 tz = ZoneInfo ("America/New_York" )
11121214 ).isoformat (),
1113- "last_equity" : bridge .equity ,
1215+ "last_equity" : current_equity ,
1216+ "equity_peak" : equity_peak ,
11141217 "positions_count" : len (bridge .positions ),
11151218 }
11161219 )
11171220
1221+ # R3-E-12 fix: append to equity_history.json for dashboard visibility
1222+ _append_equity_history (current_equity )
1223+
11181224 if traded :
1119- # Sleep until next market open (dynamic, Fix #34)
1120- next_open = clock .get ("next_open" )
1121- # After trading, sleep until market re-opens next session
1122- # If next_open is available and in the future, sleep until then
1225+ # R3-E-2 fix: re-fetch clock after trading to get fresh next_open
1226+ fresh_clock = broker .get_clock ()
1227+ next_open = fresh_clock .get ("next_open" )
11231228 if next_open :
11241229 sleep_secs = _seconds_until (next_open )
11251230 logger .info (
0 commit comments