Fix cash-capped entries + wire snapshot store into run daily

_process_entries silently let cash-capped plans through — e.g. a $1k
session with 0.55 risk sized NOW at 1 share, triggering parking
liquidation of only 2 TQQQ instead of the full balance needed for
the risk-based target. Broaden the _cash_limited trigger to include
plans where plan.shares < risk-based target, and size the liquidation
needed against that target instead of plan.shares. Mirrors the
engine_batches loop fix from the prior commit. cmd_run was also
constructing the engine without snapshot_store, so run daily's
lookback entry path never fired — route it through _make_engine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent 004f1a5bce
commit 24e88cb74f

@ -156,17 +156,9 @@ def cmd_run(args: argparse.Namespace) -> None:
_console.print(f"[red]Session '{session.session_name}' is closed.[/]") _console.print(f"[red]Session '{session.session_name}' is closed.[/]")
sys.exit(1) sys.exit(1)
broker = _get_broker()
from apps.paper_trader.event_detector import EventDetector
from apps.paper_trader.engine import PaperTradingEngine
from apps.paper_trader.reporter import print_run_summary from apps.paper_trader.reporter import print_run_summary
oracle_url = os.environ.get("ORACLE_URL") or os.environ.get("STOCK_ORACLE_URL", "http://localhost:8000") engine = _make_engine(session, args)
db_dsn = os.environ.get("DB_DSN") or os.environ.get("POSTGRES_DSN", "")
detector = EventDetector(db_dsn=db_dsn, oracle_url=oracle_url)
engine = PaperTradingEngine(session=session, broker=broker, state=state, event_detector=detector)
target_date = None target_date = None
if args.date: if args.date:

