From 27b8a49d77ccbb9ad3f26f29a66a85a9d57e7fbc Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Sat, 18 Apr 2026 10:03:38 -0700 Subject: [PATCH] Fix: paper trader ignores per-engine overrides in build_planned_order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/paper_trader/engine.py | 248 ++++++++++++++++++++++++++---------- 1 file changed, 180 insertions(+), 68 deletions(-) diff --git a/apps/paper_trader/engine.py b/apps/paper_trader/engine.py index 6eb59e4..190d679 100644 --- a/apps/paper_trader/engine.py +++ b/apps/paper_trader/engine.py @@ -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