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.[/]")
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:

@ -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: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
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,

Loading…
Cancel
Save