@ -13,7 +13,15 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any 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 ( from libs.backtest.domain import (
BacktestConfig, BacktestConfig,
Candidate, Candidate,
@ -360,15 +368,18 @@ class PaperTradingEngine:
parking_st = self._state.get_parking_state(self._session.session_id) parking_st = self._state.get_parking_state(self._session.session_id)
parking_sym = parking_st["symbol"].upper() if parking_st else None 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): for sym in sorted(alpaca_symbols - local_symbols):
if parking_sym and sym == parking_sym: if parking_sym and sym == parking_sym:
continue # parking position — tracked separately, not truly orphaned continue # parking position — tracked separately, not truly orphaned
report.orphaned_alpaca.append(sym) report.orphaned_alpaca.append(sym)
logger.warning( logger.debug(
"paper_engine_orphaned_position", "paper_engine_orphaned_position",
symbol=sym, 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) # Ghost: local state but no Alpaca position (e.g. manually closed, or order never filled)
@ -409,7 +420,7 @@ class PaperTradingEngine:
) )
return report 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. """Poll broker to verify order fill.
Returns (order, "") on success. Returns (order, "") on success.
@ -420,6 +431,13 @@ class PaperTradingEngine:
- alpaca_rejected record permanently in processed_events (real problem) - alpaca_rejected record permanently in processed_events (real problem)
- order_timeout do NOT record (allow retry on next run_next_open) - order_timeout do NOT record (allow retry on next run_next_open)
""" """
if timeout_sec is None:
# Opening auction (09:3009: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 deadline = time.monotonic() + timeout_sec
while time.monotonic() < deadline: while time.monotonic() < deadline:
try: try:
@ -695,7 +713,8 @@ class PaperTradingEngine:
if session_st.cooldown_remaining > 0: if session_st.cooldown_remaining > 0:
session_st.cooldown_remaining -= 1 session_st.cooldown_remaining -= 1
# Reset daily risk usage # 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 session_st.daily_new_risk_used = 0.0
# ============================================================ # ============================================================
@ -904,24 +923,40 @@ class PaperTradingEngine:
macro_data=macro_data, macro_data=macro_data,
engine_daily_new_risk_used=engine_risk_used, engine_daily_new_risk_used=engine_risk_used,
) )
if plan.skip_reason == "insufficient_cash": # Attempt parking liquidation when cash constrains the position —
# Attempt to free parking cash before giving up # either 0 shares (insufficient_cash) or fewer than the risk-based
needed = plan.shares * float(candidate.entry_price_est) if plan.shares else float( # target (cash cap silently reduced the size). Use full session
candidate.entry_price_est * 1 # 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): if self._parking_liquidate_for_event(session_id, today, needed):
account = self._broker.get_account() account = self._broker.get_account()
_ap2 = self._broker.list_positions() _ap2 = self._broker.list_positions()
alpaca_positions_after_exits = _ap2 alpaca_positions_after_exits = _ap2
portfolio_state = self._build_portfolio_state(account, _ap2, today) portfolio_state = self._build_portfolio_state(account, _ap2, today)
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( # Skip bucket adjustment: parking was freed specifically for
session_id=session_id, # this trade, so the freed cash should be fully available.
candidate=candidate, candidate_portfolio_state = portfolio_state
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
alpaca_positions=_ap2,
strategy_states=strategy_states_after_exits,
)
plan = build_planned_order( plan = build_planned_order(
candidate=candidate, candidate=candidate,
portfolio_state=candidate_portfolio_state, portfolio_state=candidate_portfolio_state,
@ -2160,6 +2195,9 @@ class PaperTradingEngine:
if self._config.risk.cash_parking_enabled: if self._config.risk.cash_parking_enabled:
try: try:
parking_sold_today = self._parking_check_and_sell(session_id, today) 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: except Exception as _pcs_exc:
logger.warning( logger.warning(
"paper_engine_parking_check_failed_skipped", "paper_engine_parking_check_failed_skipped",
@ -2429,6 +2467,7 @@ class PaperTradingEngine:
exits: list[dict[str, Any]] = [] exits: list[dict[str, Any]] = []
held_symbols = [p.symbol for p in alpaca_positions] held_symbols = [p.symbol for p in alpaca_positions]
if not held_symbols: if not held_symbols:
if session_st.last_processed_date != today.isoformat():
session_st.daily_new_risk_used = 0.0 session_st.daily_new_risk_used = 0.0
return exits return exits
@ -2598,6 +2637,7 @@ class PaperTradingEngine:
if session_st.cooldown_remaining > 0: if session_st.cooldown_remaining > 0:
session_st.cooldown_remaining -= 1 session_st.cooldown_remaining -= 1
if session_st.last_processed_date != today.isoformat():
session_st.daily_new_risk_used = 0.0 session_st.daily_new_risk_used = 0.0
return exits return exits
@ -2837,12 +2877,37 @@ class PaperTradingEngine:
) )
freed = False freed = False
parking_had_qty = 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 # Check if parking has shares before attempting liquidation
_pst = self._state.get_parking_state(session_id) _pst = self._state.get_parking_state(session_id)
parking_had_qty = _pst is not None and (_pst.get("qty") or 0) > 0 parking_had_qty = _pst is not None and (_pst.get("qty") or 0) > 0
# 1) Attempt to free parking cash before giving up # 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) # 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) freed = self._parking_liquidate_for_event(session_id, today, needed)
# 2) If parking didn't help, try recycle (sell weak position) # 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: 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) for ss in self._state.get_open_strategy_states(session_id)
} }
portfolio_state = self._build_portfolio_state(account, _ap2, today) portfolio_state = self._build_portfolio_state(account, _ap2, today)
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( # Skip bucket adjustment: parking was freed specifically for
session_id=session_id, # this trade, so the freed cash should be fully available.
candidate=candidate, candidate_portfolio_state = portfolio_state
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
alpaca_positions=_ap2,
strategy_states=strategy_states,
)
plan = build_planned_order( plan = build_planned_order(
candidate=candidate, portfolio_state=candidate_portfolio_state, candidate=candidate, portfolio_state=candidate_portfolio_state,
open_positions=open_positions, config=self._config, open_positions=open_positions, config=self._config,

Loading…
Cancel
Save