Fix V23 live engine: regime filter, sizing fidelity, trailing tighten

- screener.py: add min_atr_pct/max_atr_pct filters to live_pre_screen
- engine.py: prepend regime ticker (QQQ) to bar fetch so regime filter works
- engine.py: add rolling loss + account circuit breaker at run_orb_detection
- engine.py: patch today_open from first 1-min bar so regime gap is real
- engine.py: explicit regime + breadth filter before compute_orb_candidates
- engine.py: max_simultaneous_entries guard in run_breakout_check
- engine.py: _compute_sizing_capital with daily_budget_reset (fixed $10k base),
  drawdown governor, and streak sizing (win bonus / loss penalty)
- engine.py: trailing_tighten_at_r in run_stop_check (tight multiplier at 2R)

Bug fixes in _compute_sizing_capital:
- streak direction: remove reversed() so outcomes[0] = newest trade
- daily reset: use initial_equity as base (not growing equity), matching V23

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent a59b71e46a
commit 190f7a47fa

@ -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 $10k.
"""
# 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)

@ -111,6 +111,7 @@ def live_pre_screen(
Filters (applied to the most recent enrichment entry before date_str): Filters (applied to the most recent enrichment entry before date_str):
- prev_close >= min_price (proxy for current price) - prev_close >= min_price (proxy for current price)
- atr_14 >= min_atr_14 - atr_14 >= min_atr_14
- atr_14/prev_close in [min_atr_pct, max_atr_pct] (V23 quality filter)
- avg_dollar_vol_30d >= min_avg_dollar_volume - avg_dollar_vol_30d >= min_avg_dollar_volume
Returns list of qualifying tickers (unsorted). Returns list of qualifying tickers (unsorted).
@ -135,6 +136,12 @@ def live_pre_screen(
continue continue
if atr_14 < params.min_atr_14: if atr_14 < params.min_atr_14:
continue continue
if prev_close > 0:
atr_ratio = atr_14 / prev_close
if params.min_atr_pct is not None and atr_ratio < params.min_atr_pct:
continue
if params.max_atr_pct is not None and atr_ratio > params.max_atr_pct:
continue
if avg_dollar_vol < params.min_avg_dollar_volume: if avg_dollar_vol < params.min_avg_dollar_volume:
continue continue

Loading…
Cancel
Save