Fix lookback entry NO_PROGRESS bypass: screen candidates before entry

룩백 진입 시 NO_PROGRESS 체크 기간(lookback_days_elapsed >= np_days)이 이미
지난 후보를 _process_entries 호출 전에 필터링. 현재 종가가 원래 진입가 기준
progress threshold 미달이면 진입 거부.

백테스트와 live paper trader 간 행동 불일치 원인: 세션 생성일이 이벤트 발생일
이후인 경우 룩백 기능이 NO_PROGRESS 조건을 무시하고 진입, 백테스트가
청산했을 포지션을 그대로 보유하는 문제 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 3 months ago
parent 8771667e61
commit 4067dab700

@ -618,7 +618,7 @@ class PaperTradingEngine:
open_pos, open_pos,
bar, bar,
trailing_model=effective_exec.trailing_model, trailing_model=effective_exec.trailing_model,
warmup_days=effective_exec.trailing_warmup_days, warmup_days=self._config.execution.trailing_warmup_days,
) )
ss.current_stop = open_pos.current_stop ss.current_stop = open_pos.current_stop
ss.peak_price = open_pos.peak_price ss.peak_price = open_pos.peak_price
@ -788,6 +788,11 @@ class PaperTradingEngine:
# Also override event_close so reaction_close engines use today's # Also override event_close so reaction_close engines use today's
# price (not the historical reaction-day close) for entry/sizing. # price (not the historical reaction-day close) for entry/sizing.
row["event_close"] = bar.close row["event_close"] = bar.close
# NO_PROGRESS window이 이미 지난 후보를 진입 전에 제거
lookback_rows, _lb_np_rejected = self._screen_lookback_no_progress(
lookback_rows, latest_bars
)
rejected.extend(_lb_np_rejected)
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(
@ -1046,6 +1051,12 @@ class PaperTradingEngine:
}) })
continue continue
fill_price = verified.filled_avg_price or plan.entry_price_limit fill_price = verified.filled_avg_price or plan.entry_price_limit
logger.info(
"paper_engine_buy_filled",
symbol=candidate.symbol, qty=plan.shares,
fill_price=round(fill_price, 4), order_id=order.id,
session=session_id, category="order",
)
# Save local strategy state # Save local strategy state
self._state.save_strategy_state( self._state.save_strategy_state(
@ -1226,6 +1237,12 @@ class PaperTradingEngine:
}) })
continue continue
fill_price = verified.filled_avg_price or plan.entry_price_limit fill_price = verified.filled_avg_price or plan.entry_price_limit
logger.info(
"paper_engine_buy_filled",
symbol=candidate.symbol, qty=plan.shares,
fill_price=round(fill_price, 4), order_id=order.id,
session=session_id, category="order",
)
self._state.save_strategy_state( self._state.save_strategy_state(
session_id, session_id,
@ -1870,6 +1887,50 @@ class PaperTradingEngine:
logger.warning("parking_sell_for_event_failed", error=str(exc) or repr(exc), exc_type=type(exc).__name__, symbol=sym, qty=shares_to_sell) logger.warning("parking_sell_for_event_failed", error=str(exc) or repr(exc), exc_type=type(exc).__name__, symbol=sym, qty=shares_to_sell)
return False return False
def _session_strategy_mv_unreal(
self, session_id: str, alpaca_positions: "list[Any]"
) -> "tuple[float, float]":
"""Per-session market value and unrealized P&L for strategy positions only.
Uses per-session share counts from the trades table so that multiple
sessions holding the same symbol each see only their own exposure.
Parking positions are excluded here and handled by _parking_position_value.
"""
price_map: dict[str, float] = {
p.symbol: float(p.current_price)
for p in alpaca_positions
if p.current_price is not None
}
strategy_symbols = {
ss.symbol for ss in self._state.get_open_strategy_states(session_id)
}
if not strategy_symbols:
return 0.0, 0.0
sym_shares: dict[str, int] = {}
sym_cost: dict[str, float] = {}
for trade in self._state.list_trades(session_id):
if trade.get("exit_date") is not None:
continue
sym = trade["symbol"]
if sym not in strategy_symbols:
continue
shares = trade.get("shares") or 0
entry_price = float(trade.get("entry_price") or 0.0)
sym_shares[sym] = sym_shares.get(sym, 0) + shares
sym_cost[sym] = sym_cost.get(sym, 0.0) + shares * entry_price
total_mv = 0.0
total_unreal = 0.0
for sym, shares in sym_shares.items():
if shares <= 0:
continue
avg_entry = sym_cost[sym] / shares
cur = price_map.get(sym, avg_entry)
total_mv += shares * cur
total_unreal += shares * (cur - avg_entry)
return total_mv, total_unreal
def _parking_position_value( def _parking_position_value(
self, session_id: str, alpaca_positions: "list[Any]" self, session_id: str, alpaca_positions: "list[Any]"
) -> "tuple[float, float]": ) -> "tuple[float, float]":
@ -1911,13 +1972,9 @@ class PaperTradingEngine:
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 live positions (same session symbols only) # Derive from live positions using per-session share counts
session_symbols = {
ss.symbol for ss in self._state.get_open_strategy_states(session_id)
}
alpaca_positions = self._broker.list_positions() alpaca_positions = self._broker.list_positions()
session_mv = sum(p.market_value for p in alpaca_positions if p.symbol in session_symbols) session_mv, session_unreal = self._session_strategy_mv_unreal(session_id, alpaca_positions)
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, parking_unreal = self._parking_position_value(session_id, alpaca_positions) parking_mv, parking_unreal = self._parking_position_value(session_id, alpaca_positions)
@ -2250,6 +2307,11 @@ class PaperTradingEngine:
# Also override event_close so reaction_close engines use today's # Also override event_close so reaction_close engines use today's
# price (not the historical reaction-day close) for entry/sizing. # price (not the historical reaction-day close) for entry/sizing.
row["event_close"] = bar.close row["event_close"] = bar.close
# NO_PROGRESS window이 이미 지난 후보를 진입 전에 제거
lookback_rows, _lb_np_rejected = self._screen_lookback_no_progress(
lookback_rows, latest_bars
)
lookback_rejected.extend(_lb_np_rejected)
if lookback_rows: if lookback_rows:
macro_data_lb = await self._fetch_macro(today) macro_data_lb = await self._fetch_macro(today)
@ -2436,6 +2498,74 @@ class PaperTradingEngine:
max_mhd = _compute_max_effective_mhd(self._config) max_mhd = _compute_max_effective_mhd(self._config)
return today - dt.timedelta(days=max_mhd * 2) return today - dt.timedelta(days=max_mhd * 2)
def _screen_lookback_no_progress(
self,
lookback_rows: list[dict[str, Any]],
latest_bars: dict,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""룩백 후보 중 NO_PROGRESS가 이미 발동됐어야 할 항목을 사전 제거.
lookback_days_elapsed >= effective_np_days인 경우(NO_PROGRESS 체크 기간 이미 경과),
원래 진입가 기준 progress threshold를 현재 종가가 하회하면 진입 거부.
백테스트와의 행동 일치를 위해 필요.
"""
kept: list[dict[str, Any]] = []
rejected: list[dict[str, Any]] = []
for row in lookback_rows:
elapsed = int(row.get("lookback_days_elapsed", 0))
np_days_raw = row.get("engine_early_failure_no_progress_days")
np_days = int(np_days_raw) if np_days_raw is not None else (
self._config.execution.early_failure_no_progress_days
)
np_r_raw = row.get("engine_early_failure_no_progress_r")
np_r = float(np_r_raw) if np_r_raw is not None else (
self._config.execution.early_failure_no_progress_r
)
if np_days is None or np_r is None or elapsed < np_days:
kept.append(row)
continue
# elapsed >= np_days: NO_PROGRESS 체크 기간이 이미 지남.
# 현재 종가가 progress threshold 미달이면 백테스트와 동일하게 진입 거부.
sym = str(row.get("symbol", "")).upper()
original_entry = row.get("lookback_original_entry_price_est") or row.get("entry_price_est")
atr_14 = row.get("atr_14")
stop_atr_mult = float(
row.get("engine_stop_atr_multiplier") or self._config.risk.stop_atr_multiplier or 3.0
)
current_bar = latest_bars.get(sym)
if not (original_entry and atr_14 and current_bar):
kept.append(row)
continue
stop_dist = stop_atr_mult * float(atr_14)
if stop_dist <= 0:
kept.append(row)
continue
progress_price = float(original_entry) + float(np_r) * stop_dist
current_close = float(current_bar.close)
if current_close < progress_price:
logger.info(
"lookback_no_progress_screen_rejected",
symbol=sym,
lookback_days_elapsed=elapsed,
np_days=np_days,
current_close=round(current_close, 4),
progress_price=round(progress_price, 4),
original_entry=round(float(original_entry), 4),
)
rejected.append(row)
else:
kept.append(row)
return kept, rejected
@staticmethod @staticmethod
def _is_same_day_event(row: dict[str, Any]) -> bool: def _is_same_day_event(row: dict[str, Any]) -> bool:
"""event_date == reaction_date 이면 same-day (종가 진입) 이벤트.""" """event_date == reaction_date 이면 same-day (종가 진입) 이벤트."""
@ -2512,7 +2642,7 @@ class PaperTradingEngine:
update_trailing_stop( update_trailing_stop(
open_pos, bar, open_pos, bar,
trailing_model=effective_exec.trailing_model, trailing_model=effective_exec.trailing_model,
warmup_days=effective_exec.trailing_warmup_days, warmup_days=self._config.execution.trailing_warmup_days,
) )
ss.current_stop = open_pos.current_stop ss.current_stop = open_pos.current_stop
ss.peak_price = open_pos.peak_price ss.peak_price = open_pos.peak_price
@ -3024,13 +3154,19 @@ class PaperTradingEngine:
rejected.append({"symbol": candidate.symbol, "event_type": candidate.event_type, "score": candidate.score, "reason": fill_fail_reason}) rejected.append({"symbol": candidate.symbol, "event_type": candidate.event_type, "score": candidate.score, "reason": fill_fail_reason})
continue continue
fill_price = verified.filled_avg_price or plan.entry_price_limit fill_price = verified.filled_avg_price or plan.entry_price_limit
logger.info(
"paper_engine_buy_filled",
symbol=candidate.symbol, qty=plan.shares,
fill_price=round(fill_price, 4), order_id=order.id,
session=session_id, category="order",
)
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
# Lookback entries start at days_held=0 (actual hold time from today), # Lookback entries: days_held = trading days elapsed since the event,
# not lookback_days_elapsed. The signal validity was already checked # so trailing/NO_PROGRESS counters match the backtest path
# (elapsed < mhd, min_remaining_days) before entry was allowed. # (apps/backtester/run.py:2930).
initial_days_held = 0 initial_days_held = int(candidate.features.get("lookback_days_elapsed", 0))
self._state.save_strategy_state( self._state.save_strategy_state(
session_id, session_id,
StrategyStateRow( StrategyStateRow(
@ -3102,14 +3238,15 @@ class PaperTradingEngine:
) -> dict[str, Any]: ) -> dict[str, Any]:
"""일일 스냅샷 저장 + summary dict 반환.""" """일일 스냅샷 저장 + summary dict 반환."""
session_id = self._session.session_id session_id = self._session.session_id
# 세션 소유 포지션만 집계 (Alpaca 전체 계좌가 아닌 세션 기준) # 세션 소유 포지션만 집계 — per-session shares를 trades 테이블에서 읽어
# 여러 세션이 같은 심볼을 보유할 때 cross-session 오염 방지
alpaca_positions_final = self._broker.list_positions() alpaca_positions_final = self._broker.list_positions()
session_symbols_final = { session_symbols_final = {
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)
} }
session_positions_final = [p for p in alpaca_positions_final if p.symbol in session_symbols_final] session_market_value_final, session_unrealized_pl_final = self._session_strategy_mv_unreal(
session_market_value_final = sum(p.market_value for p in session_positions_final) session_id, alpaca_positions_final
session_unrealized_pl_final = sum(p.unrealized_pl for p in session_positions_final) )
# Include parking position in MV and unrealized P&L # Include parking position in MV and unrealized P&L
parking_mv_final, parking_unreal_final = self._parking_position_value( parking_mv_final, parking_unreal_final = self._parking_position_value(
@ -3140,7 +3277,7 @@ class PaperTradingEngine:
session_id=session_id, date=today.isoformat(), equity=session_equity_final, session_id=session_id, date=today.isoformat(), equity=session_equity_final,
cash=session_cash_final, market_value=session_market_value_final, cash=session_cash_final, market_value=session_market_value_final,
daily_pnl=session_equity_final - prev_equity, total_pnl=total_pnl, daily_pnl=session_equity_final - prev_equity, total_pnl=total_pnl,
drawdown_pct=drawdown_pct, open_position_count=len(session_positions_final), drawdown_pct=drawdown_pct, open_position_count=len(session_symbols_final),
) )
) )
# Kill switch check after drawdown computation # Kill switch check after drawdown computation
@ -3177,6 +3314,11 @@ class PaperTradingEngine:
PositionStatus.PARTIALLY_EXITED if ss.status == "partial" PositionStatus.PARTIALLY_EXITED if ss.status == "partial"
else PositionStatus.ENTERED else PositionStatus.ENTERED
) )
# Use plan.shares (session-specific) not alpaca_pos.qty (all-sessions total).
# In a shared Alpaca account multiple sessions can hold the same symbol;
# alpaca_pos.qty is the combined total, which would cause simulate_exit to
# close more shares than this session owns and sell other sessions' positions.
session_shares = min(plan.shares, alpaca_pos.qty)
return OpenPosition( return OpenPosition(
position_id=ss.order_id or ss.symbol, position_id=ss.order_id or ss.symbol,
plan=plan, plan=plan,
@ -3186,8 +3328,8 @@ class PaperTradingEngine:
current_stop=ss.current_stop, current_stop=ss.current_stop,
target_price=ss.target_price, target_price=ss.target_price,
peak_price=ss.peak_price, peak_price=ss.peak_price,
shares_open=alpaca_pos.qty, shares_open=session_shares,
shares_total=alpaca_pos.qty, shares_total=session_shares,
days_held=ss.days_held, days_held=ss.days_held,
status=pos_status, status=pos_status,
) )
@ -3367,8 +3509,10 @@ class PaperTradingEngine:
latest = [o for o in resp.observations if o.value is not None] latest = [o for o in resp.observations if o.value is not None]
if latest: if latest:
_macro[series_id] = latest[-1].value _macro[series_id] = latest[-1].value
except Exception: except Exception as _vix_exc:
pass logger.warning("paper_engine_vix_fred_unavailable",
series=series_id, err=str(_vix_exc),
category="macro")
except Exception: except Exception:
pass pass
return _macro return _macro

Loading…
Cancel
Save