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,
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.selector import select_candidates
from libs.common.logging import get_logger
@ -338,8 +338,14 @@ class PaperTradingEngine:
alpaca_symbols = {p.symbol for p in alpaca_positions}
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)
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(
"paper_engine_orphaned_position",
@ -649,6 +655,58 @@ class PaperTradingEngine:
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
candidate_rows = await self._detector.get_candidates_for_date(
today, self._config, convention="reaction_close"
@ -763,6 +821,7 @@ class PaperTradingEngine:
portfolio_state=candidate_portfolio_state,
open_positions=open_positions,
config=self._config,
execution_config=build_effective_execution_config(candidate, self._config),
cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data,
engine_daily_new_risk_used=engine_risk_used,
@ -790,6 +849,7 @@ class PaperTradingEngine:
portfolio_state=candidate_portfolio_state,
open_positions=open_positions,
config=self._config,
execution_config=build_effective_execution_config(candidate, self._config),
cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data,
engine_daily_new_risk_used=engine_risk_used,
@ -984,6 +1044,7 @@ class PaperTradingEngine:
portfolio_state=candidate_portfolio_state,
open_positions=open_positions,
config=self._config,
execution_config=build_effective_execution_config(candidate, self._config),
cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data,
)
@ -1411,10 +1472,10 @@ class PaperTradingEngine:
self._state.close_parking_state(session_id)
bars_now = self._broker.get_latest_bars([sym])
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,
symbol=sym,
engine_id=None,
engine_id="parking",
capital_bucket_id=None,
entry_date=parking_st["entry_date"],
exit_date=today.isoformat(),
@ -1453,10 +1514,10 @@ class PaperTradingEngine:
self._state.close_parking_state(session_id)
bars_now = self._broker.get_latest_bars([sym])
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,
symbol=sym,
engine_id=None,
engine_id="parking",
capital_bucket_id=None,
entry_date=parking_st["entry_date"],
exit_date=today.isoformat(),
@ -1529,10 +1590,10 @@ class PaperTradingEngine:
# Record trade
bars = self._broker.get_latest_bars([sym])
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,
symbol=sym,
engine_id=None,
engine_id="parking",
capital_bucket_id=None,
entry_date=parking_st["entry_date"],
exit_date=today.isoformat(),
@ -1578,18 +1639,36 @@ class PaperTradingEngine:
self._broker.close_position(sym, qty=shares_to_sell)
time.sleep(3)
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:
self._state.close_parking_state(session_id)
else:
avg = float(parking_st.get("avg_price", cur_price))
self._state.save_parking_state(
session_id, sym,
parking_st.get("entry_date", today),
raw_entry_date,
new_qty, avg, new_qty * avg,
peak_price=float(parking_st.get("peak_price", 0) or avg),
gate_in_sgov=int(parking_st.get("gate_in_sgov", 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(
"parking_partial_sell_for_event",
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
on the same broker account each see their own isolated balance.
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
if isinstance(self._broker, MockBroker):
acct = self._broker.get_account()
return float(acct.equity), float(acct.cash)
# Derive from session snapshots + open positions
# Derive from live positions (same session symbols only)
session_symbols = {
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)
# 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
snapshots = self._state.list_snapshots(session_id)
if snapshots:
session_equity = snapshots[-1]["equity"]
else:
total_realized = sum(
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
# Compute equity from first principles (same as _finalize_day) so intraday
# realized P&L (e.g. partial exits) is reflected. Using the stale daily
# snapshot underestimates cash when positions gain value within the day.
total_realized = sum(
(t.get("net_pnl") or 0.0) for t in self._state.list_trades(session_id)
)
session_equity = self._session.initial_equity + total_realized + session_unreal + parking_unreal
session_cash = max(0.0, session_equity - session_mv)
return session_equity, session_cash
@ -1741,6 +1823,10 @@ class PaperTradingEngine:
peak_price=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,
)
logger.info("parking_filled", symbol=sym, qty=qty, price=round(avg_price, 2))
return
logger.warning("parking_buy_timeout", symbol=sym, order_id=order.id)
@ -1864,7 +1950,14 @@ class PaperTradingEngine:
# ============================================================
parking_sold_today = False
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
# 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
# negative cash (cost = shares × current_price > shares × hist_price).
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:
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:
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, strategy_states,
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
alpaca_positions = self._broker.list_positions()
@ -2497,6 +2595,7 @@ class PaperTradingEngine:
plan = build_planned_order(
candidate=candidate, portfolio_state=candidate_portfolio_state,
open_positions=open_positions, config=self._config,
execution_config=build_effective_execution_config(candidate, self._config),
cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data,
engine_daily_new_risk_used=engine_risk_used if engine_cfg else 0.0,
@ -2535,6 +2634,7 @@ class PaperTradingEngine:
plan = build_planned_order(
candidate=candidate, portfolio_state=candidate_portfolio_state,
open_positions=open_positions, config=self._config,
execution_config=build_effective_execution_config(candidate, self._config),
cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data,
engine_daily_new_risk_used=engine_risk_used if engine_cfg else 0.0,
@ -2630,7 +2730,10 @@ class PaperTradingEngine:
else:
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(
session_id,
StrategyStateRow(
@ -2720,7 +2823,7 @@ class PaperTradingEngine:
# 세션 equity = initial_equity + 전체 실현 P&L + 현재 미실현 P&L (parking 포함)
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_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
return macro
import asyncio as _asyncio
from libs.oracle_client import OracleClient, PriceService
async with OracleClient(base_url=self._detector._oracle_url) as client:
svc = PriceService(client)
async def _fetch_price_bars() -> dict[str, Any]:
_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]]:
try:
resp = await svc.get_daily_bars(sym, start=start.isoformat(), end=date.isoformat())
return sym, resp.bars
except Exception:
return sym, []
async def _fetch_macro_sym(sym: str) -> tuple[str, list[Any]]:
try:
resp = await svc.get_daily_bars(sym, start=start.isoformat(), end=date.isoformat())
return sym, resp.bars
except Exception:
return sym, []
results = await __import__("asyncio").gather(
*(_fetch_macro_sym(sym) for sym in symbols)
)
results = await _asyncio.gather(
*(_fetch_macro_sym(sym) for sym in symbols)
)
macro = {}
for sym, bars in results:
if not bars:
continue
key_prefix = sym.lower()
closes = [float(b.close) for b in bars]
if closes:
macro[f"{key_prefix}_close"] = closes[-1]
if len(closes) >= 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
# Matches SnapshotStore._fetch_macro() which loads MacroObservation from DB
for sym, bars in results:
if not bars:
continue
key_prefix = sym.lower()
closes = [float(b.close) for b in bars]
if closes:
_macro[f"{key_prefix}_close"] = closes[-1]
if len(closes) >= 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
try:
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:
from libs.oracle_client import FredService, OracleClient as _OC
async with _OC(base_url=self._detector._oracle_url) 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
macro = await _asyncio.wait_for(_fetch_price_bars(), timeout=8.0)
except _asyncio.TimeoutError:
logger.warning("paper_engine_macro_fetch_timeout")
macro = {}
return macro

Loading…
Cancel
Save