Fix: paper trader ignores per-engine overrides in build_planned_order

All five build_planned_order() calls in apps/paper_trader/engine.py were
missing execution_config, causing build_planned_order to fall back to
base config.execution and silently ignore per-engine target_1_r_override,
target_1_fraction_override, max_holding_days, tiered-target settings, etc.

The backtester has always passed execution_config=_build_effective_execution_config()
(run.py:2210). This divergence caused paper trading to compute wrong target
prices and partial-exit fractions — e.g. AVGO entered with target_r=1.5
(base) instead of 3.0 (engine override), triggering a premature partial
exit on 4/15 that the backtest never produced.

Fix: add execution_config=build_effective_execution_config(candidate, self._config)
to all five call sites and hoist the function to the module-level import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent fdd56ff683
commit 27b8a49d77

@ -22,7 +22,7 @@ from libs.backtest.domain import (
PlannedOrder, PlannedOrder,
PositionStatus, PositionStatus,
) )
from libs.backtest.execution import simulate_exit, simulate_scheduled_open_exit, update_trailing_stop from libs.backtest.execution import build_effective_execution_config, simulate_exit, simulate_scheduled_open_exit, update_trailing_stop
from libs.backtest.manifests import load_manifest, resolve_config from libs.backtest.manifests import load_manifest, resolve_config
from libs.backtest.selector import select_candidates from libs.backtest.selector import select_candidates
from libs.common.logging import get_logger from libs.common.logging import get_logger
@ -338,8 +338,14 @@ class PaperTradingEngine:
alpaca_symbols = {p.symbol for p in alpaca_positions} alpaca_symbols = {p.symbol for p in alpaca_positions}
local_symbols = set(strategy_states.keys()) local_symbols = set(strategy_states.keys())
# Exclude parking position from orphaned check (it's tracked in parking_state, not strategy_states)
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 (e.g. manual buy, or state save failed)
for sym in sorted(alpaca_symbols - local_symbols): 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) report.orphaned_alpaca.append(sym)
logger.warning( logger.warning(
"paper_engine_orphaned_position", "paper_engine_orphaned_position",
@ -649,6 +655,58 @@ class PaperTradingEngine:
for ss in self._state.get_open_strategy_states(session_id) for ss in self._state.get_open_strategy_states(session_id)
} }
# Lookback entry: pick up prior-day candidates still within holding window.
# Fires once per engine instance (same gate as run_next_open).
lookback_entries: list[dict[str, Any]] = []
lookback_rejected: list[dict[str, Any]] = []
if self._config.execution.lookback_entry_enabled and not self._lookback_injected:
self._lookback_injected = True
if self._snapshot_store is not None:
from libs.backtest.calendar import get_trading_days
start_lb = self._lookback_start_date(today)
lookback_rows = []
for lb_date in get_trading_days(start_lb, today):
if lb_date >= today:
continue
for row in self._snapshot_store.get_candidates_for_date(lb_date):
row = dict(row)
row["is_lookback_entry"] = True
tdays = get_trading_days(lb_date, today)
row["lookback_days_elapsed"] = max(0, len(tdays) - 1)
lookback_rows.append(row)
if lookback_rows:
lb_symbols = list({str(r.get("symbol", "")).upper() for r in lookback_rows
if r.get("symbol") and "-" not in str(r.get("symbol", ""))})
try:
latest_bars = self._broker.get_latest_bars(lb_symbols)
except Exception as e:
logger.warning("lookback_price_override_failed", error=str(e))
latest_bars = {}
for row in lookback_rows:
sym = str(row.get("symbol", "")).upper()
bar = latest_bars.get(sym)
if bar is not None:
row["lookback_original_entry_price_est"] = row.get("entry_price_est")
row["entry_price_est"] = bar.close
# Also override event_close so reaction_close engines use today's
# price (not the historical reaction-day close) for entry/sizing.
row["event_close"] = bar.close
if lookback_rows:
macro_data_lb = await self._fetch_macro(today)
lookback_entries, lookback_rejected = await self._process_entries(
today, lookback_rows, account, alpaca_positions_after_exits,
strategy_states_after_exits, session_st, macro_data_lb,
self._broker.submit_market_buy,
entry_timing=None,
)
alpaca_positions_after_exits = self._broker.list_positions()
strategy_states_after_exits = {
ss.symbol: ss
for ss in self._state.get_open_strategy_states(session_id)
}
entries.extend(lookback_entries)
rejected.extend(lookback_rejected)
# Get event candidates for today — reaction_close convention only # Get event candidates for today — reaction_close convention only
candidate_rows = await self._detector.get_candidates_for_date( candidate_rows = await self._detector.get_candidates_for_date(
today, self._config, convention="reaction_close" today, self._config, convention="reaction_close"
@ -763,6 +821,7 @@ class PaperTradingEngine:
portfolio_state=candidate_portfolio_state, portfolio_state=candidate_portfolio_state,
open_positions=open_positions, open_positions=open_positions,
config=self._config, config=self._config,
execution_config=build_effective_execution_config(candidate, self._config),
cooldown_remaining=session_st.cooldown_remaining, cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data, macro_data=macro_data,
engine_daily_new_risk_used=engine_risk_used, engine_daily_new_risk_used=engine_risk_used,
@ -790,6 +849,7 @@ class PaperTradingEngine:
portfolio_state=candidate_portfolio_state, portfolio_state=candidate_portfolio_state,
open_positions=open_positions, open_positions=open_positions,
config=self._config, config=self._config,
execution_config=build_effective_execution_config(candidate, self._config),
cooldown_remaining=session_st.cooldown_remaining, cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data, macro_data=macro_data,
engine_daily_new_risk_used=engine_risk_used, engine_daily_new_risk_used=engine_risk_used,
@ -984,6 +1044,7 @@ class PaperTradingEngine:
portfolio_state=candidate_portfolio_state, portfolio_state=candidate_portfolio_state,
open_positions=open_positions, open_positions=open_positions,
config=self._config, config=self._config,
execution_config=build_effective_execution_config(candidate, self._config),
cooldown_remaining=session_st.cooldown_remaining, cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data, macro_data=macro_data,
) )
@ -1411,10 +1472,10 @@ class PaperTradingEngine:
self._state.close_parking_state(session_id) self._state.close_parking_state(session_id)
bars_now = self._broker.get_latest_bars([sym]) bars_now = self._broker.get_latest_bars([sym])
exit_price = bars_now[sym].close if sym in bars_now else parking_st["avg_price"] exit_price = bars_now[sym].close if sym in bars_now else parking_st["avg_price"]
self._state.record_trade( self._state.close_trade(
session_id=session_id, session_id=session_id,
symbol=sym, symbol=sym,
engine_id=None, engine_id="parking",
capital_bucket_id=None, capital_bucket_id=None,
entry_date=parking_st["entry_date"], entry_date=parking_st["entry_date"],
exit_date=today.isoformat(), exit_date=today.isoformat(),
@ -1453,10 +1514,10 @@ class PaperTradingEngine:
self._state.close_parking_state(session_id) self._state.close_parking_state(session_id)
bars_now = self._broker.get_latest_bars([sym]) bars_now = self._broker.get_latest_bars([sym])
exit_price = bars_now[sym].close if sym in bars_now else parking_st["avg_price"] exit_price = bars_now[sym].close if sym in bars_now else parking_st["avg_price"]
self._state.record_trade( self._state.close_trade(
session_id=session_id, session_id=session_id,
symbol=sym, symbol=sym,
engine_id=None, engine_id="parking",
capital_bucket_id=None, capital_bucket_id=None,
entry_date=parking_st["entry_date"], entry_date=parking_st["entry_date"],
exit_date=today.isoformat(), exit_date=today.isoformat(),
@ -1529,10 +1590,10 @@ class PaperTradingEngine:
# Record trade # Record trade
bars = self._broker.get_latest_bars([sym]) bars = self._broker.get_latest_bars([sym])
exit_price = bars[sym].close if sym in bars else parking_st["avg_price"] exit_price = bars[sym].close if sym in bars else parking_st["avg_price"]
self._state.record_trade( self._state.close_trade(
session_id=session_id, session_id=session_id,
symbol=sym, symbol=sym,
engine_id=None, engine_id="parking",
capital_bucket_id=None, capital_bucket_id=None,
entry_date=parking_st["entry_date"], entry_date=parking_st["entry_date"],
exit_date=today.isoformat(), exit_date=today.isoformat(),
@ -1578,18 +1639,36 @@ class PaperTradingEngine:
self._broker.close_position(sym, qty=shares_to_sell) self._broker.close_position(sym, qty=shares_to_sell)
time.sleep(3) time.sleep(3)
new_qty = qty - shares_to_sell new_qty = qty - shares_to_sell
avg = float(parking_st.get("avg_price", cur_price))
raw_entry_date = parking_st.get("entry_date", today)
if isinstance(raw_entry_date, str):
raw_entry_date = dt.date.fromisoformat(raw_entry_date[:10])
if new_qty <= 0: if new_qty <= 0:
self._state.close_parking_state(session_id) self._state.close_parking_state(session_id)
else: else:
avg = float(parking_st.get("avg_price", cur_price))
self._state.save_parking_state( self._state.save_parking_state(
session_id, sym, session_id, sym,
parking_st.get("entry_date", today), raw_entry_date,
new_qty, avg, new_qty * avg, new_qty, avg, new_qty * avg,
peak_price=float(parking_st.get("peak_price", 0) or avg), peak_price=float(parking_st.get("peak_price", 0) or avg),
gate_in_sgov=int(parking_st.get("gate_in_sgov", 0)), gate_in_sgov=int(parking_st.get("gate_in_sgov", 0)),
sgov_entry_value=0.0, sgov_entry_value=0.0,
) )
self._state.record_trade(
session_id=session_id,
symbol=sym,
engine_id="parking",
capital_bucket_id=None,
entry_date=raw_entry_date.isoformat(),
exit_date=today.isoformat(),
entry_price=avg,
exit_price=cur_price,
exit_reason="PARKING_LIQUIDATE_FOR_EVENT",
shares=shares_to_sell,
net_pnl=(cur_price - avg) * shares_to_sell,
r_multiple=0.0,
holding_days=(today - raw_entry_date).days,
)
logger.info( logger.info(
"parking_partial_sell_for_event", "parking_partial_sell_for_event",
symbol=sym, shares_sold=shares_to_sell, remaining_qty=max(0, new_qty), symbol=sym, shares_sold=shares_to_sell, remaining_qty=max(0, new_qty),
@ -1630,13 +1709,17 @@ class PaperTradingEngine:
Avoids using the shared Alpaca account directly so multiple sessions Avoids using the shared Alpaca account directly so multiple sessions
on the same broker account each see their own isolated balance. on the same broker account each see their own isolated balance.
Includes parking positions so available cash is correctly reduced. Includes parking positions so available cash is correctly reduced.
Equity is computed from first principles (initial + realized + unrealized)
rather than the stale daily snapshot so that intraday partial exits and
position gains are properly reflected when sizing parking purchases.
""" """
from apps.paper_trader.mock_broker import MockBroker from apps.paper_trader.mock_broker import MockBroker
if isinstance(self._broker, MockBroker): if isinstance(self._broker, MockBroker):
acct = self._broker.get_account() acct = self._broker.get_account()
return float(acct.equity), float(acct.cash) return float(acct.equity), float(acct.cash)
# Derive from session snapshots + open positions # Derive from live positions (same session symbols only)
session_symbols = { session_symbols = {
ss.symbol for ss in self._state.get_open_strategy_states(session_id) ss.symbol for ss in self._state.get_open_strategy_states(session_id)
} }
@ -1645,17 +1728,16 @@ class PaperTradingEngine:
session_unreal = sum(p.unrealized_pl for p in alpaca_positions if p.symbol in session_symbols) session_unreal = sum(p.unrealized_pl for p in alpaca_positions if p.symbol in session_symbols)
# Include parking position so cash isn't over-stated # Include parking position so cash isn't over-stated
parking_mv, _ = self._parking_position_value(session_id, alpaca_positions) parking_mv, parking_unreal = self._parking_position_value(session_id, alpaca_positions)
session_mv += parking_mv session_mv += parking_mv
snapshots = self._state.list_snapshots(session_id) # Compute equity from first principles (same as _finalize_day) so intraday
if snapshots: # realized P&L (e.g. partial exits) is reflected. Using the stale daily
session_equity = snapshots[-1]["equity"] # snapshot underestimates cash when positions gain value within the day.
else: total_realized = sum(
total_realized = sum( (t.get("net_pnl") or 0.0) for t in self._state.list_trades(session_id)
t.get("net_pnl", 0.0) for t in self._state.list_trades(session_id) )
) session_equity = self._session.initial_equity + total_realized + session_unreal + parking_unreal
session_equity = self._session.initial_equity + total_realized + session_unreal
session_cash = max(0.0, session_equity - session_mv) session_cash = max(0.0, session_equity - session_mv)
return session_equity, session_cash return session_equity, session_cash
@ -1741,6 +1823,10 @@ class PaperTradingEngine:
peak_price=avg_price, gate_in_sgov=1 if target == "sgov" else 0, peak_price=avg_price, gate_in_sgov=1 if target == "sgov" else 0,
committed_target=target, committed_target=target,
) )
self._state.open_trade(
session_id, sym, "parking", None,
today.isoformat(), avg_price, qty,
)
logger.info("parking_filled", symbol=sym, qty=qty, price=round(avg_price, 2)) logger.info("parking_filled", symbol=sym, qty=qty, price=round(avg_price, 2))
return return
logger.warning("parking_buy_timeout", symbol=sym, order_id=order.id) logger.warning("parking_buy_timeout", symbol=sym, order_id=order.id)
@ -1864,7 +1950,14 @@ class PaperTradingEngine:
# ============================================================ # ============================================================
parking_sold_today = False parking_sold_today = False
if self._config.risk.cash_parking_enabled: if self._config.risk.cash_parking_enabled:
parking_sold_today = self._parking_check_and_sell(session_id, today) try:
parking_sold_today = self._parking_check_and_sell(session_id, today)
except Exception as _pcs_exc:
logger.warning(
"paper_engine_parking_check_failed_skipped",
error=str(_pcs_exc) or repr(_pcs_exc),
exc_type=type(_pcs_exc).__name__,
)
# Lookback entry: on first run_next_open, pick up events from previous days # Lookback entry: on first run_next_open, pick up events from previous days
# that are still within their holding window (fires once per daemon session). # that are still within their holding window (fires once per daemon session).
@ -1895,24 +1988,29 @@ class PaperTradingEngine:
# to be based on a stale price, which can result in the account going into # to be based on a stale price, which can result in the account going into
# negative cash (cost = shares × current_price > shares × hist_price). # negative cash (cost = shares × current_price > shares × hist_price).
if lookback_rows: if lookback_rows:
lb_symbols = list({str(r.get("symbol", "")).upper() for r in lookback_rows if r.get("symbol")}) lb_symbols = list({str(r.get("symbol", "")).upper() for r in lookback_rows
if r.get("symbol") and "-" not in str(r.get("symbol", ""))})
try: try:
latest_bars = self._broker.get_latest_bars(lb_symbols) latest_bars = self._broker.get_latest_bars(lb_symbols)
for row in lookback_rows:
sym = str(row.get("symbol", "")).upper()
bar = latest_bars.get(sym)
if bar is not None:
row["lookback_original_entry_price_est"] = row.get("entry_price_est")
row["entry_price_est"] = bar.close
except Exception as e: except Exception as e:
logger.warning("lookback_price_override_failed", error=str(e)) logger.warning("lookback_price_override_failed", error=str(e))
latest_bars = {}
for row in lookback_rows:
sym = str(row.get("symbol", "")).upper()
bar = latest_bars.get(sym)
if bar is not None:
row["lookback_original_entry_price_est"] = row.get("entry_price_est")
row["entry_price_est"] = bar.close
# Also override event_close so reaction_close engines use today's
# price (not the historical reaction-day close) for entry/sizing.
row["event_close"] = bar.close
if lookback_rows: if lookback_rows:
macro_data_lb = await self._fetch_macro(today) macro_data_lb = await self._fetch_macro(today)
lookback_entries, lookback_rejected = await self._process_entries( lookback_entries, lookback_rejected = await self._process_entries(
today, lookback_rows, account, alpaca_positions, strategy_states, today, lookback_rows, account, alpaca_positions, strategy_states,
session_st, macro_data_lb, self._broker.submit_market_buy, session_st, macro_data_lb, self._broker.submit_market_buy,
entry_timing="next_open", entry_timing=None, # lookback: allow all engines regardless of original convention
) )
# Refresh state after lookback entries # Refresh state after lookback entries
alpaca_positions = self._broker.list_positions() alpaca_positions = self._broker.list_positions()
@ -2497,6 +2595,7 @@ class PaperTradingEngine:
plan = build_planned_order( plan = build_planned_order(
candidate=candidate, portfolio_state=candidate_portfolio_state, candidate=candidate, portfolio_state=candidate_portfolio_state,
open_positions=open_positions, config=self._config, open_positions=open_positions, config=self._config,
execution_config=build_effective_execution_config(candidate, self._config),
cooldown_remaining=session_st.cooldown_remaining, cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data, macro_data=macro_data,
engine_daily_new_risk_used=engine_risk_used if engine_cfg else 0.0, engine_daily_new_risk_used=engine_risk_used if engine_cfg else 0.0,
@ -2535,6 +2634,7 @@ class PaperTradingEngine:
plan = build_planned_order( plan = build_planned_order(
candidate=candidate, portfolio_state=candidate_portfolio_state, candidate=candidate, portfolio_state=candidate_portfolio_state,
open_positions=open_positions, config=self._config, open_positions=open_positions, config=self._config,
execution_config=build_effective_execution_config(candidate, self._config),
cooldown_remaining=session_st.cooldown_remaining, cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data, macro_data=macro_data,
engine_daily_new_risk_used=engine_risk_used if engine_cfg else 0.0, engine_daily_new_risk_used=engine_risk_used if engine_cfg else 0.0,
@ -2630,7 +2730,10 @@ class PaperTradingEngine:
else: else:
fill_price = plan.entry_price_limit # MOC: actual price unknown until close fill_price = plan.entry_price_limit # MOC: actual price unknown until close
initial_days_held = int(candidate.features.get("lookback_days_elapsed", 0)) # Lookback entries start at days_held=0 (actual hold time from today),
# not lookback_days_elapsed. The signal validity was already checked
# (elapsed < mhd, min_remaining_days) before entry was allowed.
initial_days_held = 0
self._state.save_strategy_state( self._state.save_strategy_state(
session_id, session_id,
StrategyStateRow( StrategyStateRow(
@ -2720,7 +2823,7 @@ class PaperTradingEngine:
# 세션 equity = initial_equity + 전체 실현 P&L + 현재 미실현 P&L (parking 포함) # 세션 equity = initial_equity + 전체 실현 P&L + 현재 미실현 P&L (parking 포함)
total_realized_pnl = sum( total_realized_pnl = sum(
t.get("net_pnl", 0.0) for t in self._state.list_trades(session_id) (t.get("net_pnl") or 0.0) for t in self._state.list_trades(session_id)
) )
session_equity_final = self._session.initial_equity + total_realized_pnl + session_unrealized_pl_final session_equity_final = self._session.initial_equity + total_realized_pnl + session_unrealized_pl_final
session_cash_final = max(0.0, session_equity_final - session_market_value_final) session_cash_final = max(0.0, session_equity_final - session_market_value_final)
@ -2926,50 +3029,59 @@ class PaperTradingEngine:
macro[f"{key_prefix}_sma_{sma_period}"] = sum(closes[-sma_period:]) / sma_period macro[f"{key_prefix}_sma_{sma_period}"] = sum(closes[-sma_period:]) / sma_period
return macro return macro
import asyncio as _asyncio
from libs.oracle_client import OracleClient, PriceService from libs.oracle_client import OracleClient, PriceService
async with OracleClient(base_url=self._detector._oracle_url) as client: async def _fetch_price_bars() -> dict[str, Any]:
svc = PriceService(client) _macro: dict[str, Any] = {}
async with OracleClient(base_url=self._detector._oracle_url, timeout=5.0) as client:
svc = PriceService(client)
async def _fetch_macro_sym(sym: str) -> tuple[str, list[Any]]: async def _fetch_macro_sym(sym: str) -> tuple[str, list[Any]]:
try: try:
resp = await svc.get_daily_bars(sym, start=start.isoformat(), end=date.isoformat()) resp = await svc.get_daily_bars(sym, start=start.isoformat(), end=date.isoformat())
return sym, resp.bars return sym, resp.bars
except Exception: except Exception:
return sym, [] return sym, []
results = await __import__("asyncio").gather( results = await _asyncio.gather(
*(_fetch_macro_sym(sym) for sym in symbols) *(_fetch_macro_sym(sym) for sym in symbols)
) )
macro = {} for sym, bars in results:
for sym, bars in results: if not bars:
if not bars: continue
continue key_prefix = sym.lower()
key_prefix = sym.lower() closes = [float(b.close) for b in bars]
closes = [float(b.close) for b in bars] if closes:
if closes: _macro[f"{key_prefix}_close"] = closes[-1]
macro[f"{key_prefix}_close"] = closes[-1] if len(closes) >= sma_period:
if len(closes) >= sma_period: _macro[f"{key_prefix}_sma_{sma_period}"] = sum(closes[-sma_period:]) / sma_period
macro[f"{key_prefix}_sma_{sma_period}"] = sum(closes[-sma_period:]) / sma_period
# Fetch FRED macro data (VIX, HY spread) for regime sizing
# Fetch FRED macro data (VIX, HY spread) for regime sizing try:
# Matches SnapshotStore._fetch_macro() which loads MacroObservation from DB from libs.oracle_client import FredService, OracleClient as _OC
async with _OC(base_url=self._detector._oracle_url, timeout=5.0) as fred_client:
fred_svc = FredService(fred_client)
for series_id in ("VIXCLS", "BAMLH0A0HYM2"):
try:
resp = await fred_svc.get_observations(series_id, start=start.isoformat(), end=date.isoformat())
if resp.observations:
latest = [o for o in resp.observations if o.value is not None]
if latest:
_macro[series_id] = latest[-1].value
except Exception:
pass
except Exception:
pass
return _macro
# Hard cap: don't let Oracle hangs block trading for more than 8s
try: try:
from libs.oracle_client import FredService, OracleClient as _OC macro = await _asyncio.wait_for(_fetch_price_bars(), timeout=8.0)
async with _OC(base_url=self._detector._oracle_url) as fred_client: except _asyncio.TimeoutError:
fred_svc = FredService(fred_client) logger.warning("paper_engine_macro_fetch_timeout")
for series_id in ("VIXCLS", "BAMLH0A0HYM2"): macro = {}
try:
resp = await fred_svc.get_observations(series_id, start=start.isoformat(), end=date.isoformat())
if resp.observations:
latest = [o for o in resp.observations if o.value is not None]
if latest:
macro[series_id] = latest[-1].value
except Exception:
pass
except Exception:
pass
return macro return macro

Loading…
Cancel
Save