@ -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