From 99ad043f141efeac7fde9eea1a108f6b8926b06d Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Thu, 23 Apr 2026 00:53:20 -0700 Subject: [PATCH] Paper trader Phase 1.A.1: poll actual fill price on exits, kill silent drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously close_position(..., fill_price=X) was silently swallowed by **kwargs while the DB wrote the simulated exit_price — broker and ledger drifted on every exit. - AlpacaBroker.close_position drops the **kwargs sink; docstring documents that callers must poll get_order(order.id) for the actual filled_avg_price - _poll_exit_fill(order_id, symbol, timeout=30s) added; 1s interval, returns actual Alpaca fill price, logs timeout/terminal status - 3 exit sites now poll and write the actual fill + re-derive net_pnl with direction-aware sign (short-safe for future short configs): run_daily main exit path (simulate_exit) _monitor_close (intraday stop/target hits) _process_exits (scheduled & forced exits incl. partial T1) - WARN-level "paper_engine_exit_fill_drift" when actual vs simulated diverges >0.5% so drift is visible in logs even when non-pathological Co-Authored-By: Claude Sonnet 4.6 --- apps/paper_trader/alpaca_broker.py | 9 ++- apps/paper_trader/engine.py | 106 +++++++++++++++++++++++++---- 2 files changed, 99 insertions(+), 16 deletions(-) diff --git a/apps/paper_trader/alpaca_broker.py b/apps/paper_trader/alpaca_broker.py index ef8f554..4984026 100644 --- a/apps/paper_trader/alpaca_broker.py +++ b/apps/paper_trader/alpaca_broker.py @@ -209,8 +209,13 @@ class AlpacaBroker: except Exception: return None - def close_position(self, symbol: str, qty: int | None = None, **kwargs) -> Order: - """Close a position. Pass qty for partial close.""" + def close_position(self, symbol: str, qty: int | None = None) -> Order: + """Close a position. Pass qty for partial close. + + Returns the submitted close Order; caller must poll `get_order(order.id)` + for the actual `filled_avg_price` — the submit response does NOT include + fill data. + """ from alpaca.trading.requests import ClosePositionRequest if qty is not None: diff --git a/apps/paper_trader/engine.py b/apps/paper_trader/engine.py index 40a3772..fa641fc 100644 --- a/apps/paper_trader/engine.py +++ b/apps/paper_trader/engine.py @@ -446,6 +446,35 @@ class PaperTradingEngine: ) return None, reason + def _poll_exit_fill( + self, order_id: str, symbol: str, timeout_sec: float = 30.0 + ) -> float | None: + """Poll a submitted close/exit order until `filled_avg_price` is populated. + + Returns the actual Alpaca fill price or None on timeout. Callers must + handle the None case (fall back to simulated price and log drift). + """ + deadline = time.time() + timeout_sec + while time.time() < deadline: + try: + o = self._broker.get_order(order_id) + if o and o.filled_avg_price: + return float(o.filled_avg_price) + if o and o.status in ("canceled", "rejected", "expired"): + logger.warning( + "paper_engine_exit_terminal", + symbol=symbol, order_id=order_id, alpaca_status=o.status, + ) + return None + except Exception as exc: + logger.debug("paper_engine_poll_exit_retry", order_id=order_id, error=str(exc)) + time.sleep(1.0) + logger.warning( + "paper_engine_exit_fill_timeout", + symbol=symbol, order_id=order_id, timeout_sec=timeout_sec, + ) + return None + def _is_market_open(self) -> bool: """Return True if US equity market is currently open. @@ -583,11 +612,24 @@ class PaperTradingEngine: close_qty = None if filled_trade.shares < alpaca_pos.qty: close_qty = filled_trade.shares - self._broker.close_position(sym, qty=close_qty, fill_price=filled_trade.exit_price) + close_order = self._broker.close_position(sym, qty=close_qty) + actual_fill = self._poll_exit_fill(close_order.id, sym) + if actual_fill is not None: + exit_price_used = actual_fill + drift = actual_fill - filled_trade.exit_price + if abs(drift) / max(abs(filled_trade.exit_price), 1e-9) > 0.005: + logger.warning( + "paper_engine_exit_fill_drift", + symbol=sym, simulated=filled_trade.exit_price, + actual=actual_fill, drift_pct=round(100*drift/filled_trade.exit_price, 2), + ) + else: + exit_price_used = filled_trade.exit_price logger.info( "paper_engine_exit", symbol=sym, reason=filled_trade.exit_reason.value, + exit_price=exit_price_used, pnl=filled_trade.net_pnl, ) except Exception as exc: @@ -595,6 +637,15 @@ class PaperTradingEngine: continue self._state.close_strategy_state(session_id, sym) + if exit_price_used != filled_trade.exit_price: + direction_sign = -1.0 if (ss.trade_direction or "long") == "short" else 1.0 + actual_pnl = ( + (exit_price_used - alpaca_pos.avg_entry_price) + * filled_trade.shares + * direction_sign + ) + else: + actual_pnl = filled_trade.net_pnl self._state.close_trade( session_id=session_id, symbol=sym, @@ -603,14 +654,14 @@ class PaperTradingEngine: entry_date=ss.entry_date, exit_date=today.isoformat(), entry_price=alpaca_pos.avg_entry_price, - exit_price=filled_trade.exit_price, + exit_price=exit_price_used, exit_reason=filled_trade.exit_reason.value, shares=filled_trade.shares, - net_pnl=filled_trade.net_pnl, + net_pnl=actual_pnl, r_multiple=filled_trade.r_multiple, holding_days=ss.days_held, ) - net_pnl_today += filled_trade.net_pnl + net_pnl_today += actual_pnl # Update consecutive losses / cooldown if filled_trade.net_pnl < 0: @@ -2313,7 +2364,11 @@ class PaperTradingEngine: """모니터링 루프에서 포지션 청산 처리.""" session_id = self._session.session_id try: - self._broker.close_position(pos.symbol, fill_price=price) + close_order = self._broker.close_position(pos.symbol) + actual_fill = self._poll_exit_fill(close_order.id, pos.symbol) + exit_price_used = actual_fill if actual_fill is not None else price + direction_sign = -1.0 if (ss.trade_direction or "long") == "short" else 1.0 + actual_pnl = (exit_price_used - pos.avg_entry_price) * pos.qty * direction_sign self._state.close_strategy_state(session_id, pos.symbol) self._state.close_trade( session_id=session_id, @@ -2323,10 +2378,10 @@ class PaperTradingEngine: entry_date=ss.entry_date, exit_date=dt.date.today().isoformat(), entry_price=pos.avg_entry_price, - exit_price=price, + exit_price=exit_price_used, exit_reason=reason, shares=pos.qty, - net_pnl=(price - pos.avg_entry_price) * pos.qty, + net_pnl=actual_pnl, r_multiple=0.0, holding_days=ss.days_held, ) @@ -2468,10 +2523,24 @@ class PaperTradingEngine: if filled_trade is not None: is_partial = filled_trade.shares < alpaca_pos.qty try: - self._broker.close_position(sym, qty=filled_trade.shares if is_partial else None, fill_price=filled_trade.exit_price) + close_order = self._broker.close_position( + sym, qty=filled_trade.shares if is_partial else None + ) + actual_fill = self._poll_exit_fill(close_order.id, sym) + exit_price_used = actual_fill if actual_fill is not None else filled_trade.exit_price + if actual_fill is not None: + drift = actual_fill - filled_trade.exit_price + if abs(drift) / max(abs(filled_trade.exit_price), 1e-9) > 0.005: + logger.warning( + "paper_engine_exit_fill_drift", + symbol=sym, simulated=filled_trade.exit_price, + actual=actual_fill, + drift_pct=round(100*drift/filled_trade.exit_price, 2), + ) logger.info( "paper_engine_exit", - symbol=sym, reason=filled_trade.exit_reason.value, pnl=filled_trade.net_pnl, + symbol=sym, reason=filled_trade.exit_reason.value, + exit_price=exit_price_used, pnl=filled_trade.net_pnl, partial=is_partial, ) except Exception as exc: @@ -2489,17 +2558,26 @@ class PaperTradingEngine: ) else: self._state.close_strategy_state(session_id, sym) + if exit_price_used != filled_trade.exit_price: + direction_sign = -1.0 if (ss.trade_direction or "long") == "short" else 1.0 + actual_pnl = ( + (exit_price_used - alpaca_pos.avg_entry_price) + * filled_trade.shares + * direction_sign + ) + else: + actual_pnl = filled_trade.net_pnl self._state.close_trade( session_id=session_id, symbol=sym, engine_id=ss.engine_id, capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss), entry_date=ss.entry_date, exit_date=today.isoformat(), - entry_price=alpaca_pos.avg_entry_price, exit_price=filled_trade.exit_price, + entry_price=alpaca_pos.avg_entry_price, exit_price=exit_price_used, exit_reason=filled_trade.exit_reason.value, shares=filled_trade.shares, - net_pnl=filled_trade.net_pnl, r_multiple=filled_trade.r_multiple, + net_pnl=actual_pnl, r_multiple=filled_trade.r_multiple, holding_days=ss.days_held, ) - if filled_trade.net_pnl < 0: + if actual_pnl < 0: session_st.consecutive_losses += 1 streak = self._config.risk.cooldown_after_loss_streak if streak > 0 and session_st.consecutive_losses >= streak: @@ -2509,8 +2587,8 @@ class PaperTradingEngine: session_st.consecutive_losses = 0 exits.append({ "symbol": sym, "reason": filled_trade.exit_reason.value, - "pnl": filled_trade.net_pnl, "r_multiple": filled_trade.r_multiple, - "shares": filled_trade.shares, "exit_price": filled_trade.exit_price, + "pnl": actual_pnl, "r_multiple": filled_trade.r_multiple, + "shares": filled_trade.shares, "exit_price": exit_price_used, }) else: self._state.update_strategy_state(