diff --git a/apps/orb_trader/engine.py b/apps/orb_trader/engine.py index 9112121..500c760 100644 --- a/apps/orb_trader/engine.py +++ b/apps/orb_trader/engine.py @@ -37,6 +37,7 @@ from apps.orb_trader.state import ORBStateManager log = logging.getLogger(__name__) _ET = ZoneInfo("America/New_York") +_ACCOUNT_CIRCUIT_BREAKER_PCT = 25.0 # halt if equity drops >25% from peak class ORBTradingEngine: @@ -126,7 +127,11 @@ class ORBTradingEngine: universe_source = getattr(self._params, "_universe_source", "midlarge") universe_symbols_file = getattr(self._params, "_universe_symbols_file", None) 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) bars_end = self._last_trading_day(today) @@ -134,8 +139,8 @@ class ORBTradingEngine: raw_bars: dict[str, list] = {} chunk_size = 200 - for i in range(0, len(tickers), chunk_size): - chunk = tickers[i : i + chunk_size] + for i in range(0, len(fetch_tickers), chunk_size): + chunk = fetch_tickers[i : i + chunk_size] try: raw_bars.update(self._broker.get_bars(chunk, start, bars_end)) except Exception as e: @@ -190,6 +195,56 @@ class ORBTradingEngine: 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 ─ if self._enrichment and self._pre_screened_tickers is not None: # Pre-screen already ran — skip daily bars fetch @@ -204,7 +259,9 @@ class ORBTradingEngine: universe_source = getattr(self._params, "_universe_source", "midlarge") universe_symbols_file = getattr(self._params, "_universe_symbols_file", None) 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) bars_end = self._last_trading_day(today) @@ -212,8 +269,8 @@ class ORBTradingEngine: raw_bars: dict[str, list] = {} chunk_size = 200 - for i in range(0, len(tickers), chunk_size): - chunk = tickers[i : i + chunk_size] + for i in range(0, len(fetch_tickers), chunk_size): + chunk = fetch_tickers[i : i + chunk_size] try: raw_bars.update(self._broker.get_bars(chunk, start, bars_end)) except Exception as e: @@ -274,6 +331,81 @@ class ORBTradingEngine: if intraday_count == 0: 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 self._candidates = compute_orb_candidates( bars_by_ticker=bars_by_ticker, @@ -368,7 +500,7 @@ class ORBTradingEngine: 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( self._session.session_id, date_str ) @@ -377,6 +509,10 @@ class ORBTradingEngine: self._session.session_id, date_str, ticker, "filled" ) 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 snap = snapshots.get(ticker) @@ -399,11 +535,12 @@ class ORBTradingEngine: still_pending.append(cand) 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 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)) if shares <= 0: self._log(f" {ticker}: shares=0 after sizing — skipping") @@ -604,7 +741,13 @@ class ORBTradingEngine: # Update trailing AFTER stop check if trailing_active: 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: candidate = max(bar_low, current_stop) if candidate > current_stop: @@ -627,7 +770,13 @@ class ORBTradingEngine: if trailing_active: 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: candidate = min(bar_high, current_stop) if candidate < current_stop: @@ -840,6 +989,61 @@ class ORBTradingEngine: 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]: """Reconstruct pending candidates from DB (after server restart).""" db_cands = self._state.list_candidates(self._session.session_id, date_str) diff --git a/apps/orb_trader/screener.py b/apps/orb_trader/screener.py index e80b610..0996c04 100644 --- a/apps/orb_trader/screener.py +++ b/apps/orb_trader/screener.py @@ -111,6 +111,7 @@ def live_pre_screen( Filters (applied to the most recent enrichment entry before date_str): - prev_close >= min_price (proxy for current price) - 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 Returns list of qualifying tickers (unsorted). @@ -135,6 +136,12 @@ def live_pre_screen( continue if atr_14 < params.min_atr_14: 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: continue