Paper trader Phase 1.A.1: poll actual fill price on exits, kill silent drop

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 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent d83a666f30
commit 99ad043f14

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

@ -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(

Loading…
Cancel
Save