@ -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_ position s_final) ,
drawdown_pct = drawdown_pct , open_position_count = len ( session_ symbol s_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