From 24e88cb74fadf7aac41c60ec8662886b925d963e Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Fri, 24 Apr 2026 11:30:09 -0700 Subject: [PATCH] Fix cash-capped entries + wire snapshot store into run daily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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 --- apps/paper_trader/cli.py | 10 +-- apps/paper_trader/engine.py | 124 ++++++++++++++++++++++++++---------- 2 files changed, 93 insertions(+), 41 deletions(-) diff --git a/apps/paper_trader/cli.py b/apps/paper_trader/cli.py index 46954fc..acd4946 100644 --- a/apps/paper_trader/cli.py +++ b/apps/paper_trader/cli.py @@ -156,17 +156,9 @@ def cmd_run(args: argparse.Namespace) -> None: _console.print(f"[red]Session '{session.session_name}' is closed.[/]") 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 - oracle_url = os.environ.get("ORACLE_URL") or os.environ.get("STOCK_ORACLE_URL", "http://localhost:8000") - 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) + engine = _make_engine(session, args) target_date = None if args.date: diff --git a/apps/paper_trader/engine.py b/apps/paper_trader/engine.py index fa641fc..bba9d73 100644 --- a/apps/paper_trader/engine.py +++ b/apps/paper_trader/engine.py @@ -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.warning( + logger.debug( "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,