From 41462c9e2c25b7034edbcac3bc8e7283beefbe1d Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Mon, 20 Apr 2026 09:17:10 -0700 Subject: [PATCH] Fix: parking buy fill confirmation blocks event loop with 5s hard timeout Make _parking_buy async (asyncio.sleep instead of time.sleep) and extend confirmation from 5s to 30s per attempt with up to 3 retries on timeout. On timeout, cancels the stale order before retrying with fresh price data. Co-Authored-By: Claude Sonnet 4.6 --- apps/paper_trader/engine.py | 88 +++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 18 deletions(-) diff --git a/apps/paper_trader/engine.py b/apps/paper_trader/engine.py index 190d679..4f97433 100644 --- a/apps/paper_trader/engine.py +++ b/apps/paper_trader/engine.py @@ -5,6 +5,7 @@ Order execution (HOW to execute) is done via Alpaca Paper Trading API. """ from __future__ import annotations +import asyncio import datetime as dt import json import math @@ -1157,7 +1158,7 @@ class PaperTradingEngine: # CASH PARKING: buy with idle cash (after all entries) # ============================================================ if self._config.risk.cash_parking_enabled and not parking_sold_today: - self._parking_buy(session_id, today) + await self._parking_buy(session_id, today) summary = self._finalize_day(today, session_st, exits, entries, rejected, len(candidate_rows)) summary["reconciliation"] = recon @@ -1741,7 +1742,7 @@ class PaperTradingEngine: session_cash = max(0.0, session_equity - session_mv) return session_equity, session_cash - def _parking_buy(self, session_id: str, today: dt.date) -> None: + async def _parking_buy(self, session_id: str, today: dt.date) -> None: """Buy or top-up parking with idle cash after all entries are done.""" risk = self._config.risk parking_st = self._state.get_parking_state(session_id) @@ -1810,28 +1811,79 @@ class PaperTradingEngine: if qty <= 0: return - logger.info("parking_buy", symbol=sym, qty=qty, price=round(price, 2)) - try: - order = self._broker.submit_market_buy(sym, qty) - for _ in range(5): - time.sleep(1) - filled = self._broker.get_order(order.id) - if filled and filled.filled_avg_price: - avg_price = filled.filled_avg_price + MAX_ATTEMPTS = 3 + CONFIRM_SECS = 30 + + for attempt in range(1, MAX_ATTEMPTS + 1): + if attempt > 1: + bars = self._broker.get_latest_bars([sym]) + if sym not in bars: + return + price = bars[sym].close + if price <= 0 or investable < price: + return + qty = int(investable / price) + if qty <= 0: + return + + logger.info("parking_buy", symbol=sym, qty=qty, price=round(price, 2), attempt=attempt) + try: + order = self._broker.submit_market_buy(sym, qty) + except Exception as e: + logger.warning("parking_buy_submit_failed", error=str(e), attempt=attempt) + if attempt < MAX_ATTEMPTS: + await asyncio.sleep(5) + continue + + filled_price: float | None = None + for _ in range(CONFIRM_SECS): + await asyncio.sleep(1) + o = self._broker.get_order(order.id) + if o and o.filled_avg_price: + filled_price = o.filled_avg_price + break + + if filled_price is None: + o = self._broker.get_order(order.id) + if o and o.filled_avg_price: + filled_price = o.filled_avg_price + + if filled_price is not None: + self._state.save_parking_state( + session_id, sym, today, qty, filled_price, filled_price * qty, + peak_price=filled_price, gate_in_sgov=1 if target == "sgov" else 0, + committed_target=target, + ) + self._state.open_trade( + session_id, sym, "parking", None, + today.isoformat(), filled_price, qty, + ) + logger.info("parking_filled", symbol=sym, qty=qty, price=round(filled_price, 2)) + return + + logger.warning("parking_buy_timeout", symbol=sym, order_id=order.id, attempt=attempt) + try: + self._broker.cancel_order(order.id) + except Exception: + # cancel may fail if order filled between final check and cancel call + o = self._broker.get_order(order.id) + if o and o.filled_avg_price: self._state.save_parking_state( - session_id, sym, today, qty, avg_price, avg_price * qty, - peak_price=avg_price, gate_in_sgov=1 if target == "sgov" else 0, + session_id, sym, today, qty, o.filled_avg_price, o.filled_avg_price * qty, + peak_price=o.filled_avg_price, gate_in_sgov=1 if target == "sgov" else 0, committed_target=target, ) self._state.open_trade( session_id, sym, "parking", None, - today.isoformat(), avg_price, qty, + today.isoformat(), o.filled_avg_price, qty, ) - logger.info("parking_filled", symbol=sym, qty=qty, price=round(avg_price, 2)) + logger.info("parking_filled_after_cancel_fail", symbol=sym) return - logger.warning("parking_buy_timeout", symbol=sym, order_id=order.id) - except Exception as e: - logger.warning("parking_buy_failed", error=str(e)) + + if attempt < MAX_ATTEMPTS: + await asyncio.sleep(5) + + logger.error("parking_buy_all_attempts_failed", symbol=sym) # ------------------------------------------------------------------ # # Phased execution: reaction_close / next_open / monitor @@ -2093,7 +2145,7 @@ class PaperTradingEngine: # CASH PARKING: buy with remaining idle cash after entries if self._config.risk.cash_parking_enabled and not parking_sold_today: - self._parking_buy(session_id, today) + await self._parking_buy(session_id, today) summary = self._finalize_day(today, session_st, exits, entries, rejected, len(next_open_rows)) summary["phase"] = phase