diff --git a/apps/paper_trader/engine.py b/apps/paper_trader/engine.py index bba9d73..d64c8ac 100644 --- a/apps/paper_trader/engine.py +++ b/apps/paper_trader/engine.py @@ -618,7 +618,7 @@ class PaperTradingEngine: open_pos, bar, 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.peak_price = open_pos.peak_price @@ -788,6 +788,11 @@ class PaperTradingEngine: # 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 + # 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: macro_data_lb = await self._fetch_macro(today) lookback_entries, lookback_rejected = await self._process_entries( @@ -1046,6 +1051,12 @@ class PaperTradingEngine: }) continue 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 self._state.save_strategy_state( @@ -1226,6 +1237,12 @@ class PaperTradingEngine: }) continue 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( 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) 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( self, session_id: str, alpaca_positions: "list[Any]" ) -> "tuple[float, float]": @@ -1911,13 +1972,9 @@ class PaperTradingEngine: acct = self._broker.get_account() return float(acct.equity), float(acct.cash) - # Derive from live positions (same session symbols only) - session_symbols = { - ss.symbol for ss in self._state.get_open_strategy_states(session_id) - } + # Derive from live positions using per-session share counts alpaca_positions = self._broker.list_positions() - session_mv = sum(p.market_value 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) + session_mv, session_unreal = self._session_strategy_mv_unreal(session_id, alpaca_positions) # Include parking position so cash isn't over-stated 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 # price (not the historical reaction-day close) for entry/sizing. 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: macro_data_lb = await self._fetch_macro(today) @@ -2436,6 +2498,74 @@ class PaperTradingEngine: max_mhd = _compute_max_effective_mhd(self._config) 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 def _is_same_day_event(row: dict[str, Any]) -> bool: """event_date == reaction_date 이면 same-day (종가 진입) 이벤트.""" @@ -2512,7 +2642,7 @@ class PaperTradingEngine: update_trailing_stop( open_pos, bar, 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.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}) continue 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: fill_price = plan.entry_price_limit # MOC: actual price unknown until close - # 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 + # Lookback entries: days_held = trading days elapsed since the event, + # so trailing/NO_PROGRESS counters match the backtest path + # (apps/backtester/run.py:2930). + initial_days_held = int(candidate.features.get("lookback_days_elapsed", 0)) self._state.save_strategy_state( session_id, StrategyStateRow( @@ -3102,14 +3238,15 @@ class PaperTradingEngine: ) -> dict[str, Any]: """일일 스냅샷 저장 + summary dict 반환.""" session_id = self._session.session_id - # 세션 소유 포지션만 집계 (Alpaca 전체 계좌가 아닌 세션 기준) + # 세션 소유 포지션만 집계 — per-session shares를 trades 테이블에서 읽어 + # 여러 세션이 같은 심볼을 보유할 때 cross-session 오염 방지 alpaca_positions_final = self._broker.list_positions() session_symbols_final = { 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 = sum(p.market_value for p in session_positions_final) - session_unrealized_pl_final = sum(p.unrealized_pl for p in session_positions_final) + session_market_value_final, session_unrealized_pl_final = self._session_strategy_mv_unreal( + session_id, alpaca_positions_final + ) # Include parking position in MV and unrealized P&L 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, cash=session_cash_final, market_value=session_market_value_final, 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 @@ -3177,6 +3314,11 @@ class PaperTradingEngine: PositionStatus.PARTIALLY_EXITED if ss.status == "partial" 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( position_id=ss.order_id or ss.symbol, plan=plan, @@ -3186,8 +3328,8 @@ class PaperTradingEngine: current_stop=ss.current_stop, target_price=ss.target_price, peak_price=ss.peak_price, - shares_open=alpaca_pos.qty, - shares_total=alpaca_pos.qty, + shares_open=session_shares, + shares_total=session_shares, days_held=ss.days_held, status=pos_status, ) @@ -3367,8 +3509,10 @@ class PaperTradingEngine: 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 as _vix_exc: + logger.warning("paper_engine_vix_fred_unavailable", + series=series_id, err=str(_vix_exc), + category="macro") except Exception: pass return _macro