@ -13,7 +13,15 @@ import time
from dataclasses import dataclass , field
from typing import Any
from libs . backtest . allocator import build_planned_order
from libs . backtest . allocator import (
_cap_shares_by_position_limits ,
_resolve_effective_per_trade_risk_pct ,
_resolve_sizing_equity ,
_resolve_stop_risk_config ,
build_planned_order ,
compute_shares ,
compute_stop_price ,
)
from libs . backtest . domain import (
BacktestConfig ,
Candidate ,
@ -360,15 +368,18 @@ class PaperTradingEngine:
parking_st = self . _state . get_parking_state ( self . _session . session_id )
parking_sym = parking_st [ " symbol " ] . upper ( ) if parking_st else None
# Orphaned: on Alpaca but no local state (e.g. manual buy, or state save failed)
# Orphaned: on Alpaca but no local state. In a shared Alpaca account this is commonly
# another session's position — log at DEBUG to avoid spam. Real orphans (manual buys,
# state-save failures) need manual investigation via the reconcile-orphans CLI command.
for sym in sorted ( alpaca_symbols - local_symbols ) :
if parking_sym and sym == parking_sym :
continue # parking position — tracked separately, not truly orphaned
report . orphaned_alpaca . append ( sym )
logger . warnin g(
logger . debu g(
" paper_engine_orphaned_position " ,
symbol = sym ,
msg = " Position on Alpaca but no local strategy state — skipping (manual intervention needed) " ,
session_id = session_id ,
msg = " Position on Alpaca but no local strategy state — may belong to another session " ,
)
# Ghost: local state but no Alpaca position (e.g. manually closed, or order never filled)
@ -409,7 +420,7 @@ class PaperTradingEngine:
)
return report
def _verify_order_fill ( self , order_id : str , symbol : str , timeout_sec : float = 15.0 ) - > tuple [ Order | None , str ] :
def _verify_order_fill ( self , order_id : str , symbol : str , timeout_sec : float | None = None ) - > tuple [ Order | None , str ] :
""" Poll broker to verify order fill.
Returns ( order , " " ) on success .
@ -420,6 +431,13 @@ class PaperTradingEngine:
- alpaca_rejected → record permanently in processed_events ( real problem )
- order_timeout → do NOT record ( allow retry on next run_next_open )
"""
if timeout_sec is None :
# Opening auction (09:30– 09:45 ET) needs more time for fills to propagate
import zoneinfo
now_et = dt . datetime . now ( tz = zoneinfo . ZoneInfo ( " America/New_York " ) )
open_window = now_et . replace ( hour = 9 , minute = 30 , second = 0 , microsecond = 0 )
cutoff = now_et . replace ( hour = 9 , minute = 45 , second = 0 , microsecond = 0 )
timeout_sec = 90.0 if open_window < = now_et < cutoff else 15.0
deadline = time . monotonic ( ) + timeout_sec
while time . monotonic ( ) < deadline :
try :
@ -695,8 +713,9 @@ class PaperTradingEngine:
if session_st . cooldown_remaining > 0 :
session_st . cooldown_remaining - = 1
# Reset daily risk usage
session_st . daily_new_risk_used = 0.0
# Reset daily risk usage only on first call of the day
if session_st . last_processed_date != today . isoformat ( ) :
session_st . daily_new_risk_used = 0.0
# ============================================================
# CASH PARKING: accrue interest, update peak, check gate (before entries)
@ -904,24 +923,40 @@ class PaperTradingEngine:
macro_data = macro_data ,
engine_daily_new_risk_used = engine_risk_used ,
)
if plan . skip_reason == " insufficient_cash " :
# Attempt to free parking cash before giving up
needed = plan . shares * float ( candidate . entry_price_est ) if plan . shares else float (
candidate . entry_price_est * 1
# Attempt parking liquidation when cash constrains the position —
# either 0 shares (insufficient_cash) or fewer than the risk-based
# target (cash cap silently reduced the size). Use full session
# equity/cash (not bucket-adjusted) since parking is global.
_stop = compute_stop_price ( candidate , _resolve_stop_risk_config ( candidate , self . _config ) )
_target_shares = compute_shares (
_resolve_sizing_equity ( portfolio_state ) ,
candidate . entry_price_est ,
_stop ,
self . _config . risk ,
risk_pct_override = _resolve_effective_per_trade_risk_pct ( candidate , self . _config ) ,
)
_target_shares = _cap_shares_by_position_limits (
_target_shares , candidate , portfolio_state , self . _config
)
_cash_limited = (
plan . skip_reason == " insufficient_cash "
or (
not plan . skip_reason
and _target_shares > 0
and ( plan . shares or 0 ) < _target_shares
and portfolio_state . cash_available < _target_shares * float ( candidate . entry_price_est )
)
)
if _cash_limited :
needed = max ( 0.0 , _target_shares * float ( candidate . entry_price_est ) - portfolio_state . cash_available )
if self . _parking_liquidate_for_event ( session_id , today , needed ) :
account = self . _broker . get_account ( )
_ap2 = self . _broker . list_positions ( )
alpaca_positions_after_exits = _ap2
portfolio_state = self . _build_portfolio_state ( account , _ap2 , today )
candidate_portfolio_state = self . _adjust_portfolio_state_for_candidate (
session_id = session_id ,
candidate = candidate ,
portfolio_state = portfolio_state ,
active_bucket_ids = active_bucket_ids ,
alpaca_positions = _ap2 ,
strategy_states = strategy_states_after_exits ,
)
# Skip bucket adjustment: parking was freed specifically for
# this trade, so the freed cash should be fully available.
candidate_portfolio_state = portfolio_state
plan = build_planned_order (
candidate = candidate ,
portfolio_state = candidate_portfolio_state ,
@ -2160,6 +2195,9 @@ class PaperTradingEngine:
if self . _config . risk . cash_parking_enabled :
try :
parking_sold_today = self . _parking_check_and_sell ( session_id , today )
if parking_sold_today :
account = self . _broker . get_account ( )
alpaca_positions = self . _broker . list_positions ( )
except Exception as _pcs_exc :
logger . warning (
" paper_engine_parking_check_failed_skipped " ,
@ -2429,7 +2467,8 @@ class PaperTradingEngine:
exits : list [ dict [ str , Any ] ] = [ ]
held_symbols = [ p . symbol for p in alpaca_positions ]
if not held_symbols :
session_st . daily_new_risk_used = 0.0
if session_st . last_processed_date != today . isoformat ( ) :
session_st . daily_new_risk_used = 0.0
return exits
bar_start = bar_date - dt . timedelta ( days = 30 )
@ -2598,7 +2637,8 @@ class PaperTradingEngine:
if session_st . cooldown_remaining > 0 :
session_st . cooldown_remaining - = 1
session_st . daily_new_risk_used = 0.0
if session_st . last_processed_date != today . isoformat ( ) :
session_st . daily_new_risk_used = 0.0
return exits
async def _process_entries (
@ -2837,12 +2877,37 @@ class PaperTradingEngine:
)
freed = False
parking_had_qty = False
if plan . skip_reason == " insufficient_cash " :
# Compute risk-based target to detect cash-capped plans (not just 0-share).
# plan.shares may have been silently reduced by build_planned_order's cash
# cap, so comparing against the uncapped target reveals the gap.
_stop = compute_stop_price ( candidate , _resolve_stop_risk_config ( candidate , self . _config ) )
_target_shares = compute_shares (
_resolve_sizing_equity ( portfolio_state ) ,
candidate . entry_price_est ,
_stop ,
self . _config . risk ,
risk_pct_override = _resolve_effective_per_trade_risk_pct ( candidate , self . _config ) ,
)
_target_shares = _cap_shares_by_position_limits (
_target_shares , candidate , portfolio_state , self . _config
)
_cash_limited = (
plan . skip_reason == " insufficient_cash "
or (
not plan . skip_reason
and _target_shares > 0
and ( plan . shares or 0 ) < _target_shares
and portfolio_state . cash_available < _target_shares * float ( candidate . entry_price_est )
)
)
if _cash_limited :
# Check if parking has shares before attempting liquidation
_pst = self . _state . get_parking_state ( session_id )
parking_had_qty = _pst is not None and ( _pst . get ( " qty " ) or 0 ) > 0
# 1) Attempt to free parking cash before giving up
needed = plan . shares * float ( candidate . entry_price_est ) if plan . shares else float ( candidate . entry_price_est )
# 1) Attempt to free parking cash before giving up.
# Size liquidation against the risk-based target, not plan.shares
# (which may already be cash-capped to a tiny number).
needed = max ( 0.0 , _target_shares * float ( candidate . entry_price_est ) - portfolio_state . cash_available )
freed = self . _parking_liquidate_for_event ( session_id , today , needed )
# 2) If parking didn't help, try recycle (sell weak position)
if not freed and engine_cfg is not None and engine_cfg . recycle_on_cash_block :
@ -2858,14 +2923,9 @@ class PaperTradingEngine:
for ss in self . _state . get_open_strategy_states ( session_id )
}
portfolio_state = self . _build_portfolio_state ( account , _ap2 , today )
candidate_portfolio_state = self . _adjust_portfolio_state_for_candidate (
session_id = session_id ,
candidate = candidate ,
portfolio_state = portfolio_state ,
active_bucket_ids = active_bucket_ids ,
alpaca_positions = _ap2 ,
strategy_states = strategy_states ,
)
# Skip bucket adjustment: parking was freed specifically for
# this trade, so the freed cash should be fully available.
candidate_portfolio_state = portfolio_state
plan = build_planned_order (
candidate = candidate , portfolio_state = candidate_portfolio_state ,
open_positions = open_positions , config = self . _config ,