@ -37,6 +37,7 @@ from apps.orb_trader.state import ORBStateManager
log = logging . getLogger ( __name__ )
log = logging . getLogger ( __name__ )
_ET = ZoneInfo ( " America/New_York " )
_ET = ZoneInfo ( " America/New_York " )
_ACCOUNT_CIRCUIT_BREAKER_PCT = 25.0 # halt if equity drops >25% from peak
class ORBTradingEngine :
class ORBTradingEngine :
@ -126,7 +127,11 @@ class ORBTradingEngine:
universe_source = getattr ( self . _params , " _universe_source " , " midlarge " )
universe_source = getattr ( self . _params , " _universe_source " , " midlarge " )
universe_symbols_file = getattr ( self . _params , " _universe_symbols_file " , None )
universe_symbols_file = getattr ( self . _params , " _universe_symbols_file " , None )
tickers = load_universe ( universe_source , universe_symbols_file )
tickers = load_universe ( universe_source , universe_symbols_file )
self . _log ( f " Pre-screen: { len ( tickers ) } tickers — fetching daily bars " )
# Always include the regime ticker so regime/breadth filters have data
regime_ticker = getattr ( self . _params , " market_regime_ticker " , None ) or " QQQ "
fetch_tickers = list ( dict . fromkeys ( [ regime_ticker ] + tickers ) ) # deduplicate, regime first
self . _log ( f " Pre-screen: { len ( tickers ) } tickers (+ { regime_ticker } ) — fetching daily bars " )
today = dt . date . fromisoformat ( date_str )
today = dt . date . fromisoformat ( date_str )
bars_end = self . _last_trading_day ( today )
bars_end = self . _last_trading_day ( today )
@ -134,8 +139,8 @@ class ORBTradingEngine:
raw_bars : dict [ str , list ] = { }
raw_bars : dict [ str , list ] = { }
chunk_size = 200
chunk_size = 200
for i in range ( 0 , len ( tickers) , chunk_size ) :
for i in range ( 0 , len ( fetch_ tickers) , chunk_size ) :
chunk = tickers[ i : i + chunk_size ]
chunk = fetch_ tickers[ i : i + chunk_size ]
try :
try :
raw_bars . update ( self . _broker . get_bars ( chunk , start , bars_end ) )
raw_bars . update ( self . _broker . get_bars ( chunk , start , bars_end ) )
except Exception as e :
except Exception as e :
@ -190,6 +195,56 @@ class ORBTradingEngine:
self . _session . session_id , date_str , phase = " orb_detection "
self . _session . session_id , date_str , phase = " orb_detection "
)
)
# Rolling loss filter: skip day if recent N-day equity return is below threshold
roll_days = getattr ( self . _params , " rolling_loss_days " , None )
roll_thresh = getattr ( self . _params , " rolling_loss_threshold " , None )
if roll_days is not None and roll_thresh is not None :
snapshots = self . _state . list_snapshots ( self . _session . session_id )
past = [ s for s in snapshots if s [ " date " ] < date_str ]
if len ( past ) > = roll_days :
window = past [ - roll_days : ]
rolling_pnl = sum ( s [ " daily_pnl " ] for s in window )
sizing_base = self . _session . initial_equity # daily_budget_reset mode
if sizing_base > 0 and rolling_pnl / sizing_base < roll_thresh :
self . _log (
f " Rolling loss filter triggered ( { rolling_pnl / sizing_base : .2% } "
f " < { roll_thresh : .2% } ) — skipping today "
)
self . _state . update_daily_state (
self . _session . session_id , date_str , phase = " done "
)
return {
" universe_size " : 0 ,
" daily_bars " : 0 ,
" intraday_bars " : 0 ,
" orb_candidates " : 0 ,
" long " : 0 ,
" short " : 0 ,
" skip_reason " : " rolling_loss " ,
}
# Account-level circuit breaker: halt if equity has dropped >25% from peak
equity_now = self . _get_equity ( )
peak_eq = self . _state . get_peak_equity (
self . _session . session_id , self . _session . initial_equity
)
if peak_eq > 0 :
account_dd_pct = ( peak_eq - equity_now ) / peak_eq * 100
if account_dd_pct > = _ACCOUNT_CIRCUIT_BREAKER_PCT :
self . _log (
f " CIRCUIT BREAKER: account drawdown { account_dd_pct : .1f } % "
f " >= { _ACCOUNT_CIRCUIT_BREAKER_PCT } % — session halted "
)
self . _state . set_session_status ( self . _session . session_id , " paused " )
self . _state . update_daily_state (
self . _session . session_id , date_str , phase = " done "
)
return {
" universe_size " : 0 , " daily_bars " : 0 , " intraday_bars " : 0 ,
" orb_candidates " : 0 , " long " : 0 , " short " : 0 ,
" skip_reason " : " circuit_breaker " ,
}
# ── Determine intraday_tickers: use pre-screen cache or fetch daily bars ─
# ── Determine intraday_tickers: use pre-screen cache or fetch daily bars ─
if self . _enrichment and self . _pre_screened_tickers is not None :
if self . _enrichment and self . _pre_screened_tickers is not None :
# Pre-screen already ran — skip daily bars fetch
# Pre-screen already ran — skip daily bars fetch
@ -204,7 +259,9 @@ class ORBTradingEngine:
universe_source = getattr ( self . _params , " _universe_source " , " midlarge " )
universe_source = getattr ( self . _params , " _universe_source " , " midlarge " )
universe_symbols_file = getattr ( self . _params , " _universe_symbols_file " , None )
universe_symbols_file = getattr ( self . _params , " _universe_symbols_file " , None )
tickers = load_universe ( universe_source , universe_symbols_file )
tickers = load_universe ( universe_source , universe_symbols_file )
self . _log ( f " Universe: { len ( tickers ) } tickers — fetching daily bars " )
regime_ticker = getattr ( self . _params , " market_regime_ticker " , None ) or " QQQ "
fetch_tickers = list ( dict . fromkeys ( [ regime_ticker ] + tickers ) )
self . _log ( f " Universe: { len ( tickers ) } tickers (+ { regime_ticker } ) — fetching daily bars " )
today = dt . date . fromisoformat ( date_str )
today = dt . date . fromisoformat ( date_str )
bars_end = self . _last_trading_day ( today )
bars_end = self . _last_trading_day ( today )
@ -212,8 +269,8 @@ class ORBTradingEngine:
raw_bars : dict [ str , list ] = { }
raw_bars : dict [ str , list ] = { }
chunk_size = 200
chunk_size = 200
for i in range ( 0 , len ( tickers) , chunk_size ) :
for i in range ( 0 , len ( fetch_ tickers) , chunk_size ) :
chunk = tickers[ i : i + chunk_size ]
chunk = fetch_ tickers[ i : i + chunk_size ]
try :
try :
raw_bars . update ( self . _broker . get_bars ( chunk , start , bars_end ) )
raw_bars . update ( self . _broker . get_bars ( chunk , start , bars_end ) )
except Exception as e :
except Exception as e :
@ -274,6 +331,81 @@ class ORBTradingEngine:
if intraday_count == 0 :
if intraday_count == 0 :
self . _log ( " WARNING: no intraday bars fetched — zero candidates will be produced " )
self . _log ( " WARNING: no intraday bars fetched — zero candidates will be produced " )
# Patch today_open in enrichment with actual first-bar open from intraday data.
# The pre_screen synthetic row uses prev_close as today_open (gap=0), which breaks
# market_regime_spy_threshold and breadth filters. Overwrite with real opening price.
for ticker , ticker_bars in bars_by_ticker . items ( ) :
if not ticker_bars :
continue
first_bar = ticker_bars [ 0 ]
real_open = first_bar . get ( " open " )
if real_open and ticker in self . _enrichment :
if date_str in self . _enrichment [ ticker ] :
self . _enrichment [ ticker ] [ date_str ] [ " today_open " ] = real_open
else :
# Fallback: find the entry that was created for this date
for d in sorted ( self . _enrichment [ ticker ] . keys ( ) , reverse = True ) :
if d < = date_str :
# Create a date_str entry inheriting from latest
import copy
self . _enrichment [ ticker ] [ date_str ] = copy . copy (
self . _enrichment [ ticker ] [ d ]
)
self . _enrichment [ ticker ] [ date_str ] [ " today_open " ] = real_open
break
# Market regime check (mirrors simulate_day:1678-1693)
regime_thresh = getattr ( self . _params , " market_regime_spy_threshold " , None )
if regime_thresh is not None :
regime_ticker = getattr ( self . _params , " market_regime_ticker " , None ) or " QQQ "
regime_enrich = self . _enrichment . get ( regime_ticker , { } ) . get ( date_str , { } )
regime_prev_close = regime_enrich . get ( " prev_close " )
regime_today_open = regime_enrich . get ( " today_open " )
if regime_prev_close and regime_today_open and regime_prev_close > 0 :
regime_gap = ( regime_today_open - regime_prev_close ) / regime_prev_close
if regime_gap < regime_thresh :
self . _log (
f " Regime filter: { regime_ticker } gap { regime_gap : .3% } "
f " < { regime_thresh : .3% } — skipping today "
)
self . _state . update_daily_state (
self . _session . session_id , date_str , phase = " done "
)
return {
" universe_size " : intraday_count , " daily_bars " : daily_bars_count ,
" intraday_bars " : intraday_count , " orb_candidates " : 0 ,
" long " : 0 , " short " : 0 , " skip_reason " : " market_regime " ,
}
# Breadth filter (mirrors simulate_day:1706-1727)
min_breadth = getattr ( self . _params , " min_candidate_breadth " , None )
if min_breadth is not None :
pos_gap_count = 0
total_with_data = 0
for ticker in bars_by_ticker :
t_enrich = self . _enrichment . get ( ticker , { } ) . get ( date_str , { } )
prev_c = t_enrich . get ( " prev_close " )
today_o = t_enrich . get ( " today_open " )
if prev_c and today_o and prev_c > 0 :
total_with_data + = 1
if today_o > prev_c :
pos_gap_count + = 1
if total_with_data > 0 :
breadth_ratio = pos_gap_count / total_with_data
if breadth_ratio < min_breadth :
self . _log (
f " Breadth filter: { breadth_ratio : .1% } positive gaps "
f " < { min_breadth : .1% } — skipping today "
)
self . _state . update_daily_state (
self . _session . session_id , date_str , phase = " done "
)
return {
" universe_size " : intraday_count , " daily_bars " : daily_bars_count ,
" intraday_bars " : intraday_count , " orb_candidates " : 0 ,
" long " : 0 , " short " : 0 , " skip_reason " : " breadth " ,
}
from libs . intraday . orb_simulator import compute_orb_candidates
from libs . intraday . orb_simulator import compute_orb_candidates
self . _candidates = compute_orb_candidates (
self . _candidates = compute_orb_candidates (
bars_by_ticker = bars_by_ticker ,
bars_by_ticker = bars_by_ticker ,
@ -368,7 +500,7 @@ class ORBTradingEngine:
breakout_level = orb_bar [ " high " ] if direction == " bullish " else orb_bar [ " low " ]
breakout_level = orb_bar [ " high " ] if direction == " bullish " else orb_bar [ " low " ]
# Check if already traded today
# Check if already traded today or at max simultaneous positions
open_positions = self . _state . get_open_positions (
open_positions = self . _state . get_open_positions (
self . _session . session_id , date_str
self . _session . session_id , date_str
)
)
@ -377,6 +509,10 @@ class ORBTradingEngine:
self . _session . session_id , date_str , ticker , " filled "
self . _session . session_id , date_str , ticker , " filled "
)
)
continue
continue
max_sim = getattr ( self . _params , " max_simultaneous_entries " , None )
if max_sim is not None and len ( open_positions ) > = max_sim :
still_pending . append ( cand )
continue
# Check breakout using real-time snapshot price
# Check breakout using real-time snapshot price
snap = snapshots . get ( ticker )
snap = snapshots . get ( ticker )
@ -399,11 +535,12 @@ class ORBTradingEngine:
still_pending . append ( cand )
still_pending . append ( cand )
continue
continue
risk_dollars = equity * self . _params . risk_per_trade_pct
sizing_capital = self . _compute_sizing_capital ( equity )
risk_dollars = sizing_capital * self . _params . risk_per_trade_pct
shares_from_risk = risk_dollars / stop_distance
shares_from_risk = risk_dollars / stop_distance
entry_price_est = max ( breakout_level , current_price )
entry_price_est = max ( breakout_level , current_price )
max_shares_by_capital = ( equity * self . _params . max_position_pct ) / entry_price_est
max_shares_by_capital = ( sizing_capital * self . _params . max_position_pct ) / entry_price_est
shares = int ( min ( shares_from_risk , max_shares_by_capital ) )
shares = int ( min ( shares_from_risk , max_shares_by_capital ) )
if shares < = 0 :
if shares < = 0 :
self . _log ( f " { ticker } : shares=0 after sizing — skipping " )
self . _log ( f " { ticker } : shares=0 after sizing — skipping " )
@ -604,7 +741,13 @@ class ORBTradingEngine:
# Update trailing AFTER stop check
# Update trailing AFTER stop check
if trailing_active :
if trailing_active :
if use_atr_trail :
if use_atr_trail :
candidate = peak_price - atr * self . _params . trailing_stop_atr_multiplier
tighten_r = getattr ( self . _params , " trailing_tighten_at_r " , None )
tight_mult = getattr ( self . _params , " trailing_stop_atr_multiplier_tight " , 0.0 )
if ( tighten_r is not None and current_r > = tighten_r and tight_mult > 0 ) :
atr_mult = tight_mult
else :
atr_mult = self . _params . trailing_stop_atr_multiplier
candidate = peak_price - atr * atr_mult
else :
else :
candidate = max ( bar_low , current_stop )
candidate = max ( bar_low , current_stop )
if candidate > current_stop :
if candidate > current_stop :
@ -627,7 +770,13 @@ class ORBTradingEngine:
if trailing_active :
if trailing_active :
if use_atr_trail :
if use_atr_trail :
candidate = peak_price + atr * self . _params . trailing_stop_atr_multiplier
tighten_r = getattr ( self . _params , " trailing_tighten_at_r " , None )
tight_mult = getattr ( self . _params , " trailing_stop_atr_multiplier_tight " , 0.0 )
if ( tighten_r is not None and current_r > = tighten_r and tight_mult > 0 ) :
atr_mult = tight_mult
else :
atr_mult = self . _params . trailing_stop_atr_multiplier
candidate = peak_price + atr * atr_mult
else :
else :
candidate = min ( bar_high , current_stop )
candidate = min ( bar_high , current_stop )
if candidate < current_stop :
if candidate < current_stop :
@ -840,6 +989,61 @@ class ORBTradingEngine:
self . _session . session_id , pos . date , pos . ticker
self . _session . session_id , pos . date , pos . ticker
)
)
def _compute_sizing_capital ( self , equity : float ) - > float :
""" Replicate backtest sizing_capital formula: governor + streak multiplier.
Mirrors libs / intraday / orb_simulator . py : 2141 - 2187.
V23 uses daily_budget_reset = True : base sizing = initial_equity ( not equity ) .
This matches the backtest 단리 mode where each day starts from $ 10 k .
"""
# daily_budget_reset: fixed daily budget matches V23 backtest 단리 mode
daily_reset = getattr ( self . _params , " daily_budget_reset " , False )
sizing = self . _session . initial_equity if daily_reset else equity
# Drawdown governor: scale down when equity drops below peak
gov_thresh = getattr ( self . _params , " drawdown_governor_threshold " , None )
gov_min = getattr ( self . _params , " drawdown_governor_min_scale " , 0.30 )
if gov_thresh is not None :
peak_equity = self . _state . get_peak_equity (
self . _session . session_id , self . _session . initial_equity
)
if peak_equity > 0 :
dd_pct = ( peak_equity - equity ) / peak_equity
if dd_pct > gov_thresh :
dd_excess = dd_pct - gov_thresh
governor_scale = max (
gov_min ,
1.0 - ( 1.0 - gov_min ) * min ( dd_excess / gov_thresh , 1.0 ) ,
)
sizing = sizing * governor_scale
# Streak sizing: amplify after consecutive wins, reduce after consecutive losses.
# list_trades returns DESC (newest first) — outcomes[0] = most recent trade.
win_bonus = getattr ( self . _params , " streak_sizing_win_bonus " , None )
loss_penalty = getattr ( self . _params , " streak_sizing_loss_penalty " , None )
streak_max = getattr ( self . _params , " streak_sizing_max " , 2.5 )
streak_min = getattr ( self . _params , " streak_sizing_min " , 0.5 )
if win_bonus is not None or loss_penalty is not None :
trades = self . _state . list_trades ( self . _session . session_id )
if trades :
outcomes = [ t [ " pnl " ] > 0 for t in trades ] # newest first
is_winning = outcomes [ 0 ] # most recent outcome
streak_len = 0
for o in outcomes : # count from newest
if o == is_winning :
streak_len + = 1
else :
break
streak_mult = 1.0
if is_winning and win_bonus is not None :
streak_mult = 1.0 + streak_len * win_bonus
elif not is_winning and loss_penalty is not None :
streak_mult = 1.0 - streak_len * loss_penalty
streak_mult = max ( streak_min , min ( streak_max , streak_mult ) )
sizing = sizing * streak_mult
return sizing
def _rebuild_pending_candidates ( self , date_str : str ) - > list [ dict ] :
def _rebuild_pending_candidates ( self , date_str : str ) - > list [ dict ] :
""" Reconstruct pending candidates from DB (after server restart). """
""" Reconstruct pending candidates from DB (after server restart). """
db_cands = self . _state . list_candidates ( self . _session . session_id , date_str )
db_cands = self . _state . list_candidates ( self . _session . session_id , date_str )