From 9095b376d93ade220903a43adead28661c9ec15a Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Wed, 22 Apr 2026 05:28:43 -0700 Subject: [PATCH] Clean up superseded configs and commit accumulated R&D infrastructure Key changes: - Delete superseded strategy configs: orb_gainers safe_v2-v9, orb_pullback, vwap_reclaim, hypergap, leader_safe - Add V46 prior_event_types param to domain.py + run.py event type wiring - Major simulator.py enhancements: sector thrust sleeve, sector proxy mapping, helper functions - Improve screener.py with better scoring/filtering - Add new test coverage: test_simulator.py (776 lines) + test_screener.py (313 lines) - Add V24.1 research candidate configs (w002/w003/w004/entrycap/losscap010 variants) - Add leader momentum research configs and sweep files - Update configs/snapshots/registry.json with new strategy registrations - Add docs/leader_intraday_momentum_workflow.md Co-Authored-By: Claude Sonnet 4.6 --- apps/intraday_bt/momentum_research.py | 6 +- apps/intraday_bt/run.py | 12 +- apps/intraday_bt/sweep.py | 2 + apps/paper_trader/backtest_sim.py | 2 + apps/pipeline/filing_poller/main.py | 162 +- .../strategies/hypergap_failure_v1.yaml | 119 -- ...raday_momentum_actual_catalyst_liquid.yaml | 109 ++ ...aday_momentum_event_day_liquid_hybrid.yaml | 185 +++ .../leader_intraday_momentum_high_wr.yaml | 48 - ...raday_momentum_high_wr_intraday_first.yaml | 33 +- ..._intraday_first_cluster_overlay_base.yaml} | 91 +- ...day_momentum_liquid_continuation_core.yaml | 117 ++ .../leader_intraday_momentum_safe.yaml | 101 -- .../strategies/orb_gainers_v23_safe.yaml | 116 -- .../strategies/orb_gainers_v23_safe_v2.yaml | 124 -- .../strategies/orb_gainers_v23_safe_v3.yaml | 112 -- .../strategies/orb_gainers_v23_safe_v9.yaml | 107 -- ...orb_gainers_v24_1_candidate_entrycap.yaml} | 52 +- ...rb_gainers_v24_1_candidate_losscap010.yaml | 108 ++ ... => orb_gainers_v24_1_candidate_w002.yaml} | 53 +- ... => orb_gainers_v24_1_candidate_w003.yaml} | 58 +- ... => orb_gainers_v24_1_candidate_w004.yaml} | 56 +- .../strategies/orb_gainers_v24_losscap.yaml | 124 -- .../intraday/strategies/orb_pullback_v1.yaml | 166 --- .../intraday/strategies/vwap_reclaim_v1.yaml | 123 -- .../strategies/vwap_reclaim_v1_highgap.yaml | 119 -- ...weep_leader_liquid_cluster_overlay_q1.yaml | 21 + .../sweep_orb_gainers_v24_1_gapz.yaml | 7 + configs/snapshots/registry.json | 95 ++ docs/leader_intraday_momentum_workflow.md | 174 +++ libs/export/snapshot_export.py | 8 + libs/intraday/cache.py | 3 +- libs/intraday/domain.py | 11 +- libs/intraday/metrics.py | 3 + libs/intraday/screener.py | 135 +- libs/intraday/simulator.py | 1299 ++++++++++++++++- tests/unit/intraday/test_run_helpers.py | 35 +- tests/unit/intraday/test_screener.py | 313 ++++ tests/unit/intraday/test_simulator.py | 776 ++++++++++ 39 files changed, 3674 insertions(+), 1511 deletions(-) delete mode 100644 configs/intraday/strategies/hypergap_failure_v1.yaml create mode 100644 configs/intraday/strategies/leader_intraday_momentum_actual_catalyst_liquid.yaml create mode 100644 configs/intraday/strategies/leader_intraday_momentum_event_day_liquid_hybrid.yaml delete mode 100644 configs/intraday/strategies/leader_intraday_momentum_high_wr.yaml rename configs/intraday/strategies/{leader_intraday_momentum_defended.yaml => leader_intraday_momentum_high_wr_intraday_first_cluster_overlay_base.yaml} (55%) create mode 100644 configs/intraday/strategies/leader_intraday_momentum_liquid_continuation_core.yaml delete mode 100644 configs/intraday/strategies/leader_intraday_momentum_safe.yaml delete mode 100644 configs/intraday/strategies/orb_gainers_v23_safe.yaml delete mode 100644 configs/intraday/strategies/orb_gainers_v23_safe_v2.yaml delete mode 100644 configs/intraday/strategies/orb_gainers_v23_safe_v3.yaml delete mode 100644 configs/intraday/strategies/orb_gainers_v23_safe_v9.yaml rename configs/intraday/strategies/{orb_gainers_v23_safe_v6.yaml => orb_gainers_v24_1_candidate_entrycap.yaml} (63%) create mode 100644 configs/intraday/strategies/orb_gainers_v24_1_candidate_losscap010.yaml rename configs/intraday/strategies/{orb_gainers_v23_safe_v8.yaml => orb_gainers_v24_1_candidate_w002.yaml} (61%) rename configs/intraday/strategies/{orb_gainers_v23_safe_v4.yaml => orb_gainers_v24_1_candidate_w003.yaml} (61%) rename configs/intraday/strategies/{orb_gainers_v23_safe_v7.yaml => orb_gainers_v24_1_candidate_w004.yaml} (59%) delete mode 100644 configs/intraday/strategies/orb_gainers_v24_losscap.yaml delete mode 100644 configs/intraday/strategies/orb_pullback_v1.yaml delete mode 100644 configs/intraday/strategies/vwap_reclaim_v1.yaml delete mode 100644 configs/intraday/strategies/vwap_reclaim_v1_highgap.yaml create mode 100644 configs/intraday/sweep_leader_liquid_cluster_overlay_q1.yaml create mode 100644 configs/intraday/sweep_orb_gainers_v24_1_gapz.yaml create mode 100644 docs/leader_intraday_momentum_workflow.md diff --git a/apps/intraday_bt/momentum_research.py b/apps/intraday_bt/momentum_research.py index 94bebb3..d9c58db 100644 --- a/apps/intraday_bt/momentum_research.py +++ b/apps/intraday_bt/momentum_research.py @@ -585,12 +585,15 @@ async def build_momentum_research_context( if print_progress: print(f"\n Intraday loaded: {len(all_intraday)} days") + ticker_sectors = await _load_ticker_sectors_with_oracle(tickers, client) + if _momentum_uses_historical_intraday_first(config.strategy): candidates = momentum_intraday_first_candidates( all_intraday, trading_days, config.strategy, daily_enrichment=daily_enrichment, + ticker_sectors=ticker_sectors, max_per_day=config.strategy.candidate_final_max_per_day, ) else: @@ -609,7 +612,7 @@ async def build_momentum_research_context( context = MomentumResearchContext( config=config, tickers=tickers, - ticker_sectors=await _load_ticker_sectors_with_oracle(tickers, client), + ticker_sectors=ticker_sectors, trading_days=trading_days, daily_bars=daily_bars, all_intraday=all_intraday, @@ -680,6 +683,7 @@ def simulate_momentum_params( trading_days, strategy, daily_enrichment=context.daily_enrichment, + ticker_sectors=context.ticker_sectors, max_per_day=strategy.candidate_final_max_per_day, ) else: diff --git a/apps/intraday_bt/run.py b/apps/intraday_bt/run.py index b33fc1c..b765904 100644 --- a/apps/intraday_bt/run.py +++ b/apps/intraday_bt/run.py @@ -1669,9 +1669,11 @@ async def run(config: IntradayConfig, refresh_cache: bool = False) -> tuple: _prior_lookback = int(getattr(orb_params, "prior_event_lookback_days", 0) or 0) if _prior_lookback > 0: all_tickers = list({t for day_tickers in candidates.values() for t in day_tickers}) - print(f" Prefetching prior-event features from DB (D-{_prior_lookback}) for {len(all_tickers)} tickers...") + _prior_event_types = tuple(getattr(orb_params, "prior_event_types", None) or ["earnings_release", "guidance_update"]) + print(f" Prefetching prior-event features from DB (D-{_prior_lookback}, types={_prior_event_types}) for {len(all_tickers)} tickers...") event_features = await _prefetch_prior_event_features_db( - all_tickers, trading_days, lookback_calendar_days=_prior_lookback + all_tickers, trading_days, lookback_calendar_days=_prior_lookback, + event_types=_prior_event_types, ) print(f" Prior-event coverage: {len(event_features)} tickers with events") else: @@ -2168,9 +2170,11 @@ async def run_with_sweep(config: IntradayConfig, sweep_path: str) -> None: _prior_lookback_sw = int(getattr(orb_params, "prior_event_lookback_days", 0) or 0) if _prior_lookback_sw > 0: all_tickers_sw = list({t for day_tickers in candidates.values() for t in day_tickers}) - print(f" Prefetching prior-event features from DB (D-{_prior_lookback_sw}) for {len(all_tickers_sw)} tickers...") + _prior_event_types_sw = tuple(getattr(orb_params, "prior_event_types", None) or ["earnings_release", "guidance_update"]) + print(f" Prefetching prior-event features from DB (D-{_prior_lookback_sw}, types={_prior_event_types_sw}) for {len(all_tickers_sw)} tickers...") event_features = await _prefetch_prior_event_features_db( - all_tickers_sw, trading_days, lookback_calendar_days=_prior_lookback_sw + all_tickers_sw, trading_days, lookback_calendar_days=_prior_lookback_sw, + event_types=_prior_event_types_sw, ) print(f" Prior-event coverage: {len(event_features)} tickers with events") else: diff --git a/apps/intraday_bt/sweep.py b/apps/intraday_bt/sweep.py index e5ec1c8..30bd78d 100644 --- a/apps/intraday_bt/sweep.py +++ b/apps/intraday_bt/sweep.py @@ -95,6 +95,7 @@ def run_sweep( momentum_enrichment: dict | None = None, vix_by_day: dict[str, float] | None = None, ticker_sectors: dict[str, str] | None = None, + sector_proxy_intraday_by_day: dict[str, dict[str, list[dict]]] | None = None, ) -> list[SweepResult]: """Run simulation for each parameter combination. @@ -132,6 +133,7 @@ def run_sweep( daily_enrichment=momentum_enrichment, vix_by_day=vix_by_day, ticker_sectors=ticker_sectors, + sector_proxy_intraday_by_day=sector_proxy_intraday_by_day, ) metrics = compute_metrics(day_results, config, run_id=f"sw{i:04d}") diff --git a/apps/paper_trader/backtest_sim.py b/apps/paper_trader/backtest_sim.py index 463abc8..0da2123 100644 --- a/apps/paper_trader/backtest_sim.py +++ b/apps/paper_trader/backtest_sim.py @@ -524,6 +524,8 @@ def run_backtest( universe_profile = "midwide-liquid-long-v1" elif "smallcap" in snapshot_id: universe_profile = "smallcap-liquid-long-v1" + elif "broad" in snapshot_id: + universe_profile = "broad-liquid-long-v1" if console: console.print(f"\n[bold yellow]Snapshot '{snapshot_id}' is stale — refreshing...[/]") diff --git a/apps/pipeline/filing_poller/main.py b/apps/pipeline/filing_poller/main.py index 4420e9a..b863d83 100644 --- a/apps/pipeline/filing_poller/main.py +++ b/apps/pipeline/filing_poller/main.py @@ -5,7 +5,9 @@ import argparse import asyncio import datetime as dt import uuid +from pathlib import Path +import yaml from sqlalchemy import select from libs.common.config import get_settings @@ -19,13 +21,27 @@ from libs.oracle_client import FilingsService, make_oracle_client logger = get_logger(__name__) +def _load_symbols_from_yaml(path: str) -> list[str]: + """Load ticker list from a symbols YAML (supports list or dict with 'symbols' key).""" + raw = yaml.safe_load(Path(path).read_text()) + if isinstance(raw, list): + items = raw + elif isinstance(raw, dict): + items = raw.get("symbols", []) + else: + items = [] + return sorted({str(s).upper() for s in items if s}) + + async def poll_filings( run_id: str, start_date: str | None = None, end_date: str | None = None, + symbols: list[str] | None = None, ) -> dict[str, int]: settings = get_settings() - symbols = settings.get_symbols() + if symbols is None: + symbols = settings.get_symbols() app_config = settings.get_app_config() form_types = ",".join(app_config.get("pipeline", {}).get("form_types", ["8-K", "6-K"])) @@ -58,66 +74,85 @@ async def poll_filings( ticker_to_symbol = {s.ticker: s.symbol_id for s in symbol_result.scalars().all()} for ticker in symbols: - try: - response = await svc.search_filings( - ticker, - form_type=form_types, - start_date=effective_start, - end_date=end_date, - ) - stats["seen"] += len(response.filings) - - for filing in response.filings: - # Check for duplicate - existing = await session.execute( - select(Document).where( - Document.accession_no == filing.accession_no, - Document.form_type == filing.form_type, - ) - ) - if existing.scalar_one_or_none() is not None: - stats["skipped"] += 1 - continue - - doc_id = make_document_id( - "sec", - f"TICKER::{ticker}", - filing.filing_date, - filing.accession_no, + last_exc: Exception | None = None + response = None + for attempt in range(3): + try: + response = await svc.search_filings( + ticker, + form_type=form_types, + start_date=effective_start, + end_date=end_date, ) - - doc = Document( - document_id=doc_id, - source_name="sec", - issuer_id=ticker_to_issuer.get(ticker), - symbol_id=ticker_to_symbol.get(ticker), - accession_no=filing.accession_no, - form_type=filing.form_type, - filing_date=dt.date.fromisoformat(filing.filing_date), - accepted_at_utc=( - dt.datetime.fromisoformat( - filing.accepted_at.replace("Z", "+00:00") - ) - if filing.accepted_at - else None - ), - primary_document_name=filing.primary_document, - item_numbers=filing.items if filing.items else None, - parsed_status="pending", - ) - session.add(doc) - stats["written"] += 1 - logger.info( - "new_filing_discovered", + last_exc = None + break + except Exception as exc: + last_exc = exc + wait = 2 ** attempt # 1s, 2s, 4s + logger.warning( + "poll_retry", ticker=ticker, - accession_no=filing.accession_no, - form_type=filing.form_type, - filing_date=filing.filing_date, + attempt=attempt + 1, + wait=wait, + error=str(exc) or repr(exc), + exc_type=type(exc).__name__, ) + await asyncio.sleep(wait) - except Exception as exc: - logger.error("poll_error", ticker=ticker, error=str(exc) or repr(exc), exc_type=type(exc).__name__) + if last_exc is not None: + logger.error("poll_error", ticker=ticker, error=str(last_exc) or repr(last_exc), exc_type=type(last_exc).__name__) stats["errors"] += 1 + continue + + stats["seen"] += len(response.filings) + + for filing in response.filings: + # Check for duplicate + existing = await session.execute( + select(Document).where( + Document.accession_no == filing.accession_no, + Document.form_type == filing.form_type, + ) + ) + if existing.scalar_one_or_none() is not None: + stats["skipped"] += 1 + continue + + doc_id = make_document_id( + "sec", + f"TICKER::{ticker}", + filing.filing_date, + filing.accession_no, + ) + + doc = Document( + document_id=doc_id, + source_name="sec", + issuer_id=ticker_to_issuer.get(ticker), + symbol_id=ticker_to_symbol.get(ticker), + accession_no=filing.accession_no, + form_type=filing.form_type, + filing_date=dt.date.fromisoformat(filing.filing_date), + accepted_at_utc=( + dt.datetime.fromisoformat( + filing.accepted_at.replace("Z", "+00:00") + ) + if filing.accepted_at + else None + ), + primary_document_name=filing.primary_document, + item_numbers=filing.items if filing.items else None, + parsed_status="pending", + ) + session.add(doc) + stats["written"] += 1 + logger.info( + "new_filing_discovered", + ticker=ticker, + accession_no=filing.accession_no, + form_type=filing.form_type, + filing_date=filing.filing_date, + ) # Update job record job.status = "succeeded" if stats["errors"] == 0 else "partial" @@ -146,13 +181,26 @@ def main() -> None: metavar="YYYY-MM-DD", help="End date for filing search (default: today)", ) + parser.add_argument( + "--symbols-file", + default=None, + help="Override symbols YAML (default: settings.symbols_file). " + "Use for one-off backfills against a wider universe (e.g. broad snapshot).", + ) args = parser.parse_args() settings = get_settings() configure_logging(settings.log_level) bind_job_run_id(args.run_id) - asyncio.run(poll_filings(args.run_id, start_date=args.start_date, end_date=args.end_date)) + override_symbols = _load_symbols_from_yaml(args.symbols_file) if args.symbols_file else None + + asyncio.run(poll_filings( + args.run_id, + start_date=args.start_date, + end_date=args.end_date, + symbols=override_symbols, + )) if __name__ == "__main__": diff --git a/configs/intraday/strategies/hypergap_failure_v1.yaml b/configs/intraday/strategies/hypergap_failure_v1.yaml deleted file mode 100644 index aed646a..0000000 --- a/configs/intraday/strategies/hypergap_failure_v1.yaml +++ /dev/null @@ -1,119 +0,0 @@ -_meta: - id: 38 - name: "Hypergap Failure V1" - status: aborted - aborted_date: "2026-04-21" - aborted_reason: > - 3 tests all failed. Test 1 (quality filters + regime gate): -33%, WR ~27%. - Test 2 (quality filters, no regime): -59%, WR ~25%. - Test 3 (inverted quality - no rvol, no premarket_vol): -59.16%, WR 42.9%, DD -59.16%. - Structural R/R problem: avg_win 3.55% < avg_loss 4.10%. Need WR ≥ 54% to break even at - this R/R — unachievable. High-quality stocks fail hard but rarely; low-quality stocks fail - often but with small moves. Neither profile yields positive expectancy on gap-failure shorts. - Root cause: gap-up short positions have inherently adverse asymmetry (stocks rocket up when - wrong, drift down slowly when right). No filter combination overcomes this. - description: > - Phase 3 / diagnostic: extreme-gap stocks (≥6%) that fail to hold the ORB. - - Hypothesis: V23's portfolio-level correlation (~0.40) with any long-momentum engine is - regime-driven (both long-momentum, both triggered by QQQ-positive days). The only way to - break regime correlation is to be directionally orthogonal. - - Gap failure = stock gaps up ≥6%, but ORB candle is bearish (sold off in first 5 min). - Entry: short when price breaks below ORB low. On days when V23's stocks are succeeding - (trend), these stocks should not be bearish-ORB (so no trades). On days when market - reverses (V23 losing), gap stocks are more likely to fail → shorts enter → anti-correlation. - - Gate: WR ≥ 42% (shorts tolerate lower WR than longs due to asymmetric payout), - total_return ≥ 0%, max_dd ≥ -20%. - -strategy_mode: orb - -orb_strategy: - engine_family: hypergap_failure_v1 - live_readiness: research_only - - orb_minutes: 5 - sim_bar_minutes: 5 - - entry_direction: short_only # only trade bearish ORB candles (gap failure) - order_timeout_minutes: 45 - - allow_doji_breakout: false - allow_red_to_green_breakout: false - - # === Candidate filters: extreme gap pool (≥6%), same quality bars as V23 === - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.50 - min_atr_pct: 0.04 - min_rvol: null # inverted: allow low-rvol retail stocks (test #3: invert quality) - min_abs_gap_pct: 0.06 # extreme gap: ≥6% (gap failure more likely above this threshold) - min_premarket_dollar_vol: null # inverted: allow low-premarket-vol retail stocks - max_candidates: 20 - max_candidates_per_sector: 3 - min_candidates_to_trade: 1 - ticker_cooldown_days: 0 - max_gap_pct: null # no cap - - min_candidate_breadth: null # no breadth gate — operate on any breadth day - market_regime_spy_threshold: null # no QQQ regime gate — need to find own signal first - market_regime_ticker: QQQ - rolling_loss_days: 7 - rolling_loss_threshold: null # no rolling loss kill — diagnostic mode - max_simultaneous_entries: 3 - min_breakout_rel_vol: null - - # === Scoring weights (same as V23) === - weight_rvol: 0.35 - weight_gap: 0.20 - weight_dollar_vol: 0.05 - weight_premarket_dollar_vol: 0.25 - weight_body_ratio: 0.0 - weight_momentum: 0.15 - - # === Stop / exit (conservative start for diagnostic) === - atr_stop_multiplier: 1.0 # wider stop for shorts (gap stocks can be volatile) - breakeven_at_r: 1.0 - trailing_at_r: 1.0 - trailing_stop_atr_multiplier: 0.8 - trailing_tighten_at_r: 2.0 - trailing_stop_atr_multiplier_tight: 0.3 - - partial_exit_at_r: 99.0 - partial_exit_pct: 0.50 - - risk_per_trade_pct: 0.05 - max_position_pct: 0.70 - daily_max_loss_pct: 0.05 - max_stops_per_day: 5 - exit_minutes_before_close: 5 - - slippage_bps: 5.0 - initial_capital: 10000 - - compound_returns: false - daily_budget_reset: true - settlement_days: 1 - - drawdown_governor_threshold: 0.025 - drawdown_governor_min_scale: 0.30 - - streak_sizing_win_bonus: 0.70 - streak_sizing_max: 2.5 - -universe: - source: midlarge - -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - -cache: - enabled: true - dir: data/cache/intraday - -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/leader_intraday_momentum_actual_catalyst_liquid.yaml b/configs/intraday/strategies/leader_intraday_momentum_actual_catalyst_liquid.yaml new file mode 100644 index 0000000..46f8b66 --- /dev/null +++ b/configs/intraday/strategies/leader_intraday_momentum_actual_catalyst_liquid.yaml @@ -0,0 +1,109 @@ +_meta: + name: Leader Intraday Momentum Actual Catalyst Liquid + description: Separate actual-catalyst liquid-leader continuation engine. Uses only filing-backed catalyst candidates of selected event types, seeds a broad intraday-first shortlist from same-day events, then ranks and trades the liquid leaders showing early continuation. + id: 26 +strategy_mode: momentum +strategy: + compound_returns: false + entry_minutes_after_open: 10 + confirmation_minutes_after_entry: 5 + min_confirmation_return_pct: 0.003 + exit_minutes_before_close: 10 + stop_loss_pct: null + trailing_stop_pct: -0.08 + overextended_trailing_gain_pct: 0.05 + overextended_trailing_stop_pct: -0.07 + min_gap_pct: 0.0 + min_morning_gain_pct: 0.008 + max_morning_gain_pct: 0.06 + max_gap_pct: 0.12 + min_volume_ratio_14d: 0.02 + min_entry_volume: 100000 + min_entry_dollar_volume: 12000000 + ticker_cooldown_days: 0 + top_n: 5 + max_positions_per_sector: 2 + use_five_sleeves: true + five_sleeve_force_count: 3 + use_event_sleeve: true + event_weight: 0.20 + event_min_score: 1.0 + event_sleeve_soft_day_only: false + use_liquid_largecap_sleeve: true + liquid_largecap_weight: 0.15 + liquid_largecap_min_gain_pct: 0.004 + liquid_largecap_max_gain_pct: 0.03 + liquid_largecap_min_confirmation_return_pct: 0.001 + liquid_largecap_min_entry_dollar_volume: 50000000 + liquid_largecap_min_avg_dollar_vol_30d: 1000000000 + liquid_largecap_max_entropy_20d: 0.85 + use_moderate_gap_liquid_sleeve: true + moderate_gap_liquid_weight: 0.10 + moderate_gap_liquid_min_gap_pct: 0.003 + moderate_gap_liquid_max_gap_pct: 0.04 + moderate_gap_liquid_min_gain_pct: 0.008 + moderate_gap_liquid_max_gain_pct: 0.04 + moderate_gap_liquid_min_confirmation_return_pct: 0.002 + moderate_gap_liquid_min_entry_dollar_volume: 20000000 + moderate_gap_liquid_min_avg_dollar_vol_30d: 250000000 + moderate_gap_liquid_max_avg_dollar_vol_30d: 3000000000 + moderate_gap_liquid_min_volume_ratio_14d: 0.02 + moderate_gap_liquid_max_entropy_20d: 0.84 + max_entropy_20d: 0.85 + entropy_size_scale_low: 0.78 + entropy_size_scale_high: 0.85 + entropy_size_scale_min: 0.7 + sector_concentration_scale_low: 0.4 + sector_concentration_scale_high: 0.67 + sector_concentration_scale_min: 0.85 + max_vix: 35.0 + initial_capital: 10000.0 + slippage_bps: 5.0 + candidate_source_mode: intraday_first + candidate_seed_threshold: 0.0 + candidate_seed_max_per_day: 80 + candidate_seed_liquid_overlay_slots: 0 + candidate_seed_leader_overlay_slots: 0 + candidate_seed_moderate_liquid_overlay_slots: 0 + candidate_seed_event_overlay_slots: 20 + candidate_seed_event_min_score: 1.0 + candidate_seed_event_min_gap_pct: 0.0 + candidate_seed_event_max_gap_pct: 0.12 + candidate_seed_event_min_avg_dollar_vol_30d: 150000000 + candidate_seed_event_min_ret_5d: 0.0 + candidate_seed_event_max_entropy_20d: 0.85 + candidate_final_max_per_day: 12 + candidate_intraday_rank_mode: weighted + candidate_intraday_weight_gain: 0.10 + candidate_intraday_weight_confirmation: 0.25 + candidate_intraday_weight_volume_ratio: 0.15 + candidate_intraday_weight_entry_dollar_volume: 0.20 + candidate_intraday_weight_avg_dollar_vol_30d: 0.15 + candidate_intraday_weight_gap: 0.05 + candidate_intraday_weight_low_entropy: 0.05 + candidate_intraday_weight_event_score: 0.20 + candidate_intraday_event_reserve_slots: 2 + candidate_intraday_event_reserve_min_score: 1.0 + candidate_intraday_event_reserve_soft_day_only: false + candidate_intraday_moderate_liquid_reserve_slots: 0 + candidate_require_event_flag: true + candidate_min_event_score: 1.0 + candidate_allowed_event_types: + - earnings_release + - guidance_update + - material_contract + - other_material_event +universe: + source: broad + min_price: 10.0 +backtest: + start_date: null + end_date: null + lookback_trading_days: 200 + pre_screen_threshold: 0.02 +cache: + enabled: true + dir: data/cache/intraday +output: + dir: runs/intraday + verbose: false diff --git a/configs/intraday/strategies/leader_intraday_momentum_event_day_liquid_hybrid.yaml b/configs/intraday/strategies/leader_intraday_momentum_event_day_liquid_hybrid.yaml new file mode 100644 index 0000000..14e7a88 --- /dev/null +++ b/configs/intraday/strategies/leader_intraday_momentum_event_day_liquid_hybrid.yaml @@ -0,0 +1,185 @@ +_meta: + name: Leader Intraday Momentum Event Day Liquid Hybrid + description: Flagship high-WR intraday-first basket plus a separate multi-event liquid overlay. The core basket stays identical to the flagship strategy; only on broad same-day filing clusters does the strategy reserve a small extra budget slice for liquid continuation names outside the base basket. + id: 27 +strategy_mode: momentum +strategy: + compound_returns: false + entry_minutes_after_open: 10 + confirmation_minutes_after_entry: 5 + min_confirmation_return_pct: 0.005 + exit_minutes_before_close: 10 + stop_loss_pct: null + trailing_stop_pct: -0.07 + overextended_trailing_gain_pct: 0.04 + overextended_trailing_stop_pct: -0.065 + min_morning_gain_pct: 0.015 + max_morning_gain_pct: 0.06 + max_gap_pct: 0.055 + min_volume_ratio_14d: 0.04 + min_entry_volume: 125000 + min_entry_dollar_volume: 1500000 + ticker_cooldown_days: 0 + top_n: 9 + max_positions_per_sector: 2 + use_five_sleeves: true + five_sleeve_force_count: 4 + use_event_sleeve: true + event_weight: 0.12 + event_min_score: 1.0 + event_sleeve_soft_day_only: true + soft_day_sparse_max_trades: 2 + soft_day_sparse_require_no_event: true + soft_day_sparse_exempt_largecap: true + soft_day_sparse_exempt_moderate_gap_liquid: true + soft_day_sparse_scale: 0.7 + tail_risk_day_max_trades: 3 + tail_risk_day_min_max_gain_pct: 0.025 + tail_risk_day_max_support_score: 1.0 + tail_risk_day_min_max_confirmation_return_pct: 0.01 + tail_risk_day_require_no_event: true + tail_risk_day_event_exemption_min_support_score: 0.35 + tail_risk_day_exempt_largecap: true + tail_risk_day_scale: 0.55 + low_momentum_single_name_max_gain_pct: 0.025 + low_momentum_single_name_require_no_event: true + low_momentum_single_name_exempt_largecap: true + low_momentum_single_name_scale: 0.55 + max_entropy_20d: 0.86 + entropy_size_scale_low: 0.78 + entropy_size_scale_high: 0.86 + entropy_size_scale_min: 0.6 + sector_concentration_scale_low: 0.4 + sector_concentration_scale_high: 0.67 + sector_concentration_scale_min: 0.8 + max_vix: 30.0 + recent_live_scan_days: 0 + recent_live_scan_min_price: 2.0 + recent_live_scan_avg_volume_min: 200000 + recent_live_scan_market_cap_min: 100000000.0 + recent_live_scan_max_candidates_per_day: 150 + recent_live_scan_top_n: 6 + recent_live_scan_min_morning_gain_pct: 0.005 + recent_live_scan_max_morning_gain_pct: 0.05 + recent_live_scan_min_confirmation_return_pct: 0.0005 + recent_live_scan_min_entry_dollar_volume: 50000000.0 + recent_live_scan_max_gap_pct: 0.04 + recent_live_scan_max_entropy_20d: 0.9 + recent_live_scan_use_slow_ignite_sleeve: true + recent_live_scan_slow_ignite_weight: 0.30 + recent_live_scan_slow_ignite_min_gain_pct: 0.003 + recent_live_scan_slow_ignite_max_gain_pct: 0.015 + recent_live_scan_slow_ignite_min_entry_dollar_volume: 50000000.0 + recent_live_scan_slow_ignite_max_entropy_20d: 0.9 + recent_live_scan_use_liquid_largecap_sleeve: true + recent_live_scan_liquid_largecap_weight: 0.35 + recent_live_scan_liquid_largecap_min_gain_pct: 0.004 + recent_live_scan_liquid_largecap_max_gain_pct: 0.02 + recent_live_scan_liquid_largecap_min_confirmation_return_pct: 0.0005 + recent_live_scan_liquid_largecap_min_entry_dollar_volume: 50000000.0 + recent_live_scan_liquid_largecap_min_avg_dollar_vol_30d: 500000000.0 + recent_live_scan_liquid_largecap_max_entropy_20d: 0.9 + initial_capital: 10000.0 + slippage_bps: 5.0 + candidate_source_mode: intraday_first + candidate_seed_threshold: 0.0075 + candidate_seed_max_per_day: 150 + candidate_seed_liquid_overlay_slots: 3 + candidate_seed_liquid_min_gap_pct: 0.005 + candidate_seed_liquid_max_gap_pct: 0.025 + candidate_seed_liquid_min_avg_dollar_vol_30d: 5000000000.0 + candidate_seed_liquid_min_ret_5d: 0.0 + candidate_seed_liquid_max_entropy_20d: 0.87 + candidate_seed_leader_overlay_slots: 1 + candidate_seed_leader_min_gap_pct: -0.025 + candidate_seed_leader_max_gap_pct: 0.01 + candidate_seed_leader_min_avg_dollar_vol_30d: 500000000.0 + candidate_seed_leader_min_ret_5d: 0.15 + candidate_seed_leader_min_atr_pct: 0.06 + candidate_seed_leader_max_entropy_20d: 0.75 + candidate_seed_moderate_liquid_overlay_slots: 20 + candidate_seed_moderate_liquid_min_gap_pct: 0.005 + candidate_seed_moderate_liquid_max_gap_pct: 0.025 + candidate_seed_moderate_liquid_min_avg_dollar_vol_30d: 250000000.0 + candidate_seed_moderate_liquid_max_avg_dollar_vol_30d: 2000000000.0 + candidate_seed_moderate_liquid_max_entropy_20d: 0.86 + candidate_seed_event_overlay_slots: 0 + candidate_seed_event_min_score: null + candidate_seed_event_min_gap_pct: null + candidate_seed_event_max_gap_pct: null + candidate_seed_event_min_avg_dollar_vol_30d: null + candidate_seed_event_min_ret_5d: null + candidate_seed_event_max_entropy_20d: null + candidate_final_max_per_day: 14 + candidate_intraday_rank_mode: weighted + candidate_intraday_weight_gain: 0.10 + candidate_intraday_weight_confirmation: 0.45 + candidate_intraday_weight_volume_ratio: 0.20 + candidate_intraday_weight_entry_dollar_volume: 0.15 + candidate_intraday_weight_gap: 0.05 + candidate_intraday_weight_low_entropy: 0.05 + candidate_intraday_weight_avg_dollar_vol_30d: 0.08 + candidate_intraday_weight_event_score: 0.0 + candidate_intraday_event_reserve_slots: 0 + candidate_intraday_event_reserve_min_score: null + candidate_intraday_event_reserve_soft_day_only: false + candidate_intraday_moderate_liquid_reserve_slots: 1 + candidate_intraday_moderate_liquid_reserve_trigger_below: 2 + candidate_allowed_event_types: [] + use_event_day_liquid_sleeve: true + event_day_liquid_capital_fraction: 0.12 + event_day_liquid_max_positions: 2 + event_day_liquid_allowed_event_types: + - earnings_release + - guidance_update + - material_contract + - other_material_event + - management_change + - unknown + event_day_liquid_min_event_names: 2 + event_day_liquid_min_event_score: 1.0 + event_day_liquid_min_event_support_score: 0.15 + event_day_liquid_min_total_event_entry_dollar_volume: 100000000.0 + event_day_liquid_min_gain_pct: 0.004 + event_day_liquid_max_gain_pct: 0.04 + event_day_liquid_min_confirmation_return_pct: 0.0005 + event_day_liquid_min_entry_dollar_volume: 25000000.0 + event_day_liquid_min_avg_dollar_vol_30d: 250000000.0 + event_day_liquid_max_entropy_20d: 0.88 + event_day_liquid_min_support_score: 0.35 + use_liquid_largecap_sleeve: true + liquid_largecap_weight: 0.05 + use_moderate_gap_liquid_sleeve: true + moderate_gap_liquid_weight: 0.0 + moderate_gap_liquid_min_gap_pct: 0.005 + moderate_gap_liquid_max_gap_pct: 0.025 + moderate_gap_liquid_min_gain_pct: 0.015 + moderate_gap_liquid_max_gain_pct: 0.04 + moderate_gap_liquid_min_confirmation_return_pct: 0.005 + moderate_gap_liquid_min_entry_dollar_volume: 40000000.0 + moderate_gap_liquid_min_avg_dollar_vol_30d: 250000000.0 + moderate_gap_liquid_max_avg_dollar_vol_30d: 2000000000.0 + moderate_gap_liquid_min_volume_ratio_14d: 0.04 + moderate_gap_liquid_max_entropy_20d: 0.86 + fallback_liquid_largecap_slots: 1 + fallback_liquid_largecap_trigger_below: 2 + liquid_largecap_min_gain_pct: 0.004 + liquid_largecap_max_gain_pct: 0.015 + liquid_largecap_min_confirmation_return_pct: 0.0005 + liquid_largecap_min_entry_dollar_volume: 50000000.0 + liquid_largecap_min_avg_dollar_vol_30d: 2000000000.0 + liquid_largecap_max_entropy_20d: 0.87 +universe: + source: broad + min_price: 10.0 +backtest: + start_date: null + end_date: null + lookback_trading_days: 200 + pre_screen_threshold: 0.02 +cache: + enabled: true + dir: data/cache/intraday +output: + dir: runs/intraday + verbose: false diff --git a/configs/intraday/strategies/leader_intraday_momentum_high_wr.yaml b/configs/intraday/strategies/leader_intraday_momentum_high_wr.yaml deleted file mode 100644 index 8f6b854..0000000 --- a/configs/intraday/strategies/leader_intraday_momentum_high_wr.yaml +++ /dev/null @@ -1,48 +0,0 @@ -_meta: - name: Leader Intraday Momentum High WR - description: More selective high-win-rate variant of the safe momentum basket. Uses a 5-minute confirmation that requires +0.45% follow-through, a 2.0M entry dollar-volume floor, a tighter 6.0% morning overextension cap, a 5.5% opening-gap cap, an 8.0% base trailing stop, and a slightly tighter 7.5% trail once a name is already up 4%+ at entry. Intended for users who prioritize win rate over basket breadth. - id: 20 -strategy_mode: momentum -strategy: - compound_returns: false - entry_minutes_after_open: 10 - confirmation_minutes_after_entry: 5 - min_confirmation_return_pct: 0.0045 - exit_minutes_before_close: 10 - stop_loss_pct: null - trailing_stop_pct: -0.08 - overextended_trailing_gain_pct: 0.04 - overextended_trailing_stop_pct: -0.075 - min_morning_gain_pct: 0.015 - max_morning_gain_pct: 0.06 - max_gap_pct: 0.055 - min_entry_volume: 125000 - min_entry_dollar_volume: 2000000 - ticker_cooldown_days: 4 - top_n: 8 - max_positions_per_sector: 2 - use_five_sleeves: true - max_entropy_20d: 0.86 - max_vix: 30.0 - recent_live_scan_days: 0 - recent_live_scan_min_price: 2.0 - recent_live_scan_avg_volume_min: 200000 - recent_live_scan_market_cap_min: 100000000.0 - recent_live_scan_max_candidates_per_day: 150 - initial_capital: 10000.0 - slippage_bps: 5.0 - market_regime_spy_threshold: null -universe: - source: broad - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.02 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday - verbose: false diff --git a/configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml b/configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml index d33ed0d..5d6952c 100644 --- a/configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml +++ b/configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml @@ -1,6 +1,6 @@ _meta: name: Leader Intraday Momentum High WR Intraday First - description: Corrected high-win-rate momentum variant that replaces the pure daily-gap candidate source with a historical intraday-first shortlist. It uses a 1.5% seed gap, reranks the shortlist at entry time using confirmation, volume-ratio, dollar-volume, gap, and low-entropy quality, trims exposure on the noisiest high-entropy names, only on soft days lets one event sleeve substitute a catalyst-backed name into the basket, adds a bounded moderate-gap liquid follow-through reserve for names that broad scan catches but the raw gap rank misses, and applies a no-event sparse basket tail defense. Intended to improve corrected Q1 and weak 2025 quarter robustness without reintroducing lookahead. + description: Corrected high-win-rate momentum variant that replaces the pure daily-gap candidate source with a historical intraday-first shortlist. It uses a 1.5% seed gap, reranks the shortlist at entry time using confirmation, volume-ratio, dollar-volume, gap, and low-entropy quality, trims exposure on the noisiest high-entropy names, only on soft days lets one event sleeve substitute a catalyst-backed name into the basket, adds a bounded moderate-gap liquid follow-through reserve for names that broad scan catches but the raw gap rank misses, scales down sparse soft-day baskets that lack supportive event or liquid-follow-through structure, and now reserves a small extra budget slice on broad same-day filing clusters to add liquid continuation names outside the base basket. id: 25 strategy_mode: momentum strategy: @@ -28,14 +28,24 @@ strategy: event_weight: 0.12 event_min_score: 1.0 event_sleeve_soft_day_only: true + soft_day_sparse_max_trades: 2 + soft_day_sparse_require_no_event: true + soft_day_sparse_exempt_largecap: true + soft_day_sparse_exempt_moderate_gap_liquid: true + soft_day_sparse_scale: 0.7 tail_risk_day_max_trades: 3 tail_risk_day_min_max_gain_pct: 0.025 tail_risk_day_max_support_score: 1.0 tail_risk_day_min_max_entropy_20d: null tail_risk_day_min_max_confirmation_return_pct: 0.01 tail_risk_day_require_no_event: true + tail_risk_day_event_exemption_min_support_score: 0.35 tail_risk_day_exempt_largecap: true tail_risk_day_scale: 0.55 + low_momentum_single_name_max_gain_pct: 0.025 + low_momentum_single_name_require_no_event: true + low_momentum_single_name_exempt_largecap: true + low_momentum_single_name_scale: 0.55 max_entropy_20d: 0.86 entropy_size_scale_low: 0.78 entropy_size_scale_high: 0.86 @@ -106,6 +116,27 @@ strategy: candidate_intraday_weight_avg_dollar_vol_30d: 0.08 candidate_intraday_moderate_liquid_reserve_slots: 1 candidate_intraday_moderate_liquid_reserve_trigger_below: 2 + use_event_day_liquid_sleeve: true + event_day_liquid_capital_fraction: 0.12 + event_day_liquid_max_positions: 2 + event_day_liquid_allowed_event_types: + - earnings_release + - guidance_update + - material_contract + - other_material_event + - management_change + - unknown + event_day_liquid_min_event_names: 2 + event_day_liquid_min_event_score: 1.0 + event_day_liquid_min_event_support_score: 0.15 + event_day_liquid_min_total_event_entry_dollar_volume: 100000000.0 + event_day_liquid_min_gain_pct: 0.004 + event_day_liquid_max_gain_pct: 0.04 + event_day_liquid_min_confirmation_return_pct: 0.0005 + event_day_liquid_min_entry_dollar_volume: 25000000.0 + event_day_liquid_min_avg_dollar_vol_30d: 250000000.0 + event_day_liquid_max_entropy_20d: 0.88 + event_day_liquid_min_support_score: 0.35 use_liquid_largecap_sleeve: true liquid_largecap_weight: 0.05 use_moderate_gap_liquid_sleeve: true diff --git a/configs/intraday/strategies/leader_intraday_momentum_defended.yaml b/configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first_cluster_overlay_base.yaml similarity index 55% rename from configs/intraday/strategies/leader_intraday_momentum_defended.yaml rename to configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first_cluster_overlay_base.yaml index 7b244d9..d590b82 100644 --- a/configs/intraday/strategies/leader_intraday_momentum_defended.yaml +++ b/configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first_cluster_overlay_base.yaml @@ -1,7 +1,7 @@ _meta: - name: Leader Intraday Momentum Defended - description: Intraday-first leader momentum with layered downside defense. It keeps the corrected broad-universe candidate engine, then scales down weak market/breadth days and also shrinks thin, weak-support single-name breakout days to improve loss containment without changing the core entry logic. - id: 31 + name: Leader Intraday Momentum High WR Intraday First Cluster Overlay Base + description: Experimental base config for evaluating post-allocation liquid-cluster and sector-ETF breadth overlays on top of the High WR Intraday First flagship. This is not the official production strategy; it exists to fetch the wider metadata/proxy set needed for overlay research. + id: 26 strategy_mode: momentum strategy: compound_returns: false @@ -10,9 +10,9 @@ strategy: min_confirmation_return_pct: 0.005 exit_minutes_before_close: 10 stop_loss_pct: null - trailing_stop_pct: -0.08 + trailing_stop_pct: -0.07 overextended_trailing_gain_pct: 0.04 - overextended_trailing_stop_pct: -0.075 + overextended_trailing_stop_pct: -0.065 min_morning_gain_pct: 0.015 max_morning_gain_pct: 0.06 max_gap_pct: 0.055 @@ -24,32 +24,36 @@ strategy: max_positions_per_sector: 2 use_five_sleeves: true five_sleeve_force_count: 4 - tail_risk_day_max_trades: 1 - tail_risk_day_min_max_gain_pct: 0.03 - tail_risk_day_max_support_score: 0.25 - tail_risk_day_min_max_entropy_20d: 0.79 + use_event_sleeve: true + event_weight: 0.12 + event_min_score: 1.0 + event_sleeve_soft_day_only: true + soft_day_sparse_max_trades: 2 + soft_day_sparse_require_no_event: true + soft_day_sparse_exempt_largecap: true + soft_day_sparse_exempt_moderate_gap_liquid: true + soft_day_sparse_scale: 0.7 + tail_risk_day_max_trades: 3 + tail_risk_day_min_max_gain_pct: 0.025 + tail_risk_day_max_support_score: 1.0 + tail_risk_day_min_max_entropy_20d: null tail_risk_day_min_max_confirmation_return_pct: 0.01 + tail_risk_day_require_no_event: true + tail_risk_day_event_exemption_min_support_score: 0.35 tail_risk_day_exempt_largecap: true tail_risk_day_scale: 0.55 + low_momentum_single_name_max_gain_pct: 0.025 + low_momentum_single_name_require_no_event: true + low_momentum_single_name_exempt_largecap: true + low_momentum_single_name_scale: 0.55 max_entropy_20d: 0.86 entropy_size_scale_low: 0.78 entropy_size_scale_high: 0.86 entropy_size_scale_min: 0.6 + sector_concentration_scale_low: 0.4 + sector_concentration_scale_high: 0.67 + sector_concentration_scale_min: 0.8 max_vix: 30.0 - market_regime_spy_threshold: null - market_regime_gap_threshold: -0.015 - market_regime_gap_ticker: SPY - regime_size_scale_low: -0.015 - regime_size_scale_high: 0.002 - regime_size_scale_min: 0.65 - min_candidate_breadth: 0.4 - breadth_size_scale_low: 0.4 - breadth_size_scale_high: 0.62 - breadth_size_scale_min: 0.7 - soft_day_scaler_threshold: 0.88 - soft_day_max_trades: 5 - rolling_loss_days: null - rolling_loss_threshold: null recent_live_scan_days: 0 recent_live_scan_min_price: 2.0 recent_live_scan_avg_volume_min: 200000 @@ -78,6 +82,7 @@ strategy: recent_live_scan_liquid_largecap_max_entropy_20d: 0.9 initial_capital: 10000.0 slippage_bps: 5.0 + market_regime_spy_threshold: null candidate_source_mode: intraday_first candidate_seed_threshold: 0.0075 candidate_seed_max_per_day: 150 @@ -94,6 +99,12 @@ strategy: candidate_seed_leader_min_ret_5d: 0.15 candidate_seed_leader_min_atr_pct: 0.06 candidate_seed_leader_max_entropy_20d: 0.75 + candidate_seed_moderate_liquid_overlay_slots: 20 + candidate_seed_moderate_liquid_min_gap_pct: 0.005 + candidate_seed_moderate_liquid_max_gap_pct: 0.025 + candidate_seed_moderate_liquid_min_avg_dollar_vol_30d: 250000000.0 + candidate_seed_moderate_liquid_max_avg_dollar_vol_30d: 2000000000.0 + candidate_seed_moderate_liquid_max_entropy_20d: 0.86 candidate_final_max_per_day: 14 candidate_intraday_rank_mode: weighted candidate_intraday_weight_gain: 0.10 @@ -103,8 +114,22 @@ strategy: candidate_intraday_weight_gap: 0.05 candidate_intraday_weight_low_entropy: 0.05 candidate_intraday_weight_avg_dollar_vol_30d: 0.08 + candidate_intraday_moderate_liquid_reserve_slots: 1 + candidate_intraday_moderate_liquid_reserve_trigger_below: 2 use_liquid_largecap_sleeve: true liquid_largecap_weight: 0.05 + use_moderate_gap_liquid_sleeve: true + moderate_gap_liquid_weight: 0.0 + moderate_gap_liquid_min_gap_pct: 0.005 + moderate_gap_liquid_max_gap_pct: 0.025 + moderate_gap_liquid_min_gain_pct: 0.015 + moderate_gap_liquid_max_gain_pct: 0.04 + moderate_gap_liquid_min_confirmation_return_pct: 0.005 + moderate_gap_liquid_min_entry_dollar_volume: 40000000.0 + moderate_gap_liquid_min_avg_dollar_vol_30d: 250000000.0 + moderate_gap_liquid_max_avg_dollar_vol_30d: 2000000000.0 + moderate_gap_liquid_min_volume_ratio_14d: 0.04 + moderate_gap_liquid_max_entropy_20d: 0.86 fallback_liquid_largecap_slots: 1 fallback_liquid_largecap_trigger_below: 2 liquid_largecap_min_gain_pct: 0.004 @@ -113,6 +138,26 @@ strategy: liquid_largecap_min_entry_dollar_volume: 50000000.0 liquid_largecap_min_avg_dollar_vol_30d: 2000000000.0 liquid_largecap_max_entropy_20d: 0.87 + use_liquid_cluster_engine: true + liquid_cluster_capital_fraction: 0.15 + liquid_cluster_max_positions: 1 + liquid_cluster_max_positions_per_sector: 1 + liquid_cluster_min_members: 2 + liquid_cluster_min_gain_pct: 0.015 + liquid_cluster_max_gain_pct: 0.04 + liquid_cluster_min_confirmation_return_pct: 0.005 + liquid_cluster_min_entry_dollar_volume: 40000000.0 + liquid_cluster_min_avg_dollar_vol_30d: 250000000.0 + liquid_cluster_max_avg_dollar_vol_30d: 2000000000.0 + liquid_cluster_min_volume_ratio_14d: 0.04 + liquid_cluster_max_entropy_20d: 0.86 + liquid_cluster_min_sector_avg_confirmation_return_pct: 0.005 + liquid_cluster_min_sector_total_entry_dollar_volume: 100000000.0 + liquid_cluster_require_special_liquidity_gate: true + use_sector_etf_sleeve: true + sector_etf_capital_fraction: 0.10 + sector_etf_max_positions: 1 + sector_etf_min_sector_score: 0.20 universe: source: broad min_price: 10.0 diff --git a/configs/intraday/strategies/leader_intraday_momentum_liquid_continuation_core.yaml b/configs/intraday/strategies/leader_intraday_momentum_liquid_continuation_core.yaml new file mode 100644 index 0000000..fc2bc3e --- /dev/null +++ b/configs/intraday/strategies/leader_intraday_momentum_liquid_continuation_core.yaml @@ -0,0 +1,117 @@ +_meta: + name: Leader Intraday Momentum Liquid Continuation Core + description: Separate liquid-continuation core engine. Instead of treating liquid follow-through as an overlay, this strategy makes moderate-gap liquid names, liquid large-cap leaders, and sector breadth-confirmed continuation the primary basket selection path. + id: 28 +strategy_mode: momentum +strategy: + compound_returns: false + entry_minutes_after_open: 10 + confirmation_minutes_after_entry: 5 + min_confirmation_return_pct: 0.003 + exit_minutes_before_close: 10 + stop_loss_pct: null + trailing_stop_pct: -0.075 + overextended_trailing_gain_pct: 0.04 + overextended_trailing_stop_pct: -0.06 + min_gap_pct: 0.0 + min_morning_gain_pct: 0.006 + max_morning_gain_pct: 0.04 + max_gap_pct: 0.05 + min_volume_ratio_14d: 0.02 + min_entry_volume: 100000 + min_entry_dollar_volume: 20000000 + ticker_cooldown_days: 0 + top_n: 4 + max_positions_per_sector: 2 + use_five_sleeves: false + momentum_selection_mode: liquid_continuation + soft_day_sparse_max_trades: 2 + soft_day_sparse_exempt_largecap: true + soft_day_sparse_exempt_moderate_gap_liquid: true + soft_day_sparse_scale: 0.8 + tail_risk_day_max_trades: 3 + tail_risk_day_min_max_gain_pct: 0.02 + tail_risk_day_min_max_confirmation_return_pct: 0.006 + tail_risk_day_exempt_largecap: true + tail_risk_day_scale: 0.65 + max_entropy_20d: 0.88 + entropy_size_scale_low: 0.80 + entropy_size_scale_high: 0.88 + entropy_size_scale_min: 0.7 + sector_concentration_scale_low: 0.4 + sector_concentration_scale_high: 0.67 + sector_concentration_scale_min: 0.85 + max_vix: 35.0 + initial_capital: 10000.0 + slippage_bps: 5.0 + market_regime_spy_threshold: null + candidate_source_mode: intraday_first + candidate_seed_threshold: 0.0 + candidate_seed_max_per_day: 180 + candidate_seed_liquid_overlay_slots: 10 + candidate_seed_liquid_min_gap_pct: -0.015 + candidate_seed_liquid_max_gap_pct: 0.03 + candidate_seed_liquid_min_avg_dollar_vol_30d: 2000000000.0 + candidate_seed_liquid_min_ret_5d: 0.0 + candidate_seed_liquid_max_entropy_20d: 0.88 + candidate_seed_leader_overlay_slots: 6 + candidate_seed_leader_min_gap_pct: -0.02 + candidate_seed_leader_max_gap_pct: 0.03 + candidate_seed_leader_min_avg_dollar_vol_30d: 500000000.0 + candidate_seed_leader_min_ret_5d: 0.10 + candidate_seed_leader_min_atr_pct: 0.04 + candidate_seed_leader_max_entropy_20d: 0.82 + candidate_seed_moderate_liquid_overlay_slots: 40 + candidate_seed_moderate_liquid_min_gap_pct: 0.002 + candidate_seed_moderate_liquid_max_gap_pct: 0.04 + candidate_seed_moderate_liquid_min_avg_dollar_vol_30d: 250000000.0 + candidate_seed_moderate_liquid_max_avg_dollar_vol_30d: 4000000000.0 + candidate_seed_moderate_liquid_min_ret_5d: 0.0 + candidate_seed_moderate_liquid_max_entropy_20d: 0.88 + candidate_final_max_per_day: 14 + candidate_intraday_rank_mode: liquid_continuation + candidate_intraday_moderate_liquid_reserve_slots: 2 + candidate_intraday_moderate_liquid_reserve_trigger_below: 3 + use_liquid_largecap_sleeve: true + liquid_largecap_weight: 0.0 + liquid_largecap_min_gain_pct: 0.006 + liquid_largecap_max_gain_pct: 0.025 + liquid_largecap_min_confirmation_return_pct: 0.003 + liquid_largecap_min_entry_dollar_volume: 60000000.0 + liquid_largecap_min_avg_dollar_vol_30d: 2000000000.0 + liquid_largecap_max_entropy_20d: 0.90 + use_moderate_gap_liquid_sleeve: true + moderate_gap_liquid_weight: 0.0 + moderate_gap_liquid_min_gap_pct: 0.005 + moderate_gap_liquid_max_gap_pct: 0.035 + moderate_gap_liquid_min_gain_pct: 0.01 + moderate_gap_liquid_max_gain_pct: 0.04 + moderate_gap_liquid_min_confirmation_return_pct: 0.003 + moderate_gap_liquid_min_entry_dollar_volume: 40000000.0 + moderate_gap_liquid_min_avg_dollar_vol_30d: 250000000.0 + moderate_gap_liquid_max_avg_dollar_vol_30d: 4000000000.0 + moderate_gap_liquid_min_volume_ratio_14d: 0.04 + moderate_gap_liquid_max_entropy_20d: 0.88 + use_sector_thrust_sleeve: true + sector_thrust_weight: 0.0 + sector_thrust_min_members: 2 + sector_thrust_min_gain_pct: 0.01 + sector_thrust_min_confirmation_return_pct: 0.003 + sector_thrust_min_entry_dollar_volume: 40000000.0 + sector_thrust_min_avg_dollar_vol_30d: 250000000.0 + sector_thrust_min_sector_avg_confirmation_return_pct: 0.003 + sector_thrust_min_sector_total_entry_dollar_volume: 150000000.0 +universe: + source: broad + min_price: 10.0 +backtest: + start_date: null + end_date: null + lookback_trading_days: 200 + pre_screen_threshold: 0.02 +cache: + enabled: true + dir: data/cache/intraday +output: + dir: runs/intraday + verbose: false diff --git a/configs/intraday/strategies/leader_intraday_momentum_safe.yaml b/configs/intraday/strategies/leader_intraday_momentum_safe.yaml deleted file mode 100644 index 9a47fd3..0000000 --- a/configs/intraday/strategies/leader_intraday_momentum_safe.yaml +++ /dev/null @@ -1,101 +0,0 @@ -_meta: - name: Leader Intraday Momentum Safe - description: Safer intraday-first leader basket derived from the current high-win-rate engine. It keeps the liquid-largecap-aware candidate stack, same-day confirmation rerank, and five-sleeve blend, but scales sparse 1-5 position days below full size to reduce trap-day drawdowns without reverting to the stale pre-intraday-first safe rules. - id: 19 -strategy_mode: momentum -strategy: - compound_returns: false - entry_minutes_after_open: 10 - confirmation_minutes_after_entry: 5 - min_confirmation_return_pct: 0.005 - exit_minutes_before_close: 10 - stop_loss_pct: null - trailing_stop_pct: -0.08 - overextended_trailing_gain_pct: 0.04 - overextended_trailing_stop_pct: -0.075 - min_morning_gain_pct: 0.015 - max_morning_gain_pct: 0.06 - max_gap_pct: 0.055 - min_volume_ratio_14d: 0.04 - min_entry_volume: 125000 - min_entry_dollar_volume: 1500000 - ticker_cooldown_days: 0 - top_n: 9 - full_size_positions_threshold: 6 - sparse_day_size_floor: 0.5 - max_positions_per_sector: 2 - use_five_sleeves: true - five_sleeve_force_count: 4 - max_entropy_20d: 0.86 - max_vix: 30.0 - recent_live_scan_days: 0 - recent_live_scan_min_price: 2.0 - recent_live_scan_avg_volume_min: 200000 - recent_live_scan_market_cap_min: 100000000.0 - recent_live_scan_max_candidates_per_day: 150 - recent_live_scan_top_n: 6 - recent_live_scan_min_morning_gain_pct: 0.005 - recent_live_scan_max_morning_gain_pct: 0.05 - recent_live_scan_min_confirmation_return_pct: 0.0005 - recent_live_scan_min_entry_dollar_volume: 50000000.0 - recent_live_scan_max_gap_pct: 0.04 - recent_live_scan_max_entropy_20d: 0.9 - recent_live_scan_use_slow_ignite_sleeve: true - recent_live_scan_slow_ignite_weight: 0.30 - recent_live_scan_slow_ignite_min_gain_pct: 0.003 - recent_live_scan_slow_ignite_max_gain_pct: 0.015 - recent_live_scan_slow_ignite_min_entry_dollar_volume: 50000000.0 - recent_live_scan_slow_ignite_max_entropy_20d: 0.9 - recent_live_scan_use_liquid_largecap_sleeve: true - recent_live_scan_liquid_largecap_weight: 0.35 - recent_live_scan_liquid_largecap_min_gain_pct: 0.004 - recent_live_scan_liquid_largecap_max_gain_pct: 0.02 - recent_live_scan_liquid_largecap_min_confirmation_return_pct: 0.0005 - recent_live_scan_liquid_largecap_min_entry_dollar_volume: 50000000.0 - recent_live_scan_liquid_largecap_min_avg_dollar_vol_30d: 500000000.0 - recent_live_scan_liquid_largecap_max_entropy_20d: 0.9 - initial_capital: 10000.0 - slippage_bps: 5.0 - market_regime_spy_threshold: null - candidate_source_mode: intraday_first - candidate_seed_threshold: 0.0075 - candidate_seed_max_per_day: 150 - candidate_seed_liquid_overlay_slots: 3 - candidate_seed_liquid_min_gap_pct: 0.005 - candidate_seed_liquid_max_gap_pct: 0.025 - candidate_seed_liquid_min_avg_dollar_vol_30d: 5000000000.0 - candidate_seed_liquid_min_ret_5d: 0.0 - candidate_seed_liquid_max_entropy_20d: 0.87 - candidate_final_max_per_day: 12 - candidate_intraday_rank_mode: weighted - candidate_intraday_weight_gain: 0.10 - candidate_intraday_weight_confirmation: 0.45 - candidate_intraday_weight_volume_ratio: 0.20 - candidate_intraday_weight_entry_dollar_volume: 0.15 - candidate_intraday_weight_gap: 0.05 - candidate_intraday_weight_low_entropy: 0.05 - candidate_intraday_weight_avg_dollar_vol_30d: 0.08 - use_liquid_largecap_sleeve: true - liquid_largecap_weight: 0.05 - fallback_liquid_largecap_slots: 1 - fallback_liquid_largecap_trigger_below: 2 - liquid_largecap_min_gain_pct: 0.004 - liquid_largecap_max_gain_pct: 0.015 - liquid_largecap_min_confirmation_return_pct: 0.0005 - liquid_largecap_min_entry_dollar_volume: 50000000.0 - liquid_largecap_min_avg_dollar_vol_30d: 2000000000.0 - liquid_largecap_max_entropy_20d: 0.87 -universe: - source: broad - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.02 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday - verbose: false diff --git a/configs/intraday/strategies/orb_gainers_v23_safe.yaml b/configs/intraday/strategies/orb_gainers_v23_safe.yaml deleted file mode 100644 index 5112055..0000000 --- a/configs/intraday/strategies/orb_gainers_v23_safe.yaml +++ /dev/null @@ -1,116 +0,0 @@ -_meta: - id: 29 - name: "ORB Gainers V23 Safe" - status: experimental - parent: orb_gainers_v23 - description: > - V23 파생 전략 — "안전 투자자" 버전. 수익률을 희생해서 손실을 최소화하는 것이 목표. - V23 대비 5가지 방향으로 보수화: - - 1. 레짐 필터 강화: QQQ 갭 0.15% → 0.30% (더 강한 상승 장세만 진입) - 2. 진입 품질 상향: min_rvol 1.5→2.0, min_candidate_breadth 0.60→0.70 - 3. 포지션 크기 축소: risk_per_trade 5%→3%, max_simultaneous 3→2 - 4. 손실 governor 강화: rolling_loss -7%→-3%, drawdown_governor 2.5%→1.5% - 5. 일일 손실 컷: daily_max_loss 5%→3%, max_stops_per_day 5→3 - - streak_sizing 비활성화 (승리 시 포지션 키우지 않음 — 안전 우선) - -strategy_mode: orb - -orb_strategy: - engine_family: gainers_leader - live_readiness: experimental - - orb_minutes: 5 - sim_bar_minutes: 5 - - entry_direction: long_only - order_timeout_minutes: 45 - - allow_doji_breakout: true - allow_red_to_green_breakout: true - - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.50 - min_atr_pct: 0.04 - - # === SAFE CHANGE: higher rvol requirement (was 1.5) === - min_rvol: 2.0 - # === SAFE CHANGE: slightly higher gap floor (was 0.02) === - min_abs_gap_pct: 0.025 - min_premarket_dollar_vol: 1500000 - max_candidates: 20 - max_candidates_per_sector: 3 - min_candidates_to_trade: 1 - ticker_cooldown_days: 0 - max_gap_pct: 0.04 - - # === SAFE CHANGE: higher breadth requirement (was 0.60) === - min_candidate_breadth: 0.70 - # === SAFE CHANGE: stronger QQQ regime required (was 0.0015 = 0.15%) === - market_regime_spy_threshold: 0.003 - market_regime_ticker: QQQ - rolling_loss_days: 7 - # === SAFE CHANGE: stop much sooner on bad streaks (was -0.07) === - rolling_loss_threshold: -0.03 - # === SAFE CHANGE: max 2 simultaneous positions (was 3) === - max_simultaneous_entries: 2 - min_breakout_rel_vol: 1.2 - - weight_rvol: 0.35 - weight_gap: 0.20 - weight_dollar_vol: 0.05 - weight_premarket_dollar_vol: 0.25 - weight_body_ratio: 0.0 - weight_momentum: 0.15 - - atr_stop_multiplier: 0.75 - breakeven_at_r: 1.0 - trailing_at_r: 1.0 - trailing_stop_atr_multiplier: 0.8 - trailing_tighten_at_r: 2.0 - trailing_stop_atr_multiplier_tight: 0.3 - - partial_exit_at_r: 99.0 - partial_exit_pct: 0.50 - - # === SAFE CHANGE: smaller position risk (was 0.05) === - risk_per_trade_pct: 0.03 - max_position_pct: 0.70 - # === SAFE CHANGE: cut daily losses sooner (was 0.05) === - daily_max_loss_pct: 0.03 - # === SAFE CHANGE: stop the day after 3 stops (was 5) === - max_stops_per_day: 3 - exit_minutes_before_close: 5 - - slippage_bps: 5.0 - initial_capital: 10000 - - compound_returns: false - daily_budget_reset: true - settlement_days: 1 - - # === SAFE CHANGE: tighter portfolio DD governor (was 0.025) === - drawdown_governor_threshold: 0.015 - drawdown_governor_min_scale: 0.30 - - # === SAFE CHANGE: no streak sizing boost (was bonus=0.70, max=2.5) === - streak_sizing_win_bonus: 0.0 - streak_sizing_max: 1.0 - -universe: - source: midlarge - -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - -cache: - enabled: true - dir: data/cache/intraday - -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/orb_gainers_v23_safe_v2.yaml b/configs/intraday/strategies/orb_gainers_v23_safe_v2.yaml deleted file mode 100644 index 6dbbbda..0000000 --- a/configs/intraday/strategies/orb_gainers_v23_safe_v2.yaml +++ /dev/null @@ -1,124 +0,0 @@ -_meta: - id: 30 - name: "ORB Gainers V23 Safe v2" - status: validated - parent: orb_gainers_v23 - description: > - V23 파생 전략 — "안전 투자자" v2. v1(+5.16%, DD -13.49%)보다 DD를 줄이는 것이 목표. - - 핵심 발견 (v1 분석): - - V23 손실일의 QQQ 갭: +0.3%~+3.4% → QQQ 임계값 강화는 효과 없음 - - 손실은 QQQ 방향이 아닌 개별 종목 실패에서 발생 - - DD는 손실 클러스터(Oct/Sep 2025)에서 집중 발생 - - v2 접근법: - 1. Rolling loss governor 강화: 손실 직후 즉시 거래 중단 (-2% threshold) - 2. Partial exit 활성화: 1R(0.75ATR) 도달시 50% 이익 실현 → 많은 거래를 "무조건 수익"으로 - 3. 포지션 축소: risk 5%→2% (손실 기회당 절대액 감소) - 4. 동시 포지션: 3→2 (손실 클러스터링 방지) - 5. QQQ 레짐: 유지 (효과 없음이 증명됨 — 더 강화해도 소용없음) - 6. Streak sizing 비활성화 (안전 우선) - - 200d 검증 결과 (2025-07-03 → 2026-04-20): - - 수익: +36.67% (V23 +109.32% 대비) - - Max DD: -11.54% (고점 대비, 그러나 시작 자본 이하 0일!) - - Sharpe: 2.24 - - 시작 자본($10k) 이하: 0일 (최저점 $10,017 on 2025-07-09) - - 최악의 하루: -$344 (V23 -$981 대비) - - 거래일: 49/200, 거래: 154건 - -strategy_mode: orb - -orb_strategy: - engine_family: gainers_leader - live_readiness: experimental - - orb_minutes: 5 - sim_bar_minutes: 5 - - entry_direction: long_only - order_timeout_minutes: 45 - - allow_doji_breakout: true - allow_red_to_green_breakout: true - - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.50 - min_atr_pct: 0.04 - - min_rvol: 1.5 - min_abs_gap_pct: 0.02 - min_premarket_dollar_vol: 1500000 - max_candidates: 20 - max_candidates_per_sector: 3 - min_candidates_to_trade: 1 - ticker_cooldown_days: 0 - max_gap_pct: 0.04 - - min_candidate_breadth: 0.60 - market_regime_spy_threshold: 0.0015 - market_regime_ticker: QQQ - rolling_loss_days: 5 - # === KEY CHANGE: stop IMMEDIATELY after $200 loss (was -7%) === - rolling_loss_threshold: -0.02 - # === CHANGE: max 2 simultaneous (was 3) === - max_simultaneous_entries: 2 - min_breakout_rel_vol: 1.2 - - weight_rvol: 0.35 - weight_gap: 0.20 - weight_dollar_vol: 0.05 - weight_premarket_dollar_vol: 0.25 - weight_body_ratio: 0.0 - weight_momentum: 0.15 - - atr_stop_multiplier: 0.75 - breakeven_at_r: 1.0 - trailing_at_r: 1.0 - trailing_stop_atr_multiplier: 0.8 - trailing_tighten_at_r: 2.0 - trailing_stop_atr_multiplier_tight: 0.3 - - # === KEY CHANGE: lock in 50% at 1R (was disabled at 99R) === - partial_exit_at_r: 1.0 - partial_exit_pct: 0.50 - - # === CHANGE: smaller per-trade risk (was 0.05) === - risk_per_trade_pct: 0.02 - max_position_pct: 0.70 - # === CHANGE: tighter daily loss cut (was 0.05) === - daily_max_loss_pct: 0.02 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - - slippage_bps: 5.0 - initial_capital: 10000 - - compound_returns: false - daily_budget_reset: true - settlement_days: 1 - - # === CHANGE: tighter portfolio governor (was 0.025) === - drawdown_governor_threshold: 0.015 - drawdown_governor_min_scale: 0.50 - - # === CHANGE: no streak sizing (was bonus=0.70, max=2.5) === - streak_sizing_win_bonus: 0.0 - streak_sizing_max: 1.0 - -universe: - source: midlarge - -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - -cache: - enabled: true - dir: data/cache/intraday - -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/orb_gainers_v23_safe_v3.yaml b/configs/intraday/strategies/orb_gainers_v23_safe_v3.yaml deleted file mode 100644 index 67c6d27..0000000 --- a/configs/intraday/strategies/orb_gainers_v23_safe_v3.yaml +++ /dev/null @@ -1,112 +0,0 @@ -_meta: - id: 31 - name: "ORB Gainers V23 Safe v3" - status: experimental - parent: orb_gainers_v23_safe_v2 - description: > - V23 Safe v3 — "안전 투자자" 최적화. - - v2 분석 결과 (200d): - - +36.67%, DD -11.54%, 시작 자본 이하: 0일 (최저 $10,017) - - Max DD 원인: Nov-Dec 2025 손실 클러스터 - 11/21(-$321) 후 rolling window(5일)가 만료되어 12월에 다시 거래 시작 - → COHR, TSLA, LITE, VST, CYTK 등 연속 손실 - - v3 변경: - 1. rolling_loss_days: 5→10 (손실 기억 기간 연장 → Nov 손실 후 Dec 재진입 방지) - 2. rolling_loss_threshold: -0.02→-0.015 (더 빠른 중단: $150 누적 손실시 정지) - 3. 나머지는 v2 동일 (partial_exit@1R, risk=2%, max_entries=2) - - 목표: 고점 대비 DD를 -8% 이하로 줄이면서 시작 자본 이하 0일 유지 - -strategy_mode: orb - -orb_strategy: - engine_family: gainers_leader - live_readiness: experimental - - orb_minutes: 5 - sim_bar_minutes: 5 - - entry_direction: long_only - order_timeout_minutes: 45 - - allow_doji_breakout: true - allow_red_to_green_breakout: true - - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.50 - min_atr_pct: 0.04 - - min_rvol: 1.5 - min_abs_gap_pct: 0.02 - min_premarket_dollar_vol: 1500000 - max_candidates: 20 - max_candidates_per_sector: 3 - min_candidates_to_trade: 1 - ticker_cooldown_days: 0 - max_gap_pct: 0.04 - - min_candidate_breadth: 0.60 - market_regime_spy_threshold: 0.0015 - market_regime_ticker: QQQ - # === KEY CHANGE: longer loss memory (was 5) === - rolling_loss_days: 10 - # === KEY CHANGE: stop sooner — $150 loss triggers pause (was -0.02) === - rolling_loss_threshold: -0.015 - max_simultaneous_entries: 2 - min_breakout_rel_vol: 1.2 - - weight_rvol: 0.35 - weight_gap: 0.20 - weight_dollar_vol: 0.05 - weight_premarket_dollar_vol: 0.25 - weight_body_ratio: 0.0 - weight_momentum: 0.15 - - atr_stop_multiplier: 0.75 - breakeven_at_r: 1.0 - trailing_at_r: 1.0 - trailing_stop_atr_multiplier: 0.8 - trailing_tighten_at_r: 2.0 - trailing_stop_atr_multiplier_tight: 0.3 - - # 50% partial exit at 1R (lock in gains early) - partial_exit_at_r: 1.0 - partial_exit_pct: 0.50 - - risk_per_trade_pct: 0.02 - max_position_pct: 0.70 - daily_max_loss_pct: 0.02 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - - slippage_bps: 5.0 - initial_capital: 10000 - - compound_returns: false - daily_budget_reset: true - settlement_days: 1 - - drawdown_governor_threshold: 0.015 - drawdown_governor_min_scale: 0.50 - - streak_sizing_win_bonus: 0.0 - streak_sizing_max: 1.0 - -universe: - source: midlarge - -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - -cache: - enabled: true - dir: data/cache/intraday - -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/orb_gainers_v23_safe_v9.yaml b/configs/intraday/strategies/orb_gainers_v23_safe_v9.yaml deleted file mode 100644 index 94f037b..0000000 --- a/configs/intraday/strategies/orb_gainers_v23_safe_v9.yaml +++ /dev/null @@ -1,107 +0,0 @@ -_meta: - id: 37 - name: "ORB Gainers V23 Safe v9" - status: validated_200d_only - parent: orb_gainers_v23_safe_v8 - description: > - V23 Safe v9 — v8 + streak_sizing_win_bonus: 0.70 (V23 level streak sizing). - VALIDATED champion of the Safe family on 200d window ONLY (2026-04-21). - 주의: 400d에서는 V23이 모든 지표에서 완전히 우월 — +146% vs +101%, DD -13.7% vs -17.2%. - v9는 200d 단기 보수적 대안으로만 유효. 실전 배포 기준은 V23. - - Safe v8 결과: +82.36%, DD -7.72%, Sharpe 3.14 — V23 Sharpe(3.01)보다 높고 DD는 5pp 낮음. - 단, 수익은 V23(+109.32%)보다 27pp 낮음. 차이 원인: V23의 streak sizing(win_bonus=0.70). - V23에서 streak_sizing은 핵심 수익 증폭기 (V19→V21 승진에 기여). - - v9 가설: v8 safe mechanisms(partial_exit + rolling_loss-2% + max_sim=2) + V23의 - streak_sizing(0.70) = +100%+ 수익 AND DD < V23 -12.91%? - - 200d 결과 (2025-07-03→2026-04-20): +101.25%, DD -7.62%, WR 58.06%, Sharpe 3.34 - worst_day -$324, trade_days 46/200, 124 trades. - 400d 결과 (2024-09-13→2026-04-20): +101.01%, DD -17.20%, WR 57.08%, Sharpe 1.99 - profit_factor 1.77, worst_day -6.11%, 226 trades, 93 trade days. - 400d gate: DD -17.20% ≤ -18% ✓ AND return +101% ≥ +90% ✓ → PROMOTED. - V23 대비 (200d): DD -5.29pp 개선 (-7.62% vs -12.91%); Sharpe +0.33 우위; - 수익은 -8pp 낮음 (-101.25% vs +109.32%). - - *** 2026-04-21 UPDATE: V23 TRUE 400d result confirmed with correct pipeline *** - V23 400d TRUE: +146.09%, DD -13.66%, Sharpe 2.33 (vs v9: +101.01%, DD -17.20%, Sharpe 1.99) - V23 STRICTLY DOMINATES Safe v9 on 400d in return (+45pp), DD (+3.5pp better), and Sharpe. - "Risk-adjusted superior" claim is ONLY valid on 200d window. On 400d, V23 is also safer. - V23 is the absolute champion. v9 remains valid as 200d conservative alternative only. - -strategy_mode: orb - -orb_strategy: - engine_family: gainers_leader - live_readiness: experimental - orb_minutes: 5 - sim_bar_minutes: 5 - entry_direction: long_only - order_timeout_minutes: 45 - allow_doji_breakout: true - allow_red_to_green_breakout: true - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.50 - min_atr_pct: 0.04 - min_rvol: 1.5 - min_abs_gap_pct: 0.02 - min_premarket_dollar_vol: 1500000 - max_candidates: 20 - max_candidates_per_sector: 3 - min_candidates_to_trade: 1 - ticker_cooldown_days: 0 - max_gap_pct: 0.04 - min_candidate_breadth: 0.60 - market_regime_spy_threshold: 0.0015 - market_regime_ticker: QQQ - rolling_loss_days: 7 - rolling_loss_threshold: -0.02 - max_simultaneous_entries: 2 - min_breakout_rel_vol: 1.2 - weight_rvol: 0.35 - weight_gap: 0.20 - weight_dollar_vol: 0.05 - weight_premarket_dollar_vol: 0.25 - weight_body_ratio: 0.0 - weight_momentum: 0.15 - atr_stop_multiplier: 0.75 - breakeven_at_r: 1.0 - trailing_at_r: 1.0 - trailing_stop_atr_multiplier: 0.8 - trailing_tighten_at_r: 2.0 - trailing_stop_atr_multiplier_tight: 0.3 - partial_exit_at_r: 1.0 - partial_exit_pct: 0.50 - risk_per_trade_pct: 0.05 - max_position_pct: 0.70 - daily_max_loss_pct: 0.02 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - compound_returns: false - daily_budget_reset: true - settlement_days: 1 - drawdown_governor_threshold: 0.015 - drawdown_governor_min_scale: 0.50 - # === KEY CHANGE: enable streak sizing (V23 level) === - streak_sizing_win_bonus: 0.70 - streak_sizing_max: 2.5 - -universe: - source: midlarge - -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - -cache: - enabled: true - dir: data/cache/intraday - -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/orb_gainers_v23_safe_v6.yaml b/configs/intraday/strategies/orb_gainers_v24_1_candidate_entrycap.yaml similarity index 63% rename from configs/intraday/strategies/orb_gainers_v23_safe_v6.yaml rename to configs/intraday/strategies/orb_gainers_v24_1_candidate_entrycap.yaml index 55090d2..d93e27b 100644 --- a/configs/intraday/strategies/orb_gainers_v23_safe_v6.yaml +++ b/configs/intraday/strategies/orb_gainers_v24_1_candidate_entrycap.yaml @@ -1,16 +1,13 @@ _meta: - id: 34 - name: "ORB Gainers V23 Safe v6" - status: experimental - parent: orb_gainers_v23_safe_v4 + id: 105 + name: "ORB Gainers V24.1 Candidate Entry Cap" + status: research + live_readiness: experimental + parent: orb_gainers_v24_quality_overlay description: > - V23 Safe v6 — v4 + risk_per_trade_pct 2%→3%. - - Safe v5 (atr×0.50) 실패: +39.11% 수익이지만 DD -13.81% (v4 -11.01% 대비 +2.8pp 악화), worst_day -$509. - Hypothesis: atr 조정이 아닌 per-trade risk 증가가 더 효율적. - - v6 가설: 2%→3% risk 증가 시 partial_exit + rolling_loss + 2-simultaneous 안전장치가 - DD를 V23 기준(-12.91%) 이하로 유지하면서 수익을 +50~55%로 끌어올릴 수 있는가? + Narrow candidate for V24.1. Keeps V24 unchanged except reducing + max_simultaneous_entries from 3 to 2 to lower correlated 09:35-10:15 + burst risk without altering signal ranking or stop logic. strategy_mode: orb @@ -19,14 +16,18 @@ orb_strategy: live_readiness: experimental orb_minutes: 5 sim_bar_minutes: 5 + entry_direction: long_only order_timeout_minutes: 45 + allow_doji_breakout: true allow_red_to_green_breakout: true + min_price: 10.0 min_avg_dollar_volume: 25000000 min_atr_14: 0.50 min_atr_pct: 0.04 + min_rvol: 1.5 min_abs_gap_pct: 0.02 min_premarket_dollar_vol: 1500000 @@ -35,42 +36,51 @@ orb_strategy: min_candidates_to_trade: 1 ticker_cooldown_days: 0 max_gap_pct: 0.04 + min_candidate_breadth: 0.60 market_regime_spy_threshold: 0.0015 market_regime_ticker: QQQ rolling_loss_days: 7 - rolling_loss_threshold: -0.02 + rolling_loss_threshold: -0.07 max_simultaneous_entries: 2 min_breakout_rel_vol: 1.2 + weight_rvol: 0.35 weight_gap: 0.20 weight_dollar_vol: 0.05 weight_premarket_dollar_vol: 0.25 weight_body_ratio: 0.0 weight_momentum: 0.15 + weight_obv_slope: 0.05 + atr_stop_multiplier: 0.75 breakeven_at_r: 1.0 trailing_at_r: 1.0 trailing_stop_atr_multiplier: 0.8 trailing_tighten_at_r: 2.0 trailing_stop_atr_multiplier_tight: 0.3 - partial_exit_at_r: 1.0 + + partial_exit_at_r: 99.0 partial_exit_pct: 0.50 - # === KEY CHANGE: 2%→3% per-trade risk === - risk_per_trade_pct: 0.03 + + risk_per_trade_pct: 0.05 max_position_pct: 0.70 - daily_max_loss_pct: 0.02 - max_stops_per_day: 3 + daily_max_loss_pct: 0.05 + max_stops_per_day: 5 exit_minutes_before_close: 5 + slippage_bps: 5.0 initial_capital: 10000 + compound_returns: false daily_budget_reset: true settlement_days: 1 - drawdown_governor_threshold: 0.015 - drawdown_governor_min_scale: 0.50 - streak_sizing_win_bonus: 0.0 - streak_sizing_max: 1.0 + + drawdown_governor_threshold: 0.025 + drawdown_governor_min_scale: 0.30 + + streak_sizing_win_bonus: 0.70 + streak_sizing_max: 2.5 universe: source: midlarge diff --git a/configs/intraday/strategies/orb_gainers_v24_1_candidate_losscap010.yaml b/configs/intraday/strategies/orb_gainers_v24_1_candidate_losscap010.yaml new file mode 100644 index 0000000..19c036a --- /dev/null +++ b/configs/intraday/strategies/orb_gainers_v24_1_candidate_losscap010.yaml @@ -0,0 +1,108 @@ +_meta: + id: 106 + name: "ORB Gainers V24.1 Candidate Loss Cap 10%" + status: research + live_readiness: experimental + parent: orb_gainers_v24_quality_overlay + description: > + Narrow candidate for V24.1. Keeps V24 signal logic unchanged and adds + single_trade_loss_cap_pct: 0.10 to trim only the most aggressive + streak-sized exposures. + + Rationale: + - V24 keeps streak_sizing_win_bonus: 0.70, streak_sizing_max: 2.5 + - With risk_per_trade_pct: 0.05, a fully boosted trade can risk 12.5% + of initial capital, which is structurally misaligned with + daily_max_loss_pct: 0.05 + - single_trade_loss_cap_pct: 0.10 caps only the extreme tail + (2.5x -> 2.0x max effective sizing) while preserving normal-day behavior + +strategy_mode: orb + +orb_strategy: + engine_family: gainers_leader + live_readiness: experimental + orb_minutes: 5 + sim_bar_minutes: 5 + + entry_direction: long_only + order_timeout_minutes: 45 + + allow_doji_breakout: true + allow_red_to_green_breakout: true + + min_price: 10.0 + min_avg_dollar_volume: 25000000 + min_atr_14: 0.50 + min_atr_pct: 0.04 + + min_rvol: 1.5 + min_abs_gap_pct: 0.02 + min_premarket_dollar_vol: 1500000 + max_candidates: 20 + max_candidates_per_sector: 3 + min_candidates_to_trade: 1 + ticker_cooldown_days: 0 + max_gap_pct: 0.04 + + min_candidate_breadth: 0.60 + market_regime_spy_threshold: 0.0015 + market_regime_ticker: QQQ + rolling_loss_days: 7 + rolling_loss_threshold: -0.07 + max_simultaneous_entries: 3 + min_breakout_rel_vol: 1.2 + + weight_rvol: 0.35 + weight_gap: 0.20 + weight_dollar_vol: 0.05 + weight_premarket_dollar_vol: 0.25 + weight_body_ratio: 0.0 + weight_momentum: 0.15 + weight_obv_slope: 0.05 + + atr_stop_multiplier: 0.75 + breakeven_at_r: 1.0 + trailing_at_r: 1.0 + trailing_stop_atr_multiplier: 0.8 + trailing_tighten_at_r: 2.0 + trailing_stop_atr_multiplier_tight: 0.3 + + partial_exit_at_r: 99.0 + partial_exit_pct: 0.50 + + risk_per_trade_pct: 0.05 + max_position_pct: 0.70 + daily_max_loss_pct: 0.05 + max_stops_per_day: 5 + exit_minutes_before_close: 5 + + slippage_bps: 5.0 + initial_capital: 10000 + + compound_returns: false + daily_budget_reset: true + settlement_days: 1 + + drawdown_governor_threshold: 0.025 + drawdown_governor_min_scale: 0.30 + + streak_sizing_win_bonus: 0.70 + streak_sizing_max: 2.5 + single_trade_loss_cap_pct: 0.10 + +universe: + source: midlarge + +backtest: + start_date: null + end_date: null + lookback_trading_days: 200 + +cache: + enabled: true + dir: data/cache/intraday + +output: + dir: runs/intraday_orb + verbose: false diff --git a/configs/intraday/strategies/orb_gainers_v23_safe_v8.yaml b/configs/intraday/strategies/orb_gainers_v24_1_candidate_w002.yaml similarity index 61% rename from configs/intraday/strategies/orb_gainers_v23_safe_v8.yaml rename to configs/intraday/strategies/orb_gainers_v24_1_candidate_w002.yaml index 2662ae9..7997468 100644 --- a/configs/intraday/strategies/orb_gainers_v23_safe_v8.yaml +++ b/configs/intraday/strategies/orb_gainers_v24_1_candidate_w002.yaml @@ -1,17 +1,13 @@ _meta: - id: 36 - name: "ORB Gainers V23 Safe v8" - status: experimental - parent: orb_gainers_v23_safe_v7 + id: 101 + name: "ORB Gainers V24.1 Candidate W0.02" + status: research + live_readiness: experimental + parent: orb_gainers_v24_quality_overlay description: > - V23 Safe v8 — v7 + risk_per_trade_pct 4%→5% (V23 level). - - Risk sweep trend (2026-04-21): - 2%→+36.78% DD-11.01% SR2.18 / 3%→+63.84% DD-8.90% SR2.79 / 4%→+75.63% DD-8.61% SR3.02 - 모든 메트릭이 단조 개선! V23(5% risk)은 +109.32% DD-12.91% SR3.01. - - v8 가설: V23 risk(5%) + safe mechanisms(partial_exit/rolling_loss-2%/max_sim=2)이 V23보다 - 높은 Sharpe와 낮은 DD로 유사한 수익을 낼 수 있는가? (V23에서 aggressive 파라미터만 제거) + Research candidate for V24.1. Retains the V24 OBV overlay but reduces + weight_obv_slope from 0.05 to 0.02 to test whether the current engine + prefers a lighter accumulation bias. strategy_mode: orb @@ -20,14 +16,18 @@ orb_strategy: live_readiness: experimental orb_minutes: 5 sim_bar_minutes: 5 + entry_direction: long_only order_timeout_minutes: 45 + allow_doji_breakout: true allow_red_to_green_breakout: true + min_price: 10.0 min_avg_dollar_volume: 25000000 min_atr_14: 0.50 min_atr_pct: 0.04 + min_rvol: 1.5 min_abs_gap_pct: 0.02 min_premarket_dollar_vol: 1500000 @@ -36,42 +36,51 @@ orb_strategy: min_candidates_to_trade: 1 ticker_cooldown_days: 0 max_gap_pct: 0.04 + min_candidate_breadth: 0.60 market_regime_spy_threshold: 0.0015 market_regime_ticker: QQQ rolling_loss_days: 7 - rolling_loss_threshold: -0.02 - max_simultaneous_entries: 2 + rolling_loss_threshold: -0.07 + max_simultaneous_entries: 3 min_breakout_rel_vol: 1.2 + weight_rvol: 0.35 weight_gap: 0.20 weight_dollar_vol: 0.05 weight_premarket_dollar_vol: 0.25 weight_body_ratio: 0.0 weight_momentum: 0.15 + weight_obv_slope: 0.02 + atr_stop_multiplier: 0.75 breakeven_at_r: 1.0 trailing_at_r: 1.0 trailing_stop_atr_multiplier: 0.8 trailing_tighten_at_r: 2.0 trailing_stop_atr_multiplier_tight: 0.3 - partial_exit_at_r: 1.0 + + partial_exit_at_r: 99.0 partial_exit_pct: 0.50 - # === KEY CHANGE: 4%→5% per-trade risk (V23 level) === + risk_per_trade_pct: 0.05 max_position_pct: 0.70 - daily_max_loss_pct: 0.02 - max_stops_per_day: 3 + daily_max_loss_pct: 0.05 + max_stops_per_day: 5 exit_minutes_before_close: 5 + slippage_bps: 5.0 initial_capital: 10000 + compound_returns: false daily_budget_reset: true settlement_days: 1 - drawdown_governor_threshold: 0.015 - drawdown_governor_min_scale: 0.50 - streak_sizing_win_bonus: 0.0 - streak_sizing_max: 1.0 + + drawdown_governor_threshold: 0.025 + drawdown_governor_min_scale: 0.30 + + streak_sizing_win_bonus: 0.70 + streak_sizing_max: 2.5 universe: source: midlarge diff --git a/configs/intraday/strategies/orb_gainers_v23_safe_v4.yaml b/configs/intraday/strategies/orb_gainers_v24_1_candidate_w003.yaml similarity index 61% rename from configs/intraday/strategies/orb_gainers_v23_safe_v4.yaml rename to configs/intraday/strategies/orb_gainers_v24_1_candidate_w003.yaml index 4ab5667..62a594c 100644 --- a/configs/intraday/strategies/orb_gainers_v23_safe_v4.yaml +++ b/configs/intraday/strategies/orb_gainers_v24_1_candidate_w003.yaml @@ -1,21 +1,13 @@ _meta: - id: 32 - name: "ORB Gainers V23 Safe v4" - status: validated - parent: orb_gainers_v23_safe_v2 + id: 102 + name: "ORB Gainers V24.1 Candidate W0.03" + status: research + live_readiness: experimental + parent: orb_gainers_v24_quality_overlay description: > - V23 Safe v4 — v2 + rolling_loss_days 5→7. - - v2 max DD 원인: Nov 21 손실 후 rolling window 5일 만료로 Dec 2에 재진입 허용. - 7일 window로 Nov 손실이 Dec 2까지 기억됨 → Dec 재진입 방지. - - 200d 검증 결과 (2025-07-03 → 2026-04-20): - - 수익: +36.78% (v2 +36.67% 대비 +0.11pp) - - Max DD: -11.01% (v2 -11.54% 대비 개선) - - Win Rate: 56.77% - - Sharpe: 2.18 (v2 2.24 대비 미소 하락) - - 최악의 하루: -$339 (v2 -$344 대비) - - 거래일: 48/200, 거래: 155건 + Research candidate for V24.1. Retains the V24 OBV overlay but reduces + weight_obv_slope from 0.05 to 0.03 to test whether the current engine + prefers a lighter accumulation bias. strategy_mode: orb @@ -24,14 +16,18 @@ orb_strategy: live_readiness: experimental orb_minutes: 5 sim_bar_minutes: 5 + entry_direction: long_only order_timeout_minutes: 45 + allow_doji_breakout: true allow_red_to_green_breakout: true + min_price: 10.0 min_avg_dollar_volume: 25000000 min_atr_14: 0.50 min_atr_pct: 0.04 + min_rvol: 1.5 min_abs_gap_pct: 0.02 min_premarket_dollar_vol: 1500000 @@ -40,41 +36,51 @@ orb_strategy: min_candidates_to_trade: 1 ticker_cooldown_days: 0 max_gap_pct: 0.04 + min_candidate_breadth: 0.60 market_regime_spy_threshold: 0.0015 market_regime_ticker: QQQ rolling_loss_days: 7 - rolling_loss_threshold: -0.02 - max_simultaneous_entries: 2 + rolling_loss_threshold: -0.07 + max_simultaneous_entries: 3 min_breakout_rel_vol: 1.2 + weight_rvol: 0.35 weight_gap: 0.20 weight_dollar_vol: 0.05 weight_premarket_dollar_vol: 0.25 weight_body_ratio: 0.0 weight_momentum: 0.15 + weight_obv_slope: 0.03 + atr_stop_multiplier: 0.75 breakeven_at_r: 1.0 trailing_at_r: 1.0 trailing_stop_atr_multiplier: 0.8 trailing_tighten_at_r: 2.0 trailing_stop_atr_multiplier_tight: 0.3 - partial_exit_at_r: 1.0 + + partial_exit_at_r: 99.0 partial_exit_pct: 0.50 - risk_per_trade_pct: 0.02 + + risk_per_trade_pct: 0.05 max_position_pct: 0.70 - daily_max_loss_pct: 0.02 - max_stops_per_day: 3 + daily_max_loss_pct: 0.05 + max_stops_per_day: 5 exit_minutes_before_close: 5 + slippage_bps: 5.0 initial_capital: 10000 + compound_returns: false daily_budget_reset: true settlement_days: 1 - drawdown_governor_threshold: 0.015 - drawdown_governor_min_scale: 0.50 - streak_sizing_win_bonus: 0.0 - streak_sizing_max: 1.0 + + drawdown_governor_threshold: 0.025 + drawdown_governor_min_scale: 0.30 + + streak_sizing_win_bonus: 0.70 + streak_sizing_max: 2.5 universe: source: midlarge diff --git a/configs/intraday/strategies/orb_gainers_v23_safe_v7.yaml b/configs/intraday/strategies/orb_gainers_v24_1_candidate_w004.yaml similarity index 59% rename from configs/intraday/strategies/orb_gainers_v23_safe_v7.yaml rename to configs/intraday/strategies/orb_gainers_v24_1_candidate_w004.yaml index 2558c9f..59d05c1 100644 --- a/configs/intraday/strategies/orb_gainers_v23_safe_v7.yaml +++ b/configs/intraday/strategies/orb_gainers_v24_1_candidate_w004.yaml @@ -1,18 +1,13 @@ _meta: - id: 35 - name: "ORB Gainers V23 Safe v7" - status: experimental - parent: orb_gainers_v23_safe_v6 + id: 103 + name: "ORB Gainers V24.1 Candidate W0.04" + status: research + live_readiness: experimental + parent: orb_gainers_v24_quality_overlay description: > - V23 Safe v7 — v6 + risk_per_trade_pct 3%→4%. - - Safe v6 breakthrough (2026-04-21): risk 2%→3% dramatically improved ALL metrics: - +63.84% return (+27pp vs v4), DD -8.90% (better than v4's -11.01%), WR 57.46%, Sharpe 2.79. - Counterintuitive: higher risk → better DD%. Cause: larger wins elevate peak equity faster, - same absolute $ drawdowns = smaller % DD. - - v7 가설: risk 3%→4% 시 수익 추가 향상 (≥+85%) 하면서 DD는 V23(-12.91%) 이하 유지? - V23 risk=5%일 때 +109.32%이므로, 4%는 중간 지점 탐색. + Research candidate for V24.1. Retains the V24 OBV overlay but reduces + weight_obv_slope from 0.05 to 0.04 to test whether the current engine + prefers a lighter accumulation bias. strategy_mode: orb @@ -21,14 +16,18 @@ orb_strategy: live_readiness: experimental orb_minutes: 5 sim_bar_minutes: 5 + entry_direction: long_only order_timeout_minutes: 45 + allow_doji_breakout: true allow_red_to_green_breakout: true + min_price: 10.0 min_avg_dollar_volume: 25000000 min_atr_14: 0.50 min_atr_pct: 0.04 + min_rvol: 1.5 min_abs_gap_pct: 0.02 min_premarket_dollar_vol: 1500000 @@ -37,42 +36,51 @@ orb_strategy: min_candidates_to_trade: 1 ticker_cooldown_days: 0 max_gap_pct: 0.04 + min_candidate_breadth: 0.60 market_regime_spy_threshold: 0.0015 market_regime_ticker: QQQ rolling_loss_days: 7 - rolling_loss_threshold: -0.02 - max_simultaneous_entries: 2 + rolling_loss_threshold: -0.07 + max_simultaneous_entries: 3 min_breakout_rel_vol: 1.2 + weight_rvol: 0.35 weight_gap: 0.20 weight_dollar_vol: 0.05 weight_premarket_dollar_vol: 0.25 weight_body_ratio: 0.0 weight_momentum: 0.15 + weight_obv_slope: 0.04 + atr_stop_multiplier: 0.75 breakeven_at_r: 1.0 trailing_at_r: 1.0 trailing_stop_atr_multiplier: 0.8 trailing_tighten_at_r: 2.0 trailing_stop_atr_multiplier_tight: 0.3 - partial_exit_at_r: 1.0 + + partial_exit_at_r: 99.0 partial_exit_pct: 0.50 - # === KEY CHANGE: 3%→4% per-trade risk === - risk_per_trade_pct: 0.04 + + risk_per_trade_pct: 0.05 max_position_pct: 0.70 - daily_max_loss_pct: 0.02 - max_stops_per_day: 3 + daily_max_loss_pct: 0.05 + max_stops_per_day: 5 exit_minutes_before_close: 5 + slippage_bps: 5.0 initial_capital: 10000 + compound_returns: false daily_budget_reset: true settlement_days: 1 - drawdown_governor_threshold: 0.015 - drawdown_governor_min_scale: 0.50 - streak_sizing_win_bonus: 0.0 - streak_sizing_max: 1.0 + + drawdown_governor_threshold: 0.025 + drawdown_governor_min_scale: 0.30 + + streak_sizing_win_bonus: 0.70 + streak_sizing_max: 2.5 universe: source: midlarge diff --git a/configs/intraday/strategies/orb_gainers_v24_losscap.yaml b/configs/intraday/strategies/orb_gainers_v24_losscap.yaml deleted file mode 100644 index fc176d4..0000000 --- a/configs/intraday/strategies/orb_gainers_v24_losscap.yaml +++ /dev/null @@ -1,124 +0,0 @@ -_meta: - id: 29 - name: "ORB Gainers V24 LossCap" - description: > - [DOCUMENTED FAILURE — NOT PROMOTED] - V23 → V24 via single change: single_trade_loss_cap_pct=0.05 - - Result: +92.6% (200d), WR 58.0%, Sharpe 3.00, DD -9.0% — return -55.5pp vs V23. - Also tested cap=0.10 (-33pp) and streak_max=2.0 (-36pp). All failed. - Root cause: streak sizing amplifies wins AND losses symmetrically. - Capping losses also caps wins proportionally → unavoidable trade-off. - V23 HIMS -4.63% loss is a designed -1R at streak×2.4 — not a fixable bug. - - Original hypothesis: streak_sizing_max=2.5 creates structural misalignment where a single -1R trade - can consume 2.5× daily_max_loss_pct worth of capital (e.g. 2.4× streak → $1,200 loss on - $10k initial, while daily_max_loss_pct=0.05 intent is $500 max). - - Fix: after all sizing boosts (governor + streak + rolling WR), clamp sizing_capital so - that risk_per_trade_pct × sizing_capital ≤ single_trade_loss_cap_pct × initial_capital. - With risk_per_trade_pct=0.05 and cap=0.05: max sizing = $10,000 = initial_capital. - - Example (2026-04-17 HIMS): - Without cap: streak 2.4× → sizing $24k → risk $1,200 → loss -4.63% of portfolio - With cap: sizing clamped to $10k → risk $500 → loss ~-1.92% of portfolio - - Trade-off: streak bonus is capped for loss protection, but also for wins (smaller positions - on winning streaks). Net effect on WR and return is the test hypothesis. - - Validation: - V23 200d TRUE BASELINE: +109.32%, WR 58.1%, DD -12.91%, Sharpe 3.01, 160 trades - Gates (200d): return ≥ +104%, single max loss ≤ $500, 2026-04-17 daily ≤ -2.5% - Gates (400d): return ≥ +88%, WR ≥ 52%, DD ≤ -24% - -strategy_mode: orb - -orb_strategy: - engine_family: gainers_leader - live_readiness: live_ready - - orb_minutes: 5 - sim_bar_minutes: 5 - - entry_direction: long_only - order_timeout_minutes: 45 - - allow_doji_breakout: true - allow_red_to_green_breakout: true - - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.50 - min_atr_pct: 0.04 - min_rvol: 1.5 - min_abs_gap_pct: 0.02 - min_premarket_dollar_vol: 1500000 - max_candidates: 20 - max_candidates_per_sector: 3 - min_candidates_to_trade: 1 - ticker_cooldown_days: 0 - max_gap_pct: 0.04 - - min_candidate_breadth: 0.60 - market_regime_spy_threshold: 0.0015 - market_regime_ticker: QQQ - rolling_loss_days: 7 - rolling_loss_threshold: -0.07 - max_simultaneous_entries: 3 - min_breakout_rel_vol: 1.2 - - weight_rvol: 0.35 - weight_gap: 0.20 - weight_dollar_vol: 0.05 - weight_premarket_dollar_vol: 0.25 - weight_body_ratio: 0.0 - weight_momentum: 0.15 - - atr_stop_multiplier: 0.75 - breakeven_at_r: 1.0 - trailing_at_r: 1.0 - trailing_stop_atr_multiplier: 0.8 - trailing_tighten_at_r: 2.0 - trailing_stop_atr_multiplier_tight: 0.3 - - partial_exit_at_r: 99.0 - partial_exit_pct: 0.50 - - risk_per_trade_pct: 0.05 - max_position_pct: 0.70 - daily_max_loss_pct: 0.05 - max_stops_per_day: 5 - exit_minutes_before_close: 5 - - # === CHANGE: cap single-trade loss at 5% of initial_capital (= $500 on $10k) === - # Prevents streak boost from amplifying -1R losses beyond daily_max_loss intent. - single_trade_loss_cap_pct: 0.05 - - slippage_bps: 5.0 - initial_capital: 10000 - - compound_returns: false - daily_budget_reset: true - settlement_days: 1 - - drawdown_governor_threshold: 0.025 - drawdown_governor_min_scale: 0.30 - - streak_sizing_win_bonus: 0.70 - streak_sizing_max: 2.5 - -universe: - source: midlarge - -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - -cache: - enabled: true - dir: data/cache/intraday - -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/orb_pullback_v1.yaml b/configs/intraday/strategies/orb_pullback_v1.yaml deleted file mode 100644 index 161ee1b..0000000 --- a/configs/intraday/strategies/orb_pullback_v1.yaml +++ /dev/null @@ -1,166 +0,0 @@ -_meta: - id: 35 - name: "ORB Pullback V1" - status: documented_failure - description: > - [DOCUMENTED FAILURE — NOT PROMOTED] - - Phase 1 attempt: V23's gainers_leader candidate pool + pullback continuation entry. - - Diagnostic result (200d): - - Base pullback (no quality gates): 133 trades, WR 25.6%, return -10.14% - - All quality filters (impulse_min, depth_max, vol_contraction, vwap_floor): negative selection - Adding each filter either left WR unchanged or DECREASED it (min 13.3%) - - V23 immediate-entry same pool: WR 61.5% (+36pp gap) - - Root cause: V23's candidate pool selects stocks that immediately continue after breakout. - Waiting for a pullback negatively selects against V23's edge — catches the stocks - that stall (typically failing breakouts). All 5 quality filters showed negative selection; - this is NOT a tunable parameter problem but a structural incompatibility. - - Conclusion: orb_pullback_v1 on V23 candidates = negative-EV. Pivoting to vwap_reclaim_v1. - - Original Phase 1 multi-engine hypothesis: - - Engine: orb_pullback_v1 (independent engine_family, not a V23 variant). - Same candidate universe and scoring as V23 (gainers_leader candidate pool). - Different entry: instead of immediate ORB breakout, waits for: - 1. Post-breakout impulse peak within 9:40-9:55 ET window - 2. Pullback of 25-50% of impulse move (with volume contraction) - 3. VWAP floor check (pullback can't breach VWAP by >0.3%) - 4. Continuation bar: green + above pullback extreme - - Stop: pullback_low (structural) rather than pure ATR. - - Gates (standalone 200d): - trades >= 50, WR >= 50%, total_return >= 0%, max_dd >= -20% - Portfolio gates (combined with V23, 600d): - trade_overlap <= 20%, daily_pnl_corr <= 0.30, - combined_600d_dd improvement >= 5pp vs V23 standalone (-51.25%) - - Evaluation: not standalone — portfolio contribution to V23 is the target metric. - Run via apps/intraday_bt/portfolio_report.py for combined analysis. - - Initial capital intentionally lower ($4000) for composite sleeve weighting (40% - of a hypothetical $10k combined portfolio). For standalone comparison use $10000. - -strategy_mode: orb - -orb_strategy: - engine_family: orb_pullback_v1 - live_readiness: research_only - - orb_minutes: 5 - sim_bar_minutes: 5 - - entry_direction: long_only - order_timeout_minutes: 45 - - allow_doji_breakout: true - allow_red_to_green_breakout: true - - # === Candidate filters identical to V23 (gainers_leader pool) === - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.50 - min_atr_pct: 0.04 - min_rvol: 1.5 - min_abs_gap_pct: 0.02 - min_premarket_dollar_vol: 1500000 - max_candidates: 20 - max_candidates_per_sector: 3 - min_candidates_to_trade: 1 - ticker_cooldown_days: 0 - max_gap_pct: 0.04 - - min_candidate_breadth: 0.60 - market_regime_spy_threshold: 0.0015 - market_regime_ticker: QQQ - rolling_loss_days: 7 - rolling_loss_threshold: -0.07 - max_simultaneous_entries: 3 - min_breakout_rel_vol: 1.2 - - # === Scoring weights identical to V23 === - weight_rvol: 0.35 - weight_gap: 0.20 - weight_dollar_vol: 0.05 - weight_premarket_dollar_vol: 0.25 - weight_body_ratio: 0.0 - weight_momentum: 0.15 - - # === Stop / exit parameters (base ATR same as V23) === - atr_stop_multiplier: 0.75 - breakeven_at_r: 1.0 - trailing_at_r: 1.0 - trailing_stop_atr_multiplier: 0.8 - trailing_tighten_at_r: 2.0 - trailing_stop_atr_multiplier_tight: 0.3 - - partial_exit_at_r: 99.0 - partial_exit_pct: 0.50 - - risk_per_trade_pct: 0.05 - max_position_pct: 0.70 - daily_max_loss_pct: 0.05 - max_stops_per_day: 5 - exit_minutes_before_close: 5 - - slippage_bps: 5.0 - initial_capital: 10000 - - compound_returns: false - daily_budget_reset: true - settlement_days: 1 - - drawdown_governor_threshold: 0.025 - drawdown_governor_min_scale: 0.30 - - streak_sizing_win_bonus: 0.70 - streak_sizing_max: 2.5 - - # === Pullback entry — core engine feature === - pullback_entry: true - pullback_max_bars: 8 - pullback_min_retracement_pct: 0.25 - pullback_stop_at_low: true - - # === Extended pullback controls (orb_pullback_v1) === - # Impulse peak must form by 9:55 ET (25 min from open) - pullback_impulse_window_end_min: 25 - - # Impulse must move at least 0.4× ATR above breakout level - pullback_impulse_min_move_atr: 0.4 - - # Pullback depth: 25% to 60% of impulse move - pullback_depth_max_pct: 0.60 - - # Pullback phase must have lower avg volume than impulse phase (70% threshold) - pullback_volume_contraction_ratio: 0.70 - - # Abort if pullback penetrates VWAP by more than 0.3% - pullback_vwap_floor: true - pullback_vwap_floor_tolerance_pct: 0.003 - - # Stop: structural pullback low (not VWAP — cleaner for initial testing) - pullback_stop_mode: pullback_low - pullback_stop_vwap_buffer_pct: 0.002 - - # Reclaim bar must have 1.2× average post-ORB bar volume - pullback_reclaim_confirm_rel_vol: 1.2 - -universe: - source: midlarge - -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - -cache: - enabled: true - dir: data/cache/intraday - -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/vwap_reclaim_v1.yaml b/configs/intraday/strategies/vwap_reclaim_v1.yaml deleted file mode 100644 index 6efd881..0000000 --- a/configs/intraday/strategies/vwap_reclaim_v1.yaml +++ /dev/null @@ -1,123 +0,0 @@ -_meta: - id: 36 - name: "VWAP Reclaim V1" - status: aborted - aborted_date: "2026-04-21" - aborted_reason: > - Same gainers pool as V23 → 57.3% trade overlap (fails ≤20% gate). Orthogonal high-gap - variant (id:37) reduced overlap to 16.5% but daily PnL corr=0.394 (fails ≤0.30 gate). - Root cause: correlation is regime-driven (both long-momentum, both triggered by same - QQQ-positive days) — not fixable by any stock-selection filter. VWAP stop mode broke - position sizing (entry ≈ VWAP → stop_distance ≈ 0 → overleverage → WR 16%). Baseline - +13.82%/WR 48% does not beat V23 (+95.67%/WR 58%). Not a valid diversifier. - description: > - Phase 2 / diagnostic pass: V23's gainers_leader candidate pool + minimal VWAP reclaim entry. - - Engine: vwap_reclaim_v1 — same pre-market candidates as V23, but instead of entering - on the 9:30-9:35 ORB breakout, scans from 10:00 ET (30 min from open) for the first - bar that closes above the running session VWAP. - - Hypothesis: catalyst stocks spend the first 20-30 min in price discovery. - A VWAP close-above in the 10:00-11:30 window signals committed direction. - - Diagnostic purpose: determine if V23's candidate pool structurally supports - a late-morning entry (vs. negative selection like orb_pullback_v1 showed). - Gate: base WR ≥ 45% (vs. pullback's 25.6%). If fails → wrong pool. - - This config uses zero quality gates (no tightness, no base, no vol filter) — - purely "first bar closing above VWAP in [10:00, 11:30]". - -strategy_mode: orb - -orb_strategy: - engine_family: vwap_reclaim_v1 - live_readiness: research_only - - orb_minutes: 5 - sim_bar_minutes: 5 - - entry_direction: long_only - order_timeout_minutes: 120 # not used for entry, but sets the timeout context - - allow_doji_breakout: true - allow_red_to_green_breakout: true - - # === Candidate filters identical to V23 (gainers_leader pool) === - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.50 - min_atr_pct: 0.04 - min_rvol: 1.5 - min_abs_gap_pct: 0.02 - min_premarket_dollar_vol: 1500000 - max_candidates: 20 - max_candidates_per_sector: 3 - min_candidates_to_trade: 1 - ticker_cooldown_days: 0 - max_gap_pct: 0.04 - - min_candidate_breadth: 0.60 - market_regime_spy_threshold: 0.0015 - market_regime_ticker: QQQ - rolling_loss_days: 7 - rolling_loss_threshold: -0.07 - max_simultaneous_entries: 3 - min_breakout_rel_vol: null # disabled — VWAP reclaim bar is late-morning, not ORB - - # === Scoring weights identical to V23 === - weight_rvol: 0.35 - weight_gap: 0.20 - weight_dollar_vol: 0.05 - weight_premarket_dollar_vol: 0.25 - weight_body_ratio: 0.0 - weight_momentum: 0.15 - - # === Stop / exit parameters (base ATR same as V23) === - atr_stop_multiplier: 0.75 - breakeven_at_r: 1.0 - trailing_at_r: 1.0 - trailing_stop_atr_multiplier: 0.8 - trailing_tighten_at_r: 2.0 - trailing_stop_atr_multiplier_tight: 0.3 - - partial_exit_at_r: 99.0 - partial_exit_pct: 0.50 - - risk_per_trade_pct: 0.05 - max_position_pct: 0.70 - daily_max_loss_pct: 0.05 - max_stops_per_day: 5 - exit_minutes_before_close: 5 - - slippage_bps: 5.0 - initial_capital: 10000 - - compound_returns: false - daily_budget_reset: true - settlement_days: 1 - - drawdown_governor_threshold: 0.025 - drawdown_governor_min_scale: 0.30 - - streak_sizing_win_bonus: 0.70 - streak_sizing_max: 2.5 - - # === VWAP reclaim window === - vwap_reclaim_window_start_min: 30 # 10:00 ET - vwap_reclaim_window_end_min: 120 # 11:30 ET - -universe: - source: midlarge - -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - -cache: - enabled: true - dir: data/cache/intraday - -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/vwap_reclaim_v1_highgap.yaml b/configs/intraday/strategies/vwap_reclaim_v1_highgap.yaml deleted file mode 100644 index 72000a8..0000000 --- a/configs/intraday/strategies/vwap_reclaim_v1_highgap.yaml +++ /dev/null @@ -1,119 +0,0 @@ -_meta: - id: 37 - name: "VWAP Reclaim V1 High-Gap" - status: aborted - aborted_date: "2026-04-21" - aborted_reason: > - Orthogonal gap pool (≥4%) reduced trade overlap to 16.5% (passes ≤20%) but daily PnL - corr=0.394 (fails ≤0.30). Correlation is purely regime-driven — both engines are - long-momentum triggered by QQQ-positive days. Worst-20% day combined PnL is WORSE than - V23 standalone (amplifies drawdowns). Baseline +13.82%/WR 48% fails G1 (WR<50%) and G3 - (corr). Phase 3 must use a directionally different approach to break regime correlation. - description: > - Diagnostic pass 2: high-gap pool (gap ≥ 4%) + VWAP reclaim entry. - - Problem with same-pool vwap_reclaim_v1: 57% trade overlap with V23 (same stocks, same days). - Hypothesis: V23 uses max_gap_pct=0.04 (2-4% gap). Gap > 4% stocks are orthogonal by - construction — V23 never touches them. These higher-gap stocks often exhibit genuine - price discovery (initial dump then reclaim) rather than immediate continuation. - - Gate: base WR ≥ 45% AND portfolio overlap ≤ 30%. - -strategy_mode: orb - -orb_strategy: - engine_family: vwap_reclaim_v1 - live_readiness: research_only - - orb_minutes: 5 - sim_bar_minutes: 5 - - entry_direction: long_only - order_timeout_minutes: 120 # not used for entry, but sets the timeout context - - allow_doji_breakout: true - allow_red_to_green_breakout: true - - # === Candidate filters identical to V23 (gainers_leader pool) === - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.50 - min_atr_pct: 0.04 - min_rvol: 1.5 - min_abs_gap_pct: 0.04 # high-gap pool: ≥4% gap (orthogonal to V23's 2-4% range) - min_premarket_dollar_vol: 1500000 - max_candidates: 20 - max_candidates_per_sector: 3 - min_candidates_to_trade: 1 - ticker_cooldown_days: 0 - max_gap_pct: null # no upper cap — allow all high-gap stocks - - min_candidate_breadth: 0.60 - market_regime_spy_threshold: 0.0015 - market_regime_ticker: QQQ - rolling_loss_days: 7 - rolling_loss_threshold: -0.07 - max_simultaneous_entries: 3 - min_breakout_rel_vol: null # disabled — VWAP reclaim bar is late-morning, not ORB - - # === Scoring weights identical to V23 === - weight_rvol: 0.35 - weight_gap: 0.20 - weight_dollar_vol: 0.05 - weight_premarket_dollar_vol: 0.25 - weight_body_ratio: 0.0 - weight_momentum: 0.15 - - # === Stop / exit parameters (base ATR same as V23) === - atr_stop_multiplier: 0.75 - breakeven_at_r: 1.0 - trailing_at_r: 1.0 - trailing_stop_atr_multiplier: 0.8 - trailing_tighten_at_r: 2.0 - trailing_stop_atr_multiplier_tight: 0.3 - - partial_exit_at_r: 99.0 - partial_exit_pct: 0.50 - - risk_per_trade_pct: 0.05 - max_position_pct: 0.70 - daily_max_loss_pct: 0.05 - max_stops_per_day: 5 - exit_minutes_before_close: 5 - - slippage_bps: 5.0 - initial_capital: 10000 - - compound_returns: false - daily_budget_reset: true - settlement_days: 1 - - drawdown_governor_threshold: 0.025 - drawdown_governor_min_scale: 0.30 - - streak_sizing_win_bonus: 0.70 - streak_sizing_max: 2.5 - - # === VWAP reclaim window === - vwap_reclaim_window_start_min: 30 # 10:00 ET - vwap_reclaim_window_end_min: 120 # 11:30 ET - vwap_reclaim_require_prior_dip: false - vwap_reclaim_min_clearance_pct: 0.0 # no clearance filter — enter on first close above VWAP - vwap_reclaim_stop_mode: vwap # structural stop: distance to VWAP floor - vwap_reclaim_stop_vwap_buffer_pct: 0.002 # stop at VWAP × (1 - 0.2%) - -universe: - source: midlarge - -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - -cache: - enabled: true - dir: data/cache/intraday - -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/sweep_leader_liquid_cluster_overlay_q1.yaml b/configs/intraday/sweep_leader_liquid_cluster_overlay_q1.yaml new file mode 100644 index 0000000..b93e5ff --- /dev/null +++ b/configs/intraday/sweep_leader_liquid_cluster_overlay_q1.yaml @@ -0,0 +1,21 @@ +sweep: + use_liquid_cluster_engine: [false, true] + use_sector_etf_sleeve: [false, true] + liquid_cluster_capital_fraction: [0.15] + liquid_cluster_max_positions: [1] + liquid_cluster_max_positions_per_sector: [1] + liquid_cluster_min_members: [2] + liquid_cluster_min_gain_pct: [0.015] + liquid_cluster_max_gain_pct: [0.04] + liquid_cluster_min_confirmation_return_pct: [0.005] + liquid_cluster_min_entry_dollar_volume: [40000000.0] + liquid_cluster_min_avg_dollar_vol_30d: [250000000.0] + liquid_cluster_max_avg_dollar_vol_30d: [2000000000.0] + liquid_cluster_min_volume_ratio_14d: [0.04] + liquid_cluster_max_entropy_20d: [0.86] + liquid_cluster_min_sector_avg_confirmation_return_pct: [0.005] + liquid_cluster_min_sector_total_entry_dollar_volume: [100000000.0] + liquid_cluster_require_special_liquidity_gate: [true] + sector_etf_capital_fraction: [0.10] + sector_etf_max_positions: [1] + sector_etf_min_sector_score: [0.20] diff --git a/configs/intraday/sweep_orb_gainers_v24_1_gapz.yaml b/configs/intraday/sweep_orb_gainers_v24_1_gapz.yaml new file mode 100644 index 0000000..a46f649 --- /dev/null +++ b/configs/intraday/sweep_orb_gainers_v24_1_gapz.yaml @@ -0,0 +1,7 @@ +sweep: + max_gap_zscore_20d: + - null + - 1.5 + - 2.0 + - 2.5 + - 3.0 diff --git a/configs/snapshots/registry.json b/configs/snapshots/registry.json index 17d49da..38ab2a9 100644 --- a/configs/snapshots/registry.json +++ b/configs/snapshots/registry.json @@ -191,6 +191,101 @@ "prior_catalyst_type_diversity_60d" ] }, + "broad-liquid-long-v1_bucketfix_full_audit_canonical": { + "purpose": "broad_universe_experiment", + "refresh_policy": "auto_full_rebuild", + "universe_profile": "broad-liquid-long-v1", + "label_version": "label-2.0.0", + "start_date": "2022-03-01", + "end_date": null, + "feature_versions": [ + "market_v1", + "event_v1", + "text_v1", + "earnings_surprise_v1" + ], + "feature_sets": [ + "base", + "earnings_history", + "peer_surprise", + "catalyst_persistence", + "tier2", + "tier3", + "technical", + "macro", + "prior_drift" + ], + "enrichment_steps": [ + "base_export", + "earnings_history_enrich", + "peer_surprise_enrich", + "catalyst_persistence_enrich", + "tier2_enrich", + "tier3_enrich", + "technical_enrich", + "macro_enrich", + "prior_drift_enrich" + ], + "expected_feature_columns": [ + "pre_event_hurst_60d", + "pre_event_entropy_60d", + "pre_event_short_ratio", + "pre_event_sector_momentum_20d", + "pre_event_ou_theta_60d", + "pre_event_gravitational_pull", + "pre_event_market_temperature", + "pre_event_volatility_20d", + "pre_event_rsi_14", + "pre_event_bb_position", + "pre_event_obv_slope_20d", + "macro_vix", + "macro_hy_spread", + "macro_t10y2y", + "prior_event_fwd5d", + "lm_positive_pct", + "lm_negative_pct", + "lm_net_sentiment", + "lm_uncertainty_pct", + "lm_word_count", + "reported_eps", + "estimated_eps", + "earnings_surprise_pct", + "earnings_beat", + "sue_lag_1_pct", + "sue_lag_2_pct", + "sue_lag_3_pct", + "sue_lag_4_pct", + "sue_lag_5_pct", + "sue_lag_6_pct", + "sue_lag_7_pct", + "sue_lag_8_pct", + "sue_lag_9_pct", + "sue_lag_10_pct", + "sue_lag_11_pct", + "sue_lag_12_pct", + "sue_hist_mean_4q", + "sue_hist_mean_8q", + "sue_hist_mean_12q", + "sue_hist_pos_rate_4q", + "sue_hist_pos_rate_12q", + "sue_hist_latest_pct", + "sue_hist_streak_pos", + "sector", + "peer_sector_event_count_365d", + "peer_sector_surprise_median_365d", + "peer_sector_surprise_mean_365d", + "peer_sector_surprise_pos_rate_365d", + "peer_relative_surprise_pct_365d", + "peer_sector_sue_hist_mean_4q_median_365d", + "peer_sector_sue_hist_mean_4q_mean_365d", + "peer_relative_sue_hist_mean_4q_365d", + "peer_sector_sue_hist_pos_rate_4q_mean_365d", + "prior_catalyst_count_20d", + "prior_catalyst_count_60d", + "prior_catalyst_type_diversity_20d", + "prior_catalyst_type_diversity_60d" + ] + }, "midlarge-liquid-long-v1-oot-2020-2021_canonical": { "purpose": "oot", "refresh_policy": "manual_only", diff --git a/docs/leader_intraday_momentum_workflow.md b/docs/leader_intraday_momentum_workflow.md new file mode 100644 index 0000000..1e1ce29 --- /dev/null +++ b/docs/leader_intraday_momentum_workflow.md @@ -0,0 +1,174 @@ +# Leader Intraday Momentum Workflow + +이 문서는 `Leader Intraday Momentum High WR Intraday First` 전략을 개발하고 +검증하는 운영 기준이다. 기존 `orb_gainers`와 달리 이 전략은 ORB breakout보다 +`장 초반 top leader follow-through`를 직접 매매하는 momentum 전략이다. + +## Source Of Truth + +정식 전략 파일: + +- [leader_intraday_momentum_high_wr_intraday_first.yaml](/Users/yirugi/mycloud/personal/workspace/fithia2/configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml) + +공식 검증 경로: + +- [apps/intraday_bt/run.py](/Users/yirugi/mycloud/personal/workspace/fithia2/apps/intraday_bt/run.py) + +연구 helper나 momentum snapshot 결과는 빠른 탐색용이다. 최종 채택 판단은 반드시 +아래 공식 CLI와 웹사이트가 사용하는 동일 경로로 재검증한다. + +```bash +python -u -m apps.intraday_bt.run \ + --config configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml \ + --start 2026-01-02 \ + --end 2026-03-31 \ + --daily-budget-reset \ + --no-compound-returns +``` + +전략 개발 검증은 `daily_budget_reset + no compound`를 기본으로 한다. 이는 날짜별 +edge를 보기 위한 연구 모드이며, 후반 구간의 복리/계좌 규모 효과로 과적합되는 +문제를 줄인다. 실전 계좌 결과는 별도로 simple/compound 모드에서 확인한다. + +## Current Engine + +핵심 구조: + +- Universe는 정식 `broad` 3408개 티커를 사용한다. +- Daily seed는 look-ahead 없이 당일 open 이전/entry 시점까지 알 수 있는 feature만 쓴다. +- Intraday-first shortlist를 만든 뒤, 10분 entry와 5분 confirmation으로 재랭킹한다. +- Five-sleeve selection으로 `core`, `volume`, `gap`, `trend`, `blend`를 분리한다. +- Same-day filing catalyst는 additive alpha가 아니라 weak tail exemption 판단에 쓴다. +- Moderate-gap liquid reserve는 sparse day에서만 broad scan이 잡는 liquid follow-through 후보를 보강한다. +- Multi-event liquid overlay는 core basket을 건드리지 않고, broad same-day filing cluster가 확인된 날에만 별도 budget으로 liquid continuation names를 추가한다. +- 손실 방어는 개별 티커 블랙리스트가 아니라 구조 조건만 사용한다. + +현재 방어 레이어: + +- `weak-event tail defense`: event flag가 있어도 support score가 낮으면 sparse-day 방어 예외로 보지 않는다. +- `low-momentum single-name defense`: single-name day에서 유일한 후보의 morning gain이 낮고 supported event/largecap도 아니면 day budget을 줄인다. +- `soft-day sparse defense`: soft day인데 basket이 1~2개뿐이고 event / liquid large-cap / moderate-gap liquid support가 없으면 day budget을 추가로 줄인다. +- `trailing_stop_pct: -0.07`: hard take-profit 없이 intraday trailing stop으로 큰 downside를 제한한다. +- `loss_containment_score`: WR/DD와 별도로 손실일 평균과 tail loss를 직접 보는 보조 지표다. + +## Official Validation Windows + +과적합 방지를 위해 2026 Q1만 보지 않고 2025년 분기별 official backtest를 같이 본다. + +```bash +python -u -m apps.intraday_bt.run --config configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml --start 2025-01-02 --end 2025-03-31 --daily-budget-reset --no-compound-returns +python -u -m apps.intraday_bt.run --config configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml --start 2025-04-01 --end 2025-06-30 --daily-budget-reset --no-compound-returns +python -u -m apps.intraday_bt.run --config configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml --start 2025-07-01 --end 2025-09-30 --daily-budget-reset --no-compound-returns +python -u -m apps.intraday_bt.run --config configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml --start 2025-10-01 --end 2025-12-31 --daily-budget-reset --no-compound-returns +python -u -m apps.intraday_bt.run --config configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml --start 2026-01-02 --end 2026-03-31 --daily-budget-reset --no-compound-returns +``` + +Latest official results as of 2026-04-21 after promoting the multi-event liquid overlay into the flagship: + +| Window | Result file | Return | WR | Max DD | LC | Trades | +|---|---|---:|---:|---:|---:|---:| +| 2025 Q1 | `runs/intraday/intraday_20260421_220246_34b0dff0.json` | +6.56% | 55.1% | -10.93% | 66.11 | 89 | +| 2025 Q2 | `runs/intraday/intraday_20260421_223436_79a89cb1.json` | +23.23% | 60.0% | -4.31% | 66.80 | 60 | +| 2025 Q3 | `runs/intraday/intraday_20260421_224448_ccd2985b.json` | +6.92% | 49.6% | -6.81% | 64.38 | 135 | +| 2025 Q4 | `runs/intraday/intraday_20260421_222253_6f5a98e0.json` | -5.24% | 41.8% | -15.47% | 65.33 | 122 | +| 2026 Q1 | `runs/intraday/intraday_20260421_221923_2ea8b7f2.json` | +16.14% | 55.4% | -6.16% | 66.33 | 148 | + +해석: + +- 2025 Q4는 아직 음수라서 전략의 약점 구간이다. +- 다만 multi-event overlay는 2025 Q1/Q2/Q3/Q4 official 창에서는 아예 발화하지 않아, 약한 분기들을 추가로 오염시키지는 않았다. +- 2026 Q1 holdout에서는 2026-01-02 한 번의 broad filing cluster에서만 발화했고, 그 날 `APLD`, `BMNR` 두 개를 추가해 flagship 대비 수익률과 WR을 끌어올렸다. +- 다음 개선은 개별 종목을 외우는 방식이 아니라 Q4 같은 weak regime을 더 잘 감지하는 meta-layer여야 한다. +- `sector thrust` breadth engine은 2026-04-21에 코드로 추가해 실험했지만, 정식 전략에 full enable하면 2026 Q1 holdout이 약 `+21.92% -> +15.98%`까지 악화돼 아직 승격하지 않았다. + +## Actual Catalyst Branches + +2026-04-21에 `actual catalyst + liquid leader continuation` 방향도 분리 검증했다. + +전략 파일: + +- `configs/intraday/strategies/leader_intraday_momentum_actual_catalyst_liquid.yaml` +- `configs/intraday/strategies/leader_intraday_momentum_event_day_liquid_hybrid.yaml` + +Q1 결과: + +| Variant | Result file | Return | WR | Max DD | Trades | Notes | +|---|---|---:|---:|---:|---:|---| +| strict event-only | `runs/intraday/intraday_20260421_205738_1f70d7db.json` | +3.21% | 63.6% | -7.87% | 11 | actual filing catalyst만 거래해서 너무 sparse했다 | +| hybrid event reserve | `runs/intraday/intraday_20260421_210703_5bb723e7.json` | +15.65% | 54.8% | -6.14% | 146 | baseline보다 아주 미세하게 개선됐지만 구조 변화는 작았다 | +| baseline flagship | `runs/intraday/intraday_20260421_211021_30f71c80.json` | +15.59% | 54.8% | -6.19% | 146 | 비교 기준 | + +해석: + +- strict event-only는 방향성은 맞아도 메인 엔진으로 쓰기엔 너무 희소하다. +- hybrid는 `candidate_allowed_event_types`를 통해 `earnings_release`, `guidance_update`, `material_contract`, `other_material_event`만 event로 인정하게 했지만, Q1 기준으로 baseline 대비 개선폭은 `+0.06%` 수준에 그쳤다. +- 즉, actual catalyst를 reserve/overlay로만 넣는 것만으로는 아직 획기적 변화가 없었다. +- 다음 구조 개선은 `event issuer 자체`를 더 사는 것이 아니라, `event day에 broad scan에서 잡힌 liquid continuation names를 별도 engine/sleeve로 어떻게 승격할지` 쪽이 더 유망하다. + +## Multi-Event Liquid Overlay + +2026-04-21 최종 승격안은 strict event reserve가 아니라 `overlay-only` 구조였다. + +핵심 규칙: + +- core basket은 [leader_intraday_momentum_high_wr_intraday_first.yaml](/Users/yirugi/mycloud/personal/workspace/fithia2/configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml)과 동일하게 유지한다. +- filing data는 core rank에 섞지 않는다. +- 대신 `earnings_release`, `guidance_update`, `material_contract`, `other_material_event`, `management_change`, `unknown` 중에서 **2개 이상** same-day contributor가 동시에 보이고, 합산 entry dollar volume이 **$100M 이상**일 때만 overlay를 켠다. +- overlay가 켜진 날에만 day budget의 12%를 써서, base basket 밖의 liquid continuation names를 최대 2개 추가한다. + +최종 해석: + +- 2025 official 창에서는 overlay가 발화하지 않았고 결과도 거의 그대로 유지됐다. +- 2026 Q1에서는 2026-01-02 하루만 발화했고, `APLD`, `BMNR` 두 개가 추가됐다. +- 이 한 번의 broad event cluster가 `+16.14% / WR 55.4% / DD -6.16%`를 만들었고, 직전 flagship 비교치 `+15.59% / WR 54.8% / DD -6.19%`보다 좋아졌다. +- 즉, 이 overlay는 “매일 조금씩 손대는 additive factor”가 아니라, **희소하지만 설명 가능한 broad event cluster day에만 붙는 post-allocation sleeve**로 이해해야 한다. + +## Liquid Continuation Core Experiment + +`moderate-gap liquid / liquid large-cap / sector thrust`를 overlay가 아니라 +**full core basket engine**으로 승격한 실험도 별도로 진행했다. + +- 전략 파일: [leader_intraday_momentum_liquid_continuation_core.yaml](/Users/yirugi/mycloud/personal/workspace/fithia2/configs/intraday/strategies/leader_intraday_momentum_liquid_continuation_core.yaml) +- 코드 변경: + - `momentum_selection_mode: liquid_continuation` + - `candidate_intraday_rank_mode: liquid_continuation` + - same-day `support_score`, `is_liquid_largecap`, `is_moderate_gap_liquid`를 candidate weighted rank에 추가 + +결과: + +- 1차 broad version: [intraday_20260421_235041_f5aeac8a.json](/Users/yirugi/mycloud/personal/workspace/fithia2/runs/intraday/intraday_20260421_235041_f5aeac8a.json) + - `2026 Q1: -6.98%`, `WR 47.7%`, `DD -13.52%` +- stricter version: [intraday_20260421_235327_04e5bda8.json](/Users/yirugi/mycloud/personal/workspace/fithia2/runs/intraday/intraday_20260421_235327_04e5bda8.json) + - `2026 Q1: -0.43%`, `WR 46.8%`, `DD -11.56%` +- walk-forward spot check: [intraday_20260421_235647_93d29530.json](/Users/yirugi/mycloud/personal/workspace/fithia2/runs/intraday/intraday_20260421_235647_93d29530.json) + - `2025 Q1: -2.31%`, `WR 41.8%`, `DD -9.23%` + +결론: + +- 이 엔진은 실제로 `special liquidity` 이름만 중심으로 고르도록 동작했지만, + **flagship을 대체할 full core engine으로는 아직 edge가 없다.** +- 특히 moderate-gap liquid 정의를 core로 올리면 거래 수는 줄어도 분기 성과가 + baseline보다 지속적으로 나빠졌다. +- 따라서 현재 판단은 `liquid continuation`을 full replacement로 승격하지 말고, + **tail replacement / reserve slot / rare-day sleeve** 쪽에만 제한적으로 쓰는 편이 낫다. + +## Required Checks Before Keeping A Change + +변경을 유지하려면 최소한 아래를 확인한다. + +- Unit tests pass: + +```bash +pytest -q tests/unit/intraday/test_simulator.py tests/unit/intraday/test_run_helpers.py tests/unit/intraday/test_screener.py tests/unit/intraday/test_metrics.py +``` + +- 2026 Q1 official result가 무너지지 않는다. +- 2025 Q1/Q2/Q3/Q4 중 한 분기만 좋아지고 다른 분기들이 크게 악화되지 않는다. +- 결과 JSON의 trade diagnostics로 변경이 어떤 구조에 적용됐는지 설명 가능해야 한다. + +## Known Bottleneck + +분기별 official 검증에서 `Fetching momentum filing catalysts` 단계가 가장 느리다. +현재는 캐시가 있어도 90% 이후 일부 ticker 조회가 오래 걸린다. 전략 검증 자체는 +정상 완료되지만, 다음 인프라 개선은 event 조회 범위 축소나 캐시 hit 판정 개선이 +우선이다. diff --git a/libs/export/snapshot_export.py b/libs/export/snapshot_export.py index f5944ad..f5962bb 100644 --- a/libs/export/snapshot_export.py +++ b/libs/export/snapshot_export.py @@ -39,6 +39,7 @@ _UNIVERSE_PROFILE_MIDLARGE_LIQUID_LONG_V1 = "midlarge-liquid-long-v1" _UNIVERSE_PROFILE_MIDPLUS_LIQUID_LONG_V1 = "midplus-liquid-long-v1" _UNIVERSE_PROFILE_MIDWIDE_LIQUID_LONG_V1 = "midwide-liquid-long-v1" _UNIVERSE_PROFILE_SMALLCAP_LIQUID_LONG_V1 = "smallcap-liquid-long-v1" +_UNIVERSE_PROFILE_BROAD_LIQUID_LONG_V1 = "broad-liquid-long-v1" _UNIVERSE_PROFILES: dict[str, dict[str, Any]] = { _UNIVERSE_PROFILE_MIDLARGE_LIQUID_LONG_V1: { @@ -70,6 +71,13 @@ _UNIVERSE_PROFILES: dict[str, dict[str, Any]] = { "exchange": "NYSE,NASDAQ,AMEX", "exclude_types": "ETF,FUND,ADR,SPAC", }, + _UNIVERSE_PROFILE_BROAD_LIQUID_LONG_V1: { + "market_cap_min": 300_000_000, + "price_min": 5, + "avg_dollar_volume_20d_min": 3_000_000, + "exchange": "NYSE,NASDAQ,AMEX", + "exclude_types": "ETF,FUND,ADR,SPAC", + }, } diff --git a/libs/intraday/cache.py b/libs/intraday/cache.py index be44d5b..3cd61d3 100644 --- a/libs/intraday/cache.py +++ b/libs/intraday/cache.py @@ -10,6 +10,7 @@ from __future__ import annotations import os from pathlib import Path from typing import Any +from uuid import uuid4 import pyarrow as pa import pyarrow.parquet as pq @@ -197,7 +198,7 @@ class IntradayCache: _INTRADAY_NEGATIVE_REASON_KEY: reason.encode(), }) table = pa.Table.from_pylist([], schema=schema) - tmp = p.with_suffix(".tmp") + tmp = p.with_suffix(f".{uuid4().hex}.tmp") try: pq.write_table(table, str(tmp), compression="snappy") os.replace(str(tmp), str(p)) diff --git a/libs/intraday/domain.py b/libs/intraday/domain.py index 06fbd54..0a407ff 100644 --- a/libs/intraday/domain.py +++ b/libs/intraday/domain.py @@ -1142,8 +1142,15 @@ class ORBStrategyParams(BaseModel): """Calendar days to look back for prior earnings/guidance events in DB. 0=off (default, V24 parity). 7=V46. When >0 AND weight_event_catalyst>0, uses DB events table path instead of Oracle REST API for event_flag/event_score. - Marks each trading day within this window after an earnings_release or - guidance_update event as event_flag=True, event_score=1.0.""" + Marks each trading day within this window after qualifying events as + event_flag=True, event_score=1.0. Event types controlled by prior_event_types.""" + + prior_event_types: list[str] = Field( + default_factory=lambda: ["earnings_release", "guidance_update"] + ) + """DB event types to include in prior_event_lookback_days signal. + Default matches V46 (earnings_release + guidance_update). + Set to ['earnings_release'] for earnings-only variant.""" weight_attention_wiki: float = 0.0 """Wikipedia attention weight for actual stocks-in-play ranking.""" diff --git a/libs/intraday/metrics.py b/libs/intraday/metrics.py index 78eea96..9fe27a6 100644 --- a/libs/intraday/metrics.py +++ b/libs/intraday/metrics.py @@ -778,6 +778,9 @@ def write_results( "sector_scaler": r.sector_scaler, "tail_risk_scaler": r.tail_risk_scaler, "is_soft_day": r.is_soft_day, + "event_day_liquid_active": r.event_day_liquid_active, + "event_day_liquid_event_count": r.event_day_liquid_event_count, + "event_day_liquid_total_event_entry_dollar_volume": r.event_day_liquid_total_event_entry_dollar_volume, } for r in day_results ], diff --git a/libs/intraday/screener.py b/libs/intraday/screener.py index edbeeb7..5eaa9a8 100644 --- a/libs/intraday/screener.py +++ b/libs/intraday/screener.py @@ -619,6 +619,7 @@ def momentum_pre_screen_candidates( require_event_flag = bool(getattr(strategy, "candidate_require_event_flag", False)) if strategy else False min_event_score = getattr(strategy, "candidate_min_event_score", None) if strategy else None + allowed_event_types = _momentum_allowed_event_types(strategy) min_wiki_spike = getattr(strategy, "candidate_min_attention_wiki_spike_10d", None) if strategy else None min_article_count = ( getattr(strategy, "candidate_min_attention_article_count_3d", None) @@ -651,8 +652,7 @@ def momentum_pre_screen_candidates( if gap_pct is None or gap_pct < threshold: continue - event_flag = bool(info.get("event_flag")) - event_score = float(info.get("event_score") or 0.0) + event_flag, event_score = _momentum_effective_event_state(info, allowed_event_types) wiki_spike = float(info.get("attention_wiki_spike_10d") or 0.0) article_count = int(info.get("attention_article_count_3d") or 0) us_article_count = int(info.get("attention_us_article_count_3d") or 0) @@ -707,6 +707,46 @@ def momentum_pre_screen_candidates( return result +def _momentum_allowed_event_types(strategy) -> set[str]: + if strategy is None: + return set() + return { + str(value).strip().lower() + for value in getattr(strategy, "candidate_allowed_event_types", []) + if str(value).strip() + } + + +def _momentum_event_types_pass( + info: dict, + allowed_event_types: set[str], +) -> bool: + if not allowed_event_types: + return True + raw_event_types = info.get("event_types") or [] + event_types = { + str(value).strip().lower() + for value in raw_event_types + if str(value).strip() + } + if not event_types: + return False + return any(event_type in allowed_event_types for event_type in event_types) + + +def _momentum_effective_event_state( + info: dict, + allowed_event_types: set[str], +) -> tuple[bool, float]: + event_flag = bool(info.get("event_flag")) + event_score = float(info.get("event_score") or 0.0) + if not event_flag: + return False, 0.0 + if allowed_event_types and not _momentum_event_types_pass(info, allowed_event_types): + return False, 0.0 + return True, event_score + + def _momentum_candidate_signal_passes( info: dict, strategy, @@ -716,13 +756,13 @@ def _momentum_candidate_signal_passes( return True require_event_flag = bool(getattr(strategy, "candidate_require_event_flag", False)) min_event_score = getattr(strategy, "candidate_min_event_score", None) + allowed_event_types = _momentum_allowed_event_types(strategy) min_wiki_spike = getattr(strategy, "candidate_min_attention_wiki_spike_10d", None) min_article_count = getattr(strategy, "candidate_min_attention_article_count_3d", None) min_us_article_count = getattr(strategy, "candidate_min_attention_us_article_count_3d", None) min_resolver_conf = getattr(strategy, "candidate_min_attention_resolver_confidence", None) - event_flag = bool(info.get("event_flag")) - event_score = float(info.get("event_score") or 0.0) + event_flag, event_score = _momentum_effective_event_state(info, allowed_event_types) wiki_spike = float(info.get("attention_wiki_spike_10d") or 0.0) article_count = int(info.get("attention_article_count_3d") or 0) us_article_count = int(info.get("attention_us_article_count_3d") or 0) @@ -749,6 +789,7 @@ def _momentum_intraday_weighted_score( strategy, ) -> float: """Weighted same-day candidate score for intraday-first ranking.""" + from libs.intraday.simulator import _same_day_support_score def _clip_unit(value: float | None, cap: float) -> float: if value is None or cap <= 0: @@ -785,6 +826,7 @@ def _momentum_intraday_weighted_score( if entropy_20d is not None else 0.0 ) + support_score = _same_day_support_score(info) event_score = float(daily_info.get("event_score") or 0.0) wiki_spike = float(daily_info.get("attention_wiki_spike_10d") or 0.0) @@ -811,6 +853,13 @@ def _momentum_intraday_weighted_score( score += float(getattr(strategy, "candidate_intraday_weight_avg_dollar_vol_30d", 0.0) or 0.0) * _prior_dollar_vol_score( avg_dollar_vol_30d ) + score += float(getattr(strategy, "candidate_intraday_weight_support_score", 0.0) or 0.0) * support_score + score += float(getattr(strategy, "candidate_intraday_weight_liquid_largecap", 0.0) or 0.0) * ( + 1.0 if info.get("is_liquid_largecap") else 0.0 + ) + score += float(getattr(strategy, "candidate_intraday_weight_moderate_gap_liquid", 0.0) or 0.0) * ( + 1.0 if info.get("is_moderate_gap_liquid") else 0.0 + ) score += float(getattr(strategy, "candidate_intraday_weight_gap", 0.0) or 0.0) * _clip_unit( gap_pct, 0.10 ) @@ -818,6 +867,10 @@ def _momentum_intraday_weighted_score( ret_5d, 0.20 ) score += float(getattr(strategy, "candidate_intraday_weight_low_entropy", 0.0) or 0.0) * low_entropy + score += float(getattr(strategy, "candidate_intraday_weight_sector_thrust", 0.0) or 0.0) * _clip_unit( + float(info.get("sector_thrust_score") or 0.0), + 1.0, + ) score += float(getattr(strategy, "candidate_intraday_weight_event_score", 0.0) or 0.0) * _clip_unit( event_score, 3.0 ) @@ -830,12 +883,34 @@ def _momentum_intraday_weighted_score( return score +def _momentum_intraday_liquid_continuation_score( + info: dict, +) -> tuple[float, float, float, float, float, float, float, float, float]: + from libs.intraday.simulator import _same_day_support_score + + entropy_20d = info.get("entropy_20d") + return ( + 1.0 if info.get("is_moderate_gap_liquid") else 0.0, + 1.0 if info.get("is_liquid_largecap") else 0.0, + 1.0 if info.get("is_sector_thrust") else 0.0, + _same_day_support_score(info), + float(info.get("confirmation_return_pct") or 0.0), + float(info.get("entry_dollar_volume") or 0.0), + float(info.get("avg_dollar_vol_30d") or 0.0), + float(info.get("gain_pct") or 0.0), + -(float(entropy_20d) if entropy_20d is not None else 1.0), + ) + + def _momentum_intraday_event_reserve_eligible( daily_info: dict, strategy, ) -> bool: if not bool(daily_info.get("event_flag")): return False + allowed_event_types = _momentum_allowed_event_types(strategy) + if not _momentum_event_types_pass(daily_info, allowed_event_types): + return False min_score = getattr(strategy, "candidate_intraday_event_reserve_min_score", None) if min_score is None: return True @@ -1070,6 +1145,7 @@ def momentum_intraday_first_candidates( strategy, *, daily_enrichment: dict[str, dict[str, dict]] | None = None, + ticker_sectors: dict[str, str] | None = None, max_per_day: int | None = None, ) -> dict[str, list[str]]: """Build the final momentum shortlist from entry-time intraday information. @@ -1078,7 +1154,11 @@ def momentum_intraday_first_candidates( already bounded the intraday fetch set. The final ranking uses only information known by the entry / confirmation bar of the same day. """ - from libs.intraday.simulator import _select_momentum_sleeves, compute_morning_gains + from libs.intraday.simulator import ( + _annotate_sector_thrust_features, + _select_momentum_sleeves, + compute_morning_gains, + ) if max_per_day is None: max_per_day = max(1, int(getattr(strategy, "candidate_final_max_per_day", 30) or 30)) @@ -1108,6 +1188,11 @@ def momentum_intraday_first_candidates( day, daily_features_by_ticker=daily_info_by_ticker, ) + gains = _annotate_sector_thrust_features( + gains, + shortlist_strategy, + ticker_sectors, + ) if not gains: continue filtered_gains = { @@ -1163,9 +1248,47 @@ def momentum_intraday_first_candidates( daily_info_by_ticker, strategy, max_per_day=max_per_day, + ) + continue + if rank_mode == "liquid_continuation": + rankable_liquid_gains = { + ticker: info + for ticker, info in rankable_gains.items() + if ( + info.get("is_moderate_gap_liquid") + or info.get("is_liquid_largecap") + or info.get("is_sector_thrust") + ) + } + if not rankable_liquid_gains: + continue + ranked = sorted( + rankable_liquid_gains.items(), + key=lambda item: _momentum_intraday_liquid_continuation_score(item[1]), + reverse=True, ) + if ranked: + ranked_tickers = _apply_momentum_intraday_event_reserve( + [ticker for ticker, _info in ranked], + filtered_gains, + daily_info_by_ticker, + strategy, + day_bars=day_bars, + max_per_day=max_per_day, + ) + result[day] = _apply_momentum_intraday_moderate_liquid_reserve( + ranked_tickers, + filtered_gains, + daily_info_by_ticker, + strategy, + max_per_day=max_per_day, + ) continue - picks = _select_momentum_sleeves(rankable_gains, shortlist_strategy, ticker_sectors=None) + picks = _select_momentum_sleeves( + rankable_gains, + shortlist_strategy, + ticker_sectors=ticker_sectors, + ) if picks: ranked_tickers = _apply_momentum_intraday_event_reserve( [ticker for ticker, _sleeve in picks], diff --git a/libs/intraday/simulator.py b/libs/intraday/simulator.py index 24559c2..21d573e 100644 --- a/libs/intraday/simulator.py +++ b/libs/intraday/simulator.py @@ -19,6 +19,23 @@ _MARKET_OPEN = dt.time(9, 30) # ET _MARKET_CLOSE = dt.time(16, 0) # ET _MIN_BARS = 5 # minimum market-hours bars required to simulate a stock +SECTOR_PROXY_BY_LABEL: dict[str, str] = { + "Basic Materials": "XLB", + "Communication Services": "XLC", + "Consumer Cyclical": "XLY", + "Consumer Defensive": "XLP", + "Consumer Staples": "XLP", + "Energy": "XLE", + "Financial Services": "XLF", + "Financial": "XLF", + "Healthcare": "XLV", + "Industrials": "XLI", + "Real Estate": "XLRE", + "Technology": "XLK", + "Utilities": "XLU", +} +SECTOR_PROXY_TICKERS: tuple[str, ...] = tuple(sorted(set(SECTOR_PROXY_BY_LABEL.values()))) + # ── Timestamp Parsing ────────────────────────────────────────────────────── @@ -487,6 +504,26 @@ def _five_sleeve_specs(strategy: StrategyParams) -> list[dict[str, object]]: ), } ) + if strategy.use_sector_thrust_sleeve and strategy.sector_thrust_weight > 0: + sleeves.append( + { + "label": "sector_thrust", + "weight": strategy.sector_thrust_weight, + "key_fn": lambda item: ( + 1 if item[1].get("is_sector_thrust") else 0, + item[1].get("sector_thrust_score", 0.0), + item[1].get("sector_thrust_member_count", 0), + item[1].get("entry_dollar_volume", 0.0), + item[1].get("confirmation_return_pct", -999.0), + item[1].get("gain_pct", 0.0), + ), + "component": lambda info: ( + float(info.get("sector_thrust_score") or 0.0) + if info.get("is_sector_thrust") + else 0.0 + ), + } + ) if strategy.use_gap_reclaim_sleeve and strategy.gap_reclaim_weight > 0: sleeves.append( { @@ -548,6 +585,15 @@ def _clip_log_score(value: float | None, low: float, high: float) -> float: return min(max(scaled, 0.0), 1.0) +def _round_optional(value: object, ndigits: int) -> float | None: + if value is None: + return None + try: + return round(float(value), ndigits) + except (TypeError, ValueError): + return None + + def _same_day_support_score(info: dict) -> float: """Blend liquidity and same-day attention into one support score. @@ -584,6 +630,452 @@ def _same_day_support_score(info: dict) -> float: return max(liquidity_support, attention_support, catalyst_support) +def sector_proxy_ticker_for_sector(sector: str | None) -> str | None: + if sector is None: + return None + normalized = str(sector).strip() + if not normalized or normalized.upper() == "UNKNOWN": + return None + return SECTOR_PROXY_BY_LABEL.get(normalized) + + +def _liquid_cluster_overlay_enabled(strategy: StrategyParams) -> bool: + return bool(strategy.use_liquid_cluster_engine or strategy.use_sector_etf_sleeve) + + +def _event_day_liquid_overlay_enabled(strategy: StrategyParams) -> bool: + return bool(strategy.use_event_day_liquid_sleeve) + + +def _event_day_liquid_activation_stats( + morning_gains: dict[str, dict], + strategy: StrategyParams, +) -> dict[str, float | int | bool]: + contributors: list[dict] = [] + min_event_score = getattr(strategy, "event_day_liquid_min_event_score", None) + min_support_score = getattr(strategy, "event_day_liquid_min_event_support_score", None) + allowed_event_types = { + str(value).strip().lower() + for value in getattr(strategy, "event_day_liquid_allowed_event_types", []) + if str(value).strip() + } + + for info in morning_gains.values(): + raw_event_flag = bool(info.get("raw_event_flag", info.get("event_flag"))) + if not raw_event_flag: + continue + event_score = float(info.get("raw_event_score", info.get("event_score")) or 0.0) + if allowed_event_types: + event_types = { + str(value).strip().lower() + for value in (info.get("event_types") or []) + if str(value).strip() + } + if not event_types or not any(event_type in allowed_event_types for event_type in event_types): + continue + elif not bool(info.get("event_flag")): + continue + if min_event_score is not None and event_score < float(min_event_score): + continue + support_score = _same_day_support_score(info) + if min_support_score is not None and support_score < float(min_support_score): + continue + contributors.append( + { + "event_score": event_score, + "support_score": support_score, + "entry_dollar_volume": float(info.get("entry_dollar_volume") or 0.0), + } + ) + + if not contributors: + return { + "qualifies": False, + "event_count": 0, + "max_event_score": 0.0, + "max_support_score": 0.0, + "total_entry_dollar_volume": 0.0, + } + + event_count = len(contributors) + total_entry_dollar_volume = sum(item["entry_dollar_volume"] for item in contributors) + qualifies = event_count >= max(1, int(strategy.event_day_liquid_min_event_names or 1)) + if ( + qualifies + and strategy.event_day_liquid_min_total_event_entry_dollar_volume is not None + and total_entry_dollar_volume < float(strategy.event_day_liquid_min_total_event_entry_dollar_volume) + ): + qualifies = False + return { + "qualifies": qualifies, + "event_count": event_count, + "max_event_score": max(item["event_score"] for item in contributors), + "max_support_score": max(item["support_score"] for item in contributors), + "total_entry_dollar_volume": total_entry_dollar_volume, + } + + +def _event_day_liquid_pick_score( + info: dict, +) -> tuple[float, float, float, float, float, float, float]: + entropy_20d = info.get("entropy_20d") + return ( + 1.0 if info.get("is_moderate_gap_liquid") else 0.0, + 1.0 if info.get("is_liquid_largecap") else 0.0, + _same_day_support_score(info), + float(info.get("confirmation_return_pct") or 0.0), + float(info.get("entry_dollar_volume") or 0.0), + float(info.get("avg_dollar_vol_30d") or 0.0), + -(float(entropy_20d) if entropy_20d is not None else 1.0), + ) + + +def _passes_liquid_cluster_own_gate( + strategy: StrategyParams, + *, + gain_pct: float, + confirmation_return_pct: float | None, + entry_dollar_volume: float, + avg_dollar_vol_30d: float | None, + volume_ratio_14d: float | None, + entropy_20d: float | None, + is_moderate_gap_liquid: bool, + is_liquid_largecap: bool, +) -> bool: + if ( + strategy.liquid_cluster_require_special_liquidity_gate + and not (is_moderate_gap_liquid or is_liquid_largecap) + ): + return False + if ( + strategy.liquid_cluster_min_gain_pct is not None + and gain_pct < strategy.liquid_cluster_min_gain_pct + ): + return False + if ( + strategy.liquid_cluster_max_gain_pct is not None + and gain_pct > strategy.liquid_cluster_max_gain_pct + ): + return False + if ( + strategy.liquid_cluster_min_confirmation_return_pct is not None + and ( + confirmation_return_pct is None + or confirmation_return_pct < strategy.liquid_cluster_min_confirmation_return_pct + ) + ): + return False + if ( + strategy.liquid_cluster_min_entry_dollar_volume is not None + and entry_dollar_volume < strategy.liquid_cluster_min_entry_dollar_volume + ): + return False + if ( + strategy.liquid_cluster_min_avg_dollar_vol_30d is not None + and ( + avg_dollar_vol_30d is None + or avg_dollar_vol_30d < strategy.liquid_cluster_min_avg_dollar_vol_30d + ) + ): + return False + if ( + strategy.liquid_cluster_max_avg_dollar_vol_30d is not None + and ( + avg_dollar_vol_30d is None + or avg_dollar_vol_30d > strategy.liquid_cluster_max_avg_dollar_vol_30d + ) + ): + return False + if ( + strategy.liquid_cluster_min_volume_ratio_14d is not None + and ( + volume_ratio_14d is None + or volume_ratio_14d < strategy.liquid_cluster_min_volume_ratio_14d + ) + ): + return False + if ( + strategy.liquid_cluster_max_entropy_20d is not None + and ( + entropy_20d is None + or entropy_20d > strategy.liquid_cluster_max_entropy_20d + ) + ): + return False + return True + + +def _annotate_sector_thrust_features( + morning_gains: dict[str, dict], + strategy: StrategyParams, + ticker_sectors: dict[str, str] | None, +) -> dict[str, dict]: + """Annotate PEAD-style sector breadth-confirmation features.""" + annotated = {ticker: dict(info) for ticker, info in morning_gains.items()} + for info in annotated.values(): + info.setdefault("is_sector_thrust", False) + info.setdefault("sector_thrust_member_count", 0) + info.setdefault("sector_thrust_total_entry_dollar_volume", 0.0) + info.setdefault("sector_thrust_avg_confirmation_return_pct", 0.0) + info.setdefault("sector_thrust_score", 0.0) + + if not annotated or not strategy.use_sector_thrust_sleeve or not ticker_sectors: + return annotated + + def _sector_for_ticker(ticker: str) -> str | None: + sector = str(ticker_sectors.get(ticker) or "").strip() + if not sector or sector.upper() == "UNKNOWN": + return None + return sector + + def _passes_own_gate(info: dict) -> bool: + gain_pct = float(info.get("gain_pct") or 0.0) + confirmation_return_pct = float(info.get("confirmation_return_pct") or 0.0) + entry_dollar_volume = float(info.get("entry_dollar_volume") or 0.0) + avg_dollar_vol_30d = float(info.get("avg_dollar_vol_30d") or 0.0) + if ( + strategy.sector_thrust_min_gain_pct is not None + and gain_pct < strategy.sector_thrust_min_gain_pct + ): + return False + if ( + strategy.sector_thrust_min_confirmation_return_pct is not None + and confirmation_return_pct < strategy.sector_thrust_min_confirmation_return_pct + ): + return False + if ( + strategy.sector_thrust_min_entry_dollar_volume is not None + and entry_dollar_volume < strategy.sector_thrust_min_entry_dollar_volume + ): + return False + if ( + strategy.sector_thrust_min_avg_dollar_vol_30d is not None + and avg_dollar_vol_30d < strategy.sector_thrust_min_avg_dollar_vol_30d + ): + return False + return True + + sector_members: dict[str, list[tuple[str, dict]]] = {} + for ticker, info in annotated.items(): + sector = _sector_for_ticker(ticker) + if sector is None or not _passes_own_gate(info): + continue + sector_members.setdefault(sector, []).append((ticker, info)) + + min_members = max(1, int(strategy.sector_thrust_min_members or 1)) + sector_stats: dict[str, dict[str, float | bool | set[str]]] = {} + for sector, members in sector_members.items(): + member_count = len(members) + avg_confirmation = sum( + float(info.get("confirmation_return_pct") or 0.0) + for _ticker, info in members + ) / member_count + total_entry_dollar_volume = sum( + float(info.get("entry_dollar_volume") or 0.0) + for _ticker, info in members + ) + qualifies = member_count >= min_members + if ( + strategy.sector_thrust_min_sector_avg_confirmation_return_pct is not None + and avg_confirmation < strategy.sector_thrust_min_sector_avg_confirmation_return_pct + ): + qualifies = False + if ( + strategy.sector_thrust_min_sector_total_entry_dollar_volume is not None + and total_entry_dollar_volume < strategy.sector_thrust_min_sector_total_entry_dollar_volume + ): + qualifies = False + sector_stats[sector] = { + "member_count": float(member_count), + "avg_confirmation_return_pct": avg_confirmation, + "total_entry_dollar_volume": total_entry_dollar_volume, + "qualifies": qualifies, + "tickers": {ticker for ticker, _info in members}, + } + + for ticker, info in annotated.items(): + sector = _sector_for_ticker(ticker) + if sector is None: + continue + stats = sector_stats.get(sector) + if not stats: + continue + info["sector_thrust_member_count"] = int(stats["member_count"]) + info["sector_thrust_total_entry_dollar_volume"] = float( + stats["total_entry_dollar_volume"] + ) + info["sector_thrust_avg_confirmation_return_pct"] = float( + stats["avg_confirmation_return_pct"] + ) + if not bool(stats["qualifies"]) or ticker not in stats["tickers"]: + continue + count_score = _clip_unit_score(stats["member_count"], 5.0) + avg_confirmation_score = _clip_unit_score( + stats["avg_confirmation_return_pct"], + 0.015, + ) + total_entry_dollar_volume_score = _clip_log_score( + stats["total_entry_dollar_volume"], + 50_000_000.0, + 2_000_000_000.0, + ) + own_confirmation_score = _clip_unit_score( + info.get("confirmation_return_pct"), + 0.02, + ) + own_entry_dollar_volume_score = _clip_log_score( + info.get("entry_dollar_volume"), + 10_000_000.0, + 500_000_000.0, + ) + info["is_sector_thrust"] = True + info["sector_thrust_score"] = ( + 0.30 * count_score + + 0.20 * avg_confirmation_score + + 0.20 * total_entry_dollar_volume_score + + 0.20 * own_confirmation_score + + 0.10 * own_entry_dollar_volume_score + ) + return annotated + + +def _annotate_liquid_cluster_features( + morning_gains: dict[str, dict], + strategy: StrategyParams, + ticker_sectors: dict[str, str] | None, +) -> tuple[dict[str, dict], dict[str, dict[str, object]]]: + """Annotate separate post-allocation liquid-cluster overlay features.""" + annotated = {ticker: dict(info) for ticker, info in morning_gains.items()} + for info in annotated.values(): + info.setdefault("is_liquid_cluster", False) + info.setdefault("liquid_cluster_member_count", 0) + info.setdefault("liquid_cluster_total_entry_dollar_volume", 0.0) + info.setdefault("liquid_cluster_sector", None) + info.setdefault("liquid_cluster_sector_score", 0.0) + info.setdefault("liquid_cluster_score", 0.0) + info.setdefault("sector_proxy_ticker", None) + + if not annotated or not ticker_sectors or not _liquid_cluster_overlay_enabled(strategy): + return annotated, {} + + def _sector_for_ticker(ticker: str) -> str | None: + sector = str(ticker_sectors.get(ticker) or "").strip() + if not sector or sector.upper() == "UNKNOWN": + return None + return sector + + def _passes_own_gate(info: dict) -> bool: + return _passes_liquid_cluster_own_gate( + strategy, + gain_pct=float(info.get("gain_pct") or 0.0), + confirmation_return_pct=info.get("confirmation_return_pct"), + entry_dollar_volume=float(info.get("entry_dollar_volume") or 0.0), + avg_dollar_vol_30d=info.get("avg_dollar_vol_30d"), + volume_ratio_14d=info.get("volume_ratio_14d"), + entropy_20d=info.get("entropy_20d"), + is_moderate_gap_liquid=bool(info.get("is_moderate_gap_liquid")), + is_liquid_largecap=bool(info.get("is_liquid_largecap")), + ) + + sector_members: dict[str, list[tuple[str, dict]]] = {} + for ticker, info in annotated.items(): + sector = _sector_for_ticker(ticker) + if sector is None or not _passes_own_gate(info): + continue + sector_members.setdefault(sector, []).append((ticker, info)) + + min_members = max(1, int(strategy.liquid_cluster_min_members or 1)) + sector_stats: dict[str, dict[str, object]] = {} + for sector, members in sector_members.items(): + member_count = len(members) + avg_confirmation = sum( + float(info.get("confirmation_return_pct") or 0.0) + for _ticker, info in members + ) / member_count + total_entry_dollar_volume = sum( + float(info.get("entry_dollar_volume") or 0.0) + for _ticker, info in members + ) + qualifies = member_count >= min_members + if ( + strategy.liquid_cluster_min_sector_avg_confirmation_return_pct is not None + and avg_confirmation < strategy.liquid_cluster_min_sector_avg_confirmation_return_pct + ): + qualifies = False + if ( + strategy.liquid_cluster_min_sector_total_entry_dollar_volume is not None + and total_entry_dollar_volume < strategy.liquid_cluster_min_sector_total_entry_dollar_volume + ): + qualifies = False + + count_score = _clip_unit_score(member_count, 5.0) + avg_confirmation_score = _clip_unit_score(avg_confirmation, 0.015) + total_entry_dollar_volume_score = _clip_log_score( + total_entry_dollar_volume, + 100_000_000.0, + 5_000_000_000.0, + ) + sector_score = ( + 0.35 * count_score + + 0.30 * avg_confirmation_score + + 0.35 * total_entry_dollar_volume_score + ) + sector_stats[sector] = { + "member_count": float(member_count), + "avg_confirmation_return_pct": avg_confirmation, + "total_entry_dollar_volume": total_entry_dollar_volume, + "qualifies": qualifies, + "sector_score": sector_score, + "proxy_ticker": sector_proxy_ticker_for_sector(sector), + "tickers": {ticker for ticker, _info in members}, + } + + for ticker, info in annotated.items(): + sector = _sector_for_ticker(ticker) + if sector is None: + continue + stats = sector_stats.get(sector) + if not stats: + continue + info["liquid_cluster_member_count"] = int(stats["member_count"]) + info["liquid_cluster_total_entry_dollar_volume"] = float( + stats["total_entry_dollar_volume"] + ) + info["liquid_cluster_sector"] = sector + info["liquid_cluster_sector_score"] = float(stats["sector_score"]) + info["sector_proxy_ticker"] = stats["proxy_ticker"] + if not bool(stats["qualifies"]) or ticker not in stats["tickers"]: + continue + + own_confirmation_score = _clip_unit_score( + info.get("confirmation_return_pct"), + 0.02, + ) + own_entry_dollar_volume_score = _clip_log_score( + info.get("entry_dollar_volume"), + 10_000_000.0, + 1_000_000_000.0, + ) + own_avg_dollar_vol_score = _clip_log_score( + info.get("avg_dollar_vol_30d"), + 100_000_000.0, + 10_000_000_000.0, + ) + own_gain_score = _clip_unit_score( + info.get("gain_pct"), + 0.05, + ) + info["is_liquid_cluster"] = True + info["liquid_cluster_score"] = ( + 0.45 * float(stats["sector_score"]) + + 0.20 * own_confirmation_score + + 0.15 * own_entry_dollar_volume_score + + 0.15 * own_avg_dollar_vol_score + + 0.05 * own_gain_score + ) + return annotated, sector_stats + + def _basket_quality_stats( picks: list[tuple[str, str]], morning_gains: dict[str, dict], @@ -595,7 +1087,9 @@ def _basket_quality_stats( "avg_quality": 0.0, "best_quality": 0.0, "event_count": 0.0, + "strong_event_count": 0.0, "liquid_largecap_count": 0.0, + "moderate_gap_liquid_count": 0.0, "max_gain_pct": 0.0, "avg_support": 0.0, "max_entropy_20d": 0.0, @@ -604,18 +1098,26 @@ def _basket_quality_stats( qualities: list[float] = [] support_scores: list[float] = [] event_count = 0 + strong_event_count = 0 liquid_largecap_count = 0 + moderate_gap_liquid_count = 0 max_gain_pct = 0.0 max_entropy_20d = 0.0 max_confirmation_return_pct = 0.0 for ticker, _sleeve in picks: info = morning_gains.get(ticker, {}) qualities.append(_momentum_quality_score(info, strategy)) - support_scores.append(_same_day_support_score(info)) + support_score = _same_day_support_score(info) + support_scores.append(support_score) if info.get("is_event_candidate"): event_count += 1 + min_event_support = strategy.tail_risk_day_event_exemption_min_support_score + if min_event_support is None or support_score >= min_event_support: + strong_event_count += 1 if info.get("is_liquid_largecap"): liquid_largecap_count += 1 + if info.get("is_moderate_gap_liquid"): + moderate_gap_liquid_count += 1 max_gain_pct = max(max_gain_pct, float(info.get("gain_pct") or 0.0)) max_entropy_20d = max(max_entropy_20d, float(info.get("entropy_20d") or 0.0)) max_confirmation_return_pct = max( @@ -627,7 +1129,9 @@ def _basket_quality_stats( "avg_quality": sum(qualities) / len(qualities), "best_quality": max(qualities), "event_count": float(event_count), + "strong_event_count": float(strong_event_count), "liquid_largecap_count": float(liquid_largecap_count), + "moderate_gap_liquid_count": float(moderate_gap_liquid_count), "max_gain_pct": max_gain_pct, "avg_support": sum(support_scores) / len(support_scores), "max_entropy_20d": max_entropy_20d, @@ -718,13 +1222,83 @@ def _tail_risk_day_scaler( and stats["max_confirmation_return_pct"] < strategy.tail_risk_day_min_max_confirmation_return_pct ): return 1.0 - if strategy.tail_risk_day_require_no_event and stats["event_count"] > 0: + if strategy.tail_risk_day_require_no_event and stats["strong_event_count"] > 0: return 1.0 if strategy.tail_risk_day_exempt_largecap and stats["liquid_largecap_count"] > 0: return 1.0 return max(0.0, min(1.0, strategy.tail_risk_day_scale)) +def _low_momentum_single_name_scaler( + picks: list[tuple[str, str]], + morning_gains: dict[str, dict], + strategy: StrategyParams, +) -> float: + scale = strategy.low_momentum_single_name_scale + threshold = strategy.low_momentum_single_name_max_gain_pct + if not picks or len(picks) != 1 or threshold is None or scale >= 1.0: + return 1.0 + + stats = _basket_quality_stats(picks, morning_gains, strategy) + if stats["max_gain_pct"] > threshold: + return 1.0 + if ( + strategy.low_momentum_single_name_require_no_event + and stats["strong_event_count"] > 0 + ): + return 1.0 + if ( + strategy.low_momentum_single_name_exempt_largecap + and stats["liquid_largecap_count"] > 0 + ): + return 1.0 + return max(0.0, min(1.0, scale)) + + +def _soft_day_sparse_scaler( + picks: list[tuple[str, str]], + morning_gains: dict[str, dict], + strategy: StrategyParams, + *, + is_soft_day: bool, +) -> float: + scale = strategy.soft_day_sparse_scale + if not is_soft_day or not picks or scale >= 1.0: + return 1.0 + if not any( + [ + strategy.soft_day_sparse_max_trades is not None, + strategy.soft_day_sparse_require_no_event, + strategy.soft_day_sparse_exempt_largecap, + strategy.soft_day_sparse_exempt_moderate_gap_liquid, + ] + ): + return 1.0 + + stats = _basket_quality_stats(picks, morning_gains, strategy) + if ( + strategy.soft_day_sparse_max_trades is not None + and stats["count"] > strategy.soft_day_sparse_max_trades + ): + return 1.0 + if ( + strategy.soft_day_sparse_require_no_event + and stats["strong_event_count"] > 0 + ): + return 1.0 + if ( + strategy.soft_day_sparse_exempt_largecap + and stats["liquid_largecap_count"] > 0 + ): + return 1.0 + if ( + strategy.soft_day_sparse_exempt_moderate_gap_liquid + and stats["moderate_gap_liquid_count"] > 0 + ): + return 1.0 + return max(0.0, min(1.0, scale)) + + def _apply_basket_quality_floor( picks: list[tuple[str, str]], morning_gains: dict[str, dict], @@ -774,6 +1348,69 @@ def _apply_basket_quality_floor( return kept +def _is_liquid_continuation_candidate(info: dict) -> bool: + return bool( + info.get("is_moderate_gap_liquid") + or info.get("is_liquid_largecap") + or info.get("is_sector_thrust") + ) + + +def _liquid_continuation_core_score(info: dict) -> float: + low_entropy = max(1.0 - float(info.get("entropy_20d") or 1.0), 0.0) + return ( + (3.0 if info.get("is_moderate_gap_liquid") else 0.0) + + (2.5 if info.get("is_liquid_largecap") else 0.0) + + (1.5 if info.get("is_sector_thrust") else 0.0) + + 2.0 * _same_day_support_score(info) + + max((info.get("confirmation_return_pct") or 0.0) * 25.0, 0.0) + + max((info.get("gain_pct") or 0.0) * 8.0, 0.0) + + min(max((info.get("entry_dollar_volume") or 0.0) / 150_000_000.0, 0.0), 4.0) + + min(max((info.get("avg_dollar_vol_30d") or 0.0) / 1_000_000_000.0, 0.0), 4.0) + + low_entropy + ) + + +def _select_liquid_continuation_core( + eligible_gains: dict[str, dict], + strategy: StrategyParams, + *, + can_pick_ticker, + record_pick, +) -> list[tuple[str, str]]: + items = list(eligible_gains.items()) + preferred = [item for item in items if _is_liquid_continuation_candidate(item[1])] + if not preferred: + return [] + + ranked_preferred = sorted( + preferred, + key=lambda item: ( + _liquid_continuation_core_score(item[1]), + _same_day_support_score(item[1]), + item[1].get("confirmation_return_pct", -999.0), + item[1].get("entry_dollar_volume", 0.0), + item[1].get("gain_pct", 0.0), + -_safe_value(item[1].get("entropy_20d"), default=1.0), + ), + reverse=True, + ) + + picks: list[tuple[str, str]] = [] + chosen: set[str] = set() + for ticker, _info in ranked_preferred: + if ticker in chosen: + continue + if not can_pick_ticker(ticker): + continue + picks.append((ticker, "liquid_continuation_core")) + chosen.add(ticker) + record_pick(ticker) + if len(picks) >= strategy.top_n: + return _apply_basket_quality_floor(picks, eligible_gains, strategy) + return _apply_basket_quality_floor(picks, eligible_gains, strategy) + + def _select_momentum_sleeves( morning_gains: dict[str, dict], strategy: StrategyParams, @@ -782,6 +1419,11 @@ def _select_momentum_sleeves( """Return ordered (ticker, sleeve) picks for the day.""" if not morning_gains: return [] + morning_gains = _annotate_sector_thrust_features( + morning_gains, + strategy, + ticker_sectors, + ) sector_cap = strategy.max_positions_per_sector if strategy.max_positions_per_sector and strategy.max_positions_per_sector > 0 else None sector_counts: dict[str, int] = {} @@ -809,12 +1451,29 @@ def _select_momentum_sleeves( return sector_counts[sector] = sector_counts.get(sector, 0) + 1 + eligible_gains = { + ticker: info + for ticker, info in morning_gains.items() + if not info.get("overlay_only_candidate") + } + if not eligible_gains: + return [] + + selection_mode = str(getattr(strategy, "momentum_selection_mode", "standard") or "standard").lower() + if selection_mode == "liquid_continuation": + return _select_liquid_continuation_core( + eligible_gains, + strategy, + can_pick_ticker=_can_pick_ticker, + record_pick=_record_pick, + ) + if not strategy.use_five_sleeves: ranked = sorted( - morning_gains.keys(), + eligible_gains.keys(), key=lambda t: ( - morning_gains[t]["gain_pct"], - morning_gains[t].get("entry_volume", 0.0), + eligible_gains[t]["gain_pct"], + eligible_gains[t].get("entry_volume", 0.0), ), reverse=True, ) @@ -826,13 +1485,13 @@ def _select_momentum_sleeves( _record_pick(ticker) if len(picks) >= strategy.top_n: break - return _apply_basket_quality_floor(picks, morning_gains, strategy) + return _apply_basket_quality_floor(picks, eligible_gains, strategy) sleeves = _five_sleeve_specs(strategy) picks: list[tuple[str, str]] = [] chosen: set[str] = set() - items = list(morning_gains.items()) + items = list(eligible_gains.items()) forced_sleeves = [ sleeve for sleeve in sorted(sleeves, key=lambda sleeve: float(sleeve["weight"]), reverse=True) @@ -851,7 +1510,7 @@ def _select_momentum_sleeves( _record_pick(ticker) break if len(picks) >= strategy.top_n: - return _apply_basket_quality_floor(picks[: strategy.top_n], morning_gains, strategy) + return _apply_basket_quality_floor(picks[: strategy.top_n], eligible_gains, strategy) fallback_slots = max(0, int(getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0)) fallback_trigger = max(0, int(getattr(strategy, "fallback_liquid_largecap_trigger_below", 0) or 0)) @@ -907,7 +1566,356 @@ def _select_momentum_sleeves( _record_pick(ticker) if len(picks) >= strategy.top_n: break - return _apply_basket_quality_floor(picks, morning_gains, strategy) + return _apply_basket_quality_floor(picks, eligible_gains, strategy) + + +def _select_event_day_liquid_picks( + morning_gains: dict[str, dict], + existing_picks: list[tuple[str, str]], + strategy: StrategyParams, + *, + is_soft_day: bool, + activation_stats: dict[str, float | int | bool] | None = None, +) -> list[tuple[str, str]]: + if not ( + strategy.use_event_day_liquid_sleeve + and strategy.event_day_liquid_capital_fraction > 0 + and strategy.event_day_liquid_max_positions > 0 + ): + return [] + if strategy.event_day_liquid_soft_day_only and not is_soft_day: + return [] + + activation = activation_stats or _event_day_liquid_activation_stats(morning_gains, strategy) + if not bool(activation.get("qualifies")): + return [] + + chosen = {ticker for ticker, _sleeve in existing_picks} + ranked = sorted( + ( + (ticker, info) + for ticker, info in morning_gains.items() + if ticker not in chosen + ), + key=lambda item: _event_day_liquid_pick_score(item[1]), + reverse=True, + ) + picks: list[tuple[str, str]] = [] + for ticker, info in ranked: + gain_pct = float(info.get("gain_pct") or 0.0) + if ( + strategy.event_day_liquid_min_gain_pct is not None + and gain_pct < strategy.event_day_liquid_min_gain_pct + ): + continue + if ( + strategy.event_day_liquid_max_gain_pct is not None + and gain_pct > strategy.event_day_liquid_max_gain_pct + ): + continue + confirmation_return_pct = info.get("confirmation_return_pct") + if ( + strategy.event_day_liquid_min_confirmation_return_pct is not None + and ( + confirmation_return_pct is None + or confirmation_return_pct < strategy.event_day_liquid_min_confirmation_return_pct + ) + ): + continue + entry_dollar_volume = float(info.get("entry_dollar_volume") or 0.0) + if ( + strategy.event_day_liquid_min_entry_dollar_volume is not None + and entry_dollar_volume < strategy.event_day_liquid_min_entry_dollar_volume + ): + continue + avg_dollar_vol_30d = info.get("avg_dollar_vol_30d") + if ( + strategy.event_day_liquid_min_avg_dollar_vol_30d is not None + and ( + avg_dollar_vol_30d is None + or avg_dollar_vol_30d < strategy.event_day_liquid_min_avg_dollar_vol_30d + ) + ): + continue + entropy_20d = info.get("entropy_20d") + if ( + strategy.event_day_liquid_max_entropy_20d is not None + and ( + entropy_20d is None + or entropy_20d > strategy.event_day_liquid_max_entropy_20d + ) + ): + continue + support_score = _same_day_support_score(info) + if ( + strategy.event_day_liquid_min_support_score is not None + and support_score < strategy.event_day_liquid_min_support_score + ): + continue + picks.append((ticker, "event_day_liquid")) + if len(picks) >= strategy.event_day_liquid_max_positions: + break + return picks + + +def _select_liquid_cluster_picks( + morning_gains: dict[str, dict], + base_picks: list[tuple[str, str]], + strategy: StrategyParams, +) -> list[tuple[str, str]]: + if not ( + strategy.use_liquid_cluster_engine + and strategy.liquid_cluster_capital_fraction > 0 + and strategy.liquid_cluster_max_positions > 0 + ): + return [] + + chosen = {ticker for ticker, _sleeve in base_picks} + max_per_sector = max(1, int(strategy.liquid_cluster_max_positions_per_sector or 1)) + sector_counts: dict[str, int] = {} + picks: list[tuple[str, str]] = [] + ranked = sorted( + ( + (ticker, info) + for ticker, info in morning_gains.items() + if info.get("is_liquid_cluster") and ticker not in chosen + ), + key=lambda item: ( + float(item[1].get("liquid_cluster_score") or 0.0), + float(item[1].get("entry_dollar_volume") or 0.0), + float(item[1].get("confirmation_return_pct") or -999.0), + float(item[1].get("gain_pct") or 0.0), + ), + reverse=True, + ) + for ticker, info in ranked: + sector = str(info.get("liquid_cluster_sector") or "").strip() + if sector and sector_counts.get(sector, 0) >= max_per_sector: + continue + picks.append((ticker, "liquid_cluster_engine")) + chosen.add(ticker) + if sector: + sector_counts[sector] = sector_counts.get(sector, 0) + 1 + if len(picks) >= strategy.liquid_cluster_max_positions: + break + return picks + + +def _select_sector_etf_picks( + morning_gains: dict[str, dict], + cluster_stats: dict[str, dict[str, object]], + base_picks: list[tuple[str, str]], + cluster_picks: list[tuple[str, str]], + sector_proxy_bars_by_ticker: dict[str, list[dict]] | None, + strategy: StrategyParams, +) -> list[tuple[str, str, str]]: + if not ( + strategy.use_sector_etf_sleeve + and strategy.sector_etf_capital_fraction > 0 + and strategy.sector_etf_max_positions > 0 + and sector_proxy_bars_by_ticker + ): + return [] + + base_sectors = { + str(morning_gains.get(ticker, {}).get("liquid_cluster_sector") or "").strip() + for ticker, _sleeve in cluster_picks + } + ranked: list[tuple[float, float, float, str, str]] = [] + for sector, stats in cluster_stats.items(): + if not bool(stats.get("qualifies")): + continue + if sector in base_sectors: + continue + proxy_ticker = stats.get("proxy_ticker") + if not isinstance(proxy_ticker, str) or proxy_ticker not in sector_proxy_bars_by_ticker: + continue + sector_score = float(stats.get("sector_score") or 0.0) + if ( + strategy.sector_etf_min_sector_score is not None + and sector_score < strategy.sector_etf_min_sector_score + ): + continue + ranked.append( + ( + sector_score, + float(stats.get("total_entry_dollar_volume") or 0.0), + float(stats.get("avg_confirmation_return_pct") or 0.0), + sector, + proxy_ticker, + ) + ) + + ranked.sort(reverse=True) + picks: list[tuple[str, str, str]] = [] + chosen_proxies = {ticker for ticker, _sleeve in base_picks} + for _score, _total_dv, _avg_conf, sector, proxy_ticker in ranked: + if proxy_ticker in chosen_proxies: + continue + picks.append((proxy_ticker, "sector_etf", sector)) + chosen_proxies.add(proxy_ticker) + if len(picks) >= strategy.sector_etf_max_positions: + break + return picks + + +def _execution_info_from_bars( + bars: list[dict], + strategy: StrategyParams, + date_str: str, +) -> dict | None: + """Build execution context from raw intraday bars without candidate filters.""" + market_open = _market_open_ts(date_str) + mkt_bars = filter_market_hours(bars) + if len(mkt_bars) < _MIN_BARS: + return None + + open_price = mkt_bars[0]["open"] + if open_price <= 0: + return None + + initial_entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open) + if initial_entry_bar is None: + return None + + entry_bar = initial_entry_bar + confirmation_return = None + if strategy.confirmation_minutes_after_entry > 0: + confirmation_bar = _bar_at_offset( + mkt_bars, + market_open, + strategy.entry_minutes_after_open + strategy.confirmation_minutes_after_entry, + ) + if confirmation_bar is None: + return None + confirmation_return = ( + confirmation_bar["close"] - initial_entry_bar["close"] + ) / initial_entry_bar["close"] + entry_bar = confirmation_bar + + entry_price_raw = entry_bar["close"] + if entry_price_raw <= 0: + return None + + entry_ts = _parse_ts(entry_bar["timestamp"]) + opening_range_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) <= entry_ts] + if not opening_range_bars: + return None + opening_range_high = max(float(bar["high"]) for bar in opening_range_bars) + opening_range_low = min(float(bar["low"]) for bar in opening_range_bars) + opening_range_width = max(0.0, opening_range_high - opening_range_low) + return { + "gain_pct": (entry_price_raw - open_price) / open_price, + "entry_price_raw": entry_price_raw, + "entry_bar": entry_bar, + "mkt_bars": mkt_bars, + "entry_volume": _volume_up_to_bar(mkt_bars, entry_ts), + "entry_dollar_volume": _dollar_volume_up_to_bar(mkt_bars, entry_ts), + "opening_range_width": opening_range_width, + "confirmation_return_pct": confirmation_return, + "recovery_from_opening_low_pct": ( + (entry_price_raw - opening_range_low) / opening_range_low + if opening_range_low > 0 + else None + ), + "gap_pct": None, + "volume_ratio_14d": None, + "ret_5d": None, + "entropy_20d": None, + "avg_dollar_vol_30d": None, + "atr_14": None, + "event_score": None, + "is_liquid_largecap": False, + "is_moderate_gap_liquid": False, + "is_sector_thrust": False, + "is_liquid_cluster": False, + } + + +def _build_intraday_trade( + ticker: str, + info: dict, + *, + date_str: str, + strategy: StrategyParams, + trade_capital: float, + sleeve: str, +) -> IntradayTrade: + entry_price_raw = info["entry_price_raw"] + entry_bar = info["entry_bar"] + mkt_bars = info["mkt_bars"] + + exit_price, exit_time_str, exit_reason = simulate_trade( + mkt_bars, + entry_bar, + entry_price_raw, + strategy.exit_minutes_before_close, + strategy.stop_loss_pct, + _trade_trailing_stop_pct(info, strategy), + _trade_catastrophic_stop_price(info, strategy), + strategy.trailing_activation_gain_pct, + strategy.slippage_bps, + date_str, + ) + + entry_price_filled = _apply_slippage_entry(entry_price_raw, strategy.slippage_bps) + shares = trade_capital / entry_price_filled if trade_capital > 0 else 0.0 + pnl_pct = (exit_price - entry_price_filled) / entry_price_filled if entry_price_filled > 0 else 0.0 + pnl = pnl_pct * trade_capital + slippage_cost = ( + (entry_price_filled - entry_price_raw) + + (entry_price_raw * strategy.slippage_bps / 10_000) + ) * shares + + return IntradayTrade( + date=date_str, + ticker=ticker, + entry_price=round(entry_price_filled, 4), + exit_price=round(exit_price, 4), + entry_time=entry_bar["timestamp"], + exit_time=exit_time_str, + shares=round(shares, 4), + pnl=round(pnl, 4), + pnl_pct=round(pnl_pct, 6), + exit_reason=exit_reason, + morning_gain_pct=round(float(info.get("gain_pct") or 0.0), 6), + slippage_cost=round(slippage_cost, 4), + trade_sleeve=sleeve, + gap_pct=_round_optional(info.get("gap_pct"), 6), + confirmation_return_pct=_round_optional(info.get("confirmation_return_pct"), 6), + entry_dollar_volume=_round_optional(info.get("entry_dollar_volume"), 2), + avg_dollar_vol_30d=_round_optional(info.get("avg_dollar_vol_30d"), 2), + entropy_20d=_round_optional(info.get("entropy_20d"), 6), + ret_5d=_round_optional(info.get("ret_5d"), 6), + event_score=_round_optional(info.get("event_score"), 4), + support_score=round(_same_day_support_score(info), 6), + is_liquid_largecap=bool(info.get("is_liquid_largecap")), + is_moderate_gap_liquid=bool(info.get("is_moderate_gap_liquid")), + is_sector_thrust=bool(info.get("is_sector_thrust")), + sector_thrust_member_count=int(info.get("sector_thrust_member_count") or 0), + sector_thrust_total_entry_dollar_volume=_round_optional( + info.get("sector_thrust_total_entry_dollar_volume"), + 2, + ), + is_liquid_cluster=bool(info.get("is_liquid_cluster")), + liquid_cluster_member_count=int(info.get("liquid_cluster_member_count") or 0), + liquid_cluster_total_entry_dollar_volume=_round_optional( + info.get("liquid_cluster_total_entry_dollar_volume"), + 2, + ), + liquid_cluster_sector=( + str(info.get("liquid_cluster_sector")) + if info.get("liquid_cluster_sector") + else None + ), + liquid_cluster_sector_score=_round_optional(info.get("liquid_cluster_sector_score"), 6), + sector_proxy_ticker=( + str(info.get("sector_proxy_ticker")) + if info.get("sector_proxy_ticker") + else None + ), + total_capital_deployed=round(trade_capital, 4), + ) # ── Trade Simulation ─────────────────────────────────────────────────────── @@ -1035,6 +2043,27 @@ def compute_morning_gains( {ticker: {gain_pct, entry_price_raw, entry_bar, mkt_bars, entry_volume}} """ market_open = _market_open_ts(date_str) + allowed_event_types = { + str(value).strip().lower() + for value in getattr(strategy, "candidate_allowed_event_types", []) + if str(value).strip() + } + + def _effective_event_state(daily_features: dict) -> tuple[bool, float]: + raw_event_flag = bool(daily_features.get("event_flag")) + raw_event_score = float(daily_features.get("event_score") or 0.0) + if not raw_event_flag: + return False, 0.0 + if allowed_event_types: + raw_event_types = daily_features.get("event_types") or [] + event_types = { + str(value).strip().lower() + for value in raw_event_types + if str(value).strip() + } + if not event_types or not any(event_type in allowed_event_types for event_type in event_types): + return False, 0.0 + return True, raw_event_score # Market regime check: compute SPY's morning return if strategy.market_regime_spy_threshold is not None and spy_bars: @@ -1131,8 +2160,10 @@ def compute_morning_gains( entropy_20d = daily_features.get("entropy_20d") avg_dollar_vol_30d = daily_features.get("avg_dollar_vol_30d") atr_14 = daily_features.get("atr_14") - event_flag = bool(daily_features.get("event_flag")) - event_score = float(daily_features.get("event_score") or 0.0) + raw_event_flag = bool(daily_features.get("event_flag")) + raw_event_score = float(daily_features.get("event_score") or 0.0) + event_types = list(daily_features.get("event_types") or []) + event_flag, event_score = _effective_event_state(daily_features) attention_wiki_spike_10d = float(daily_features.get("attention_wiki_spike_10d") or 0.0) attention_article_count_3d = int(daily_features.get("attention_article_count_3d") or 0) attention_us_article_count_3d = int(daily_features.get("attention_us_article_count_3d") or 0) @@ -1213,6 +2244,8 @@ def compute_morning_gains( liquid_largecap_enabled = ( strategy.use_liquid_largecap_sleeve or (getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0) > 0 + or _liquid_cluster_overlay_enabled(strategy) + or _event_day_liquid_overlay_enabled(strategy) ) if liquid_largecap_enabled and gap_min_ok and gap_max_ok and confirmation_ok: liquid_largecap_entropy_cap = strategy.liquid_largecap_max_entropy_20d @@ -1255,7 +2288,12 @@ def compute_morning_gains( liquid_largecap_ok = True moderate_gap_liquid_ok = False - if strategy.use_moderate_gap_liquid_sleeve: + moderate_gap_liquid_enabled = ( + strategy.use_moderate_gap_liquid_sleeve + or _liquid_cluster_overlay_enabled(strategy) + or _event_day_liquid_overlay_enabled(strategy) + ) + if moderate_gap_liquid_enabled: moderate_entropy_cap = strategy.moderate_gap_liquid_max_entropy_20d if moderate_entropy_cap is None: moderate_entropy_cap = strategy.max_entropy_20d @@ -1332,6 +2370,20 @@ def compute_morning_gains( else: moderate_gap_liquid_ok = True + liquid_cluster_candidate_ok = False + if _liquid_cluster_overlay_enabled(strategy): + liquid_cluster_candidate_ok = _passes_liquid_cluster_own_gate( + strategy, + gain_pct=gain_pct, + confirmation_return_pct=confirmation_return, + entry_dollar_volume=entry_dollar_vol, + avg_dollar_vol_30d=avg_dollar_vol_30d, + volume_ratio_14d=volume_ratio_14d, + entropy_20d=entropy_20d, + is_moderate_gap_liquid=moderate_gap_liquid_ok, + is_liquid_largecap=liquid_largecap_ok, + ) + gap_reclaim_ok = False if strategy.use_gap_reclaim_sleeve: if strategy.gap_reclaim_min_gap_pct is not None and ( @@ -1371,6 +2423,7 @@ def compute_morning_gains( and not slow_ignite_ok and not liquid_largecap_ok and not moderate_gap_liquid_ok + and not liquid_cluster_candidate_ok and not gap_reclaim_ok ): continue @@ -1388,6 +2441,9 @@ def compute_morning_gains( "entropy_20d": entropy_20d, "avg_dollar_vol_30d": avg_dollar_vol_30d, "atr_14": atr_14, + "raw_event_flag": raw_event_flag, + "raw_event_score": raw_event_score, + "event_types": event_types, "event_flag": event_flag, "event_score": event_score, "attention_wiki_spike_10d": attention_wiki_spike_10d, @@ -1402,6 +2458,14 @@ def compute_morning_gains( "is_liquid_largecap": liquid_largecap_ok, "is_moderate_gap_liquid": moderate_gap_liquid_ok, "is_gap_reclaim": gap_reclaim_ok, + "overlay_only_candidate": bool( + liquid_cluster_candidate_ok + and not regular_ok + and not slow_ignite_ok + and not liquid_largecap_ok + and not moderate_gap_liquid_ok + and not gap_reclaim_ok + ), } return result @@ -1420,6 +2484,7 @@ def simulate_day( vix_value: float | None = None, current_equity: float | None = None, ticker_sectors: dict[str, str] | None = None, + sector_proxy_bars_by_ticker: dict[str, list[dict]] | None = None, ) -> DayResult: """Simulate one full trading day. @@ -1468,6 +2533,16 @@ def simulate_day( daily_features_by_ticker=daily_features_by_ticker, vix_value=None, ) + morning_gains = _annotate_sector_thrust_features( + morning_gains, + strategy, + ticker_sectors, + ) + morning_gains, liquid_cluster_stats = _annotate_liquid_cluster_features( + morning_gains, + strategy, + ticker_sectors, + ) result.candidates_found = len(morning_gains) if not morning_gains: @@ -1519,8 +2594,46 @@ def simulate_day( sector_scaler = _basket_sector_scaler(top_tickers, ticker_sectors, strategy) result.sector_scaler = sector_scaler result.is_soft_day = (regime_scaler * breadth_scaler * sector_scaler) < strategy.soft_day_scaler_threshold - tail_risk_scaler = _tail_risk_day_scaler(top_tickers, morning_gains, strategy) + soft_day_sparse_scaler = _soft_day_sparse_scaler( + top_tickers, + morning_gains, + strategy, + is_soft_day=result.is_soft_day, + ) + result.soft_day_sparse_scaler = soft_day_sparse_scaler + tail_risk_scaler = min( + _tail_risk_day_scaler(top_tickers, morning_gains, strategy), + _low_momentum_single_name_scaler(top_tickers, morning_gains, strategy), + soft_day_sparse_scaler, + ) result.tail_risk_scaler = tail_risk_scaler + event_day_liquid_activation = _event_day_liquid_activation_stats(morning_gains, strategy) + result.event_day_liquid_active = bool(event_day_liquid_activation.get("qualifies")) + result.event_day_liquid_event_count = int(event_day_liquid_activation.get("event_count") or 0) + result.event_day_liquid_total_event_entry_dollar_volume = round( + float(event_day_liquid_activation.get("total_entry_dollar_volume") or 0.0), + 2, + ) + event_day_liquid_picks = _select_event_day_liquid_picks( + morning_gains, + top_tickers, + strategy, + is_soft_day=result.is_soft_day, + activation_stats=event_day_liquid_activation, + ) + liquid_cluster_picks = _select_liquid_cluster_picks( + morning_gains, + top_tickers + event_day_liquid_picks, + strategy, + ) + sector_etf_picks = _select_sector_etf_picks( + morning_gains, + liquid_cluster_stats, + top_tickers, + liquid_cluster_picks, + sector_proxy_bars_by_ticker, + strategy, + ) if strategy.daily_budget_reset: # Research mode: every day resets to initial_capital (ignore prior-day PnL). @@ -1542,56 +2655,114 @@ def simulate_day( * tail_risk_scaler * _sparse_day_scaler(len(top_tickers), strategy) ) - capital_per_trade = capital_budget / len(top_tickers) - - for ticker, sleeve in top_tickers: - info = morning_gains[ticker] - entry_price_raw = info["entry_price_raw"] - entry_bar = info["entry_bar"] - mkt_bars = info["mkt_bars"] - - exit_price, exit_time_str, exit_reason = simulate_trade( - mkt_bars, - entry_bar, - entry_price_raw, - strategy.exit_minutes_before_close, - strategy.stop_loss_pct, - _trade_trailing_stop_pct(info, strategy), - _trade_catastrophic_stop_price(info, strategy), - strategy.trailing_activation_gain_pct, - strategy.slippage_bps, - date_str, - ) - - entry_price_filled = _apply_slippage_entry(entry_price_raw, strategy.slippage_bps) - trade_capital = capital_per_trade * _entropy_trade_scaler(info.get("entropy_20d"), strategy) - shares = trade_capital / entry_price_filled - pnl_pct = (exit_price - entry_price_filled) / entry_price_filled - pnl = pnl_pct * trade_capital - - slippage_cost = ( - (entry_price_filled - entry_price_raw) + - (entry_price_raw * strategy.slippage_bps / 10_000) - ) * shares - - trade = IntradayTrade( - date=date_str, - ticker=ticker, - entry_price=round(entry_price_filled, 4), - exit_price=round(exit_price, 4), - entry_time=entry_bar["timestamp"], - exit_time=exit_time_str, - shares=round(shares, 4), - pnl=round(pnl, 4), - pnl_pct=round(pnl_pct, 6), - exit_reason=exit_reason, - morning_gain_pct=round(info["gain_pct"], 6), - slippage_cost=round(slippage_cost, 4), - trade_sleeve=sleeve, - total_capital_deployed=round(trade_capital, 4), - ) - result.trades.append(trade) - result.daily_pnl += trade.pnl + event_day_liquid_fraction = ( + max(0.0, min(1.0, strategy.event_day_liquid_capital_fraction)) + if event_day_liquid_picks + else 0.0 + ) + liquid_cluster_fraction = ( + max(0.0, min(1.0, strategy.liquid_cluster_capital_fraction)) + if liquid_cluster_picks + else 0.0 + ) + sector_etf_fraction = ( + max(0.0, min(1.0, strategy.sector_etf_capital_fraction)) + if sector_etf_picks + else 0.0 + ) + reserved_fraction = event_day_liquid_fraction + liquid_cluster_fraction + sector_etf_fraction + if reserved_fraction > 1.0: + event_day_liquid_fraction /= reserved_fraction + liquid_cluster_fraction /= reserved_fraction + sector_etf_fraction /= reserved_fraction + base_fraction = 0.0 + else: + base_fraction = 1.0 - reserved_fraction + + base_capital_budget = capital_budget * base_fraction + event_day_liquid_capital_budget = capital_budget * event_day_liquid_fraction + liquid_cluster_capital_budget = capital_budget * liquid_cluster_fraction + sector_etf_capital_budget = capital_budget * sector_etf_fraction + + if top_tickers and base_capital_budget > 0: + capital_per_trade = base_capital_budget / len(top_tickers) + for ticker, sleeve in top_tickers: + info = morning_gains[ticker] + trade_capital = capital_per_trade * _entropy_trade_scaler(info.get("entropy_20d"), strategy) + trade = _build_intraday_trade( + ticker, + info, + date_str=date_str, + strategy=strategy, + trade_capital=trade_capital, + sleeve=sleeve, + ) + result.trades.append(trade) + result.daily_pnl += trade.pnl + + if event_day_liquid_picks and event_day_liquid_capital_budget > 0: + event_day_liquid_capital_per_trade = event_day_liquid_capital_budget / len(event_day_liquid_picks) + for ticker, sleeve in event_day_liquid_picks: + info = morning_gains[ticker] + trade_capital = event_day_liquid_capital_per_trade * _entropy_trade_scaler( + info.get("entropy_20d"), + strategy, + ) + trade = _build_intraday_trade( + ticker, + info, + date_str=date_str, + strategy=strategy, + trade_capital=trade_capital, + sleeve=sleeve, + ) + result.trades.append(trade) + result.daily_pnl += trade.pnl + + if liquid_cluster_picks and liquid_cluster_capital_budget > 0: + cluster_capital_per_trade = liquid_cluster_capital_budget / len(liquid_cluster_picks) + for ticker, sleeve in liquid_cluster_picks: + info = morning_gains[ticker] + trade_capital = cluster_capital_per_trade * _entropy_trade_scaler(info.get("entropy_20d"), strategy) + trade = _build_intraday_trade( + ticker, + info, + date_str=date_str, + strategy=strategy, + trade_capital=trade_capital, + sleeve=sleeve, + ) + result.trades.append(trade) + result.daily_pnl += trade.pnl + + if sector_etf_picks and sector_etf_capital_budget > 0 and sector_proxy_bars_by_ticker: + etf_capital_per_trade = sector_etf_capital_budget / len(sector_etf_picks) + for proxy_ticker, sleeve, sector in sector_etf_picks: + proxy_info = _execution_info_from_bars( + sector_proxy_bars_by_ticker.get(proxy_ticker, []), + strategy, + date_str, + ) + if not proxy_info: + continue + stats = liquid_cluster_stats.get(sector, {}) + proxy_info["liquid_cluster_sector"] = sector + proxy_info["liquid_cluster_sector_score"] = float(stats.get("sector_score") or 0.0) + proxy_info["liquid_cluster_member_count"] = int(stats.get("member_count") or 0) + proxy_info["liquid_cluster_total_entry_dollar_volume"] = float( + stats.get("total_entry_dollar_volume") or 0.0 + ) + proxy_info["sector_proxy_ticker"] = proxy_ticker + trade = _build_intraday_trade( + proxy_ticker, + proxy_info, + date_str=date_str, + strategy=strategy, + trade_capital=etf_capital_per_trade, + sleeve=sleeve, + ) + result.trades.append(trade) + result.daily_pnl += trade.pnl if result.trades: total_deployed = sum((t.total_capital_deployed or (t.shares * t.entry_price)) for t in result.trades) @@ -1615,6 +2786,7 @@ def run_simulation( daily_enrichment: dict[str, dict[str, dict]] | None = None, vix_by_day: dict[str, float] | None = None, ticker_sectors: dict[str, str] | None = None, + sector_proxy_intraday_by_day: dict[str, dict[str, list[dict]]] | None = None, ) -> list[DayResult]: """Run the full backtest simulation across all trading days. @@ -1710,6 +2882,7 @@ def run_simulation( vix_value=(vix_by_day or {}).get(date_str), current_equity=equity, ticker_sectors=ticker_sectors, + sector_proxy_bars_by_ticker=(sector_proxy_intraday_by_day or {}).get(date_str), ) results.append(day_result) equity += day_result.daily_pnl diff --git a/tests/unit/intraday/test_run_helpers.py b/tests/unit/intraday/test_run_helpers.py index f8a3ca7..5a97418 100644 --- a/tests/unit/intraday/test_run_helpers.py +++ b/tests/unit/intraday/test_run_helpers.py @@ -17,9 +17,12 @@ from apps.intraday_bt.run import ( _normalize_candidate_map, _momentum_intraday_seed_candidates, _momentum_strategy_uses_candidate_stage_catalyst, + _momentum_strategy_uses_daily_enrichment, _momentum_strategy_requires_regime_ticker_daily, _momentum_strategy_uses_attention, _momentum_strategy_uses_catalyst, + _momentum_strategy_uses_sector_labels, + _momentum_strategy_uses_sector_proxies, _retain_recent_intraday_shortlist, _recent_intraday_first_candidates, _strategy_for_recent_live_scan, @@ -275,13 +278,26 @@ def test_momentum_strategy_uses_seed_event_overlay_for_candidate_stage_catalyst( assert _momentum_strategy_uses_candidate_stage_catalyst(strategy) is True +def test_momentum_strategy_uses_candidate_event_type_filter_for_fetch_activation() -> None: + strategy = StrategyParams(candidate_allowed_event_types=["earnings_release"]) + + assert _momentum_strategy_uses_catalyst(strategy) is True + assert _momentum_strategy_uses_candidate_stage_catalyst(strategy) is True + + def test_momentum_strategy_uses_event_reserve_and_event_sleeve_for_fetch_activation() -> None: reserve_strategy = StrategyParams(candidate_intraday_event_reserve_slots=1) sleeve_strategy = StrategyParams(use_event_sleeve=True, event_weight=0.1) + event_day_liquid_strategy = StrategyParams( + use_event_day_liquid_sleeve=True, + event_day_liquid_capital_fraction=0.1, + event_day_liquid_max_positions=1, + ) assert _momentum_strategy_uses_catalyst(reserve_strategy) is True assert _momentum_strategy_uses_candidate_stage_catalyst(reserve_strategy) is False assert _momentum_strategy_uses_catalyst(sleeve_strategy) is True + assert _momentum_strategy_uses_catalyst(event_day_liquid_strategy) is True def test_momentum_strategy_uses_intraday_attention_weight_for_fetch_activation() -> None: @@ -296,6 +312,21 @@ def test_momentum_strategy_requires_regime_ticker_daily_for_gap_meta_layer() -> assert _momentum_strategy_requires_regime_ticker_daily(strategy) is True +def test_momentum_strategy_uses_sector_metadata_and_proxy_fetch_for_overlay_engines() -> None: + cluster_strategy = StrategyParams(use_liquid_cluster_engine=True) + etf_strategy = StrategyParams( + use_sector_etf_sleeve=True, + sector_etf_capital_fraction=0.2, + sector_etf_max_positions=1, + ) + + assert _momentum_strategy_uses_daily_enrichment(cluster_strategy) is True + assert _momentum_strategy_uses_sector_labels(cluster_strategy) is True + assert _momentum_strategy_uses_sector_proxies(cluster_strategy) is False + assert _momentum_strategy_uses_sector_labels(etf_strategy) is True + assert _momentum_strategy_uses_sector_proxies(etf_strategy) is True + + def test_momentum_intraday_seed_candidates_only_apply_signal_filters_in_final_pass() -> None: daily_bars = { "AAA": [ @@ -620,7 +651,9 @@ def test_retain_recent_intraday_shortlist_preserves_intraday_candidates() -> Non def test_momentum_strategy_defaults_to_simple_returns_and_cli_can_override() -> None: - config = load_config("configs/intraday/strategies/leader_intraday_momentum_high_wr.yaml") + config = load_config( + "configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml" + ) assert config.strategy.compound_returns is False diff --git a/tests/unit/intraday/test_screener.py b/tests/unit/intraday/test_screener.py index 758ea7b..dbcd65b 100644 --- a/tests/unit/intraday/test_screener.py +++ b/tests/unit/intraday/test_screener.py @@ -184,6 +184,112 @@ def test_momentum_pre_screen_candidates_can_require_event_and_attention() -> Non assert result == {"2026-01-05": ["AAA"]} +def test_momentum_pre_screen_candidates_can_filter_event_types() -> None: + daily_bars = { + "AAA": [ + {"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000}, + {"date": "2026-01-05", "open": 10.3, "high": 10.8, "low": 10.2, "close": 10.6, "volume": 2_000}, + ], + "BBB": [ + {"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000}, + {"date": "2026-01-05", "open": 10.4, "high": 10.9, "low": 10.3, "close": 10.7, "volume": 2_000}, + ], + } + enrichment = { + "AAA": { + "2026-01-05": { + "gap_pct": 0.03, + "ret_5d": 0.03, + "entropy_20d": 0.60, + "avg_dollar_vol_30d": 20_000_000.0, + "atr_14": 1.0, + "event_flag": True, + "event_score": 1.0, + "event_types": ["earnings_release"], + } + }, + "BBB": { + "2026-01-05": { + "gap_pct": 0.04, + "ret_5d": 0.04, + "entropy_20d": 0.50, + "avg_dollar_vol_30d": 25_000_000.0, + "atr_14": 1.2, + "event_flag": True, + "event_score": 1.0, + "event_types": ["management_change"], + } + }, + } + strategy = StrategyParams( + candidate_require_event_flag=True, + candidate_allowed_event_types=["earnings_release"], + ) + + result = momentum_pre_screen_candidates( + daily_bars, + ["2026-01-05"], + enrichment, + threshold=0.02, + max_per_day=5, + strategy=strategy, + ) + + assert result == {"2026-01-05": ["AAA"]} + + +def test_momentum_pre_screen_candidates_event_type_filter_does_not_block_non_event_names() -> None: + daily_bars = { + "AAA": [ + {"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000}, + {"date": "2026-01-05", "open": 10.3, "high": 10.8, "low": 10.2, "close": 10.6, "volume": 2_000}, + ], + "BBB": [ + {"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000}, + {"date": "2026-01-05", "open": 10.4, "high": 10.9, "low": 10.3, "close": 10.7, "volume": 2_000}, + ], + } + enrichment = { + "AAA": { + "2026-01-05": { + "gap_pct": 0.03, + "ret_5d": 0.03, + "entropy_20d": 0.60, + "avg_dollar_vol_30d": 20_000_000.0, + "atr_14": 1.0, + "event_flag": True, + "event_score": 1.0, + "event_types": ["management_change"], + } + }, + "BBB": { + "2026-01-05": { + "gap_pct": 0.04, + "ret_5d": 0.04, + "entropy_20d": 0.50, + "avg_dollar_vol_30d": 25_000_000.0, + "atr_14": 1.2, + "event_flag": False, + "event_score": 0.0, + } + }, + } + strategy = StrategyParams( + candidate_allowed_event_types=["earnings_release"], + ) + + result = momentum_pre_screen_candidates( + daily_bars, + ["2026-01-05"], + enrichment, + threshold=0.02, + max_per_day=5, + strategy=strategy, + ) + + assert result == {"2026-01-05": ["BBB", "AAA"]} + + def test_momentum_intraday_first_candidates_uses_entry_time_info_only() -> None: strategy = StrategyParams( candidate_source_mode="intraday_first", @@ -229,6 +335,68 @@ def test_momentum_intraday_first_candidates_uses_entry_time_info_only() -> None: assert result == {"2026-01-05": ["BBB"]} +def test_momentum_intraday_first_candidates_can_filter_event_types() -> None: + strategy = StrategyParams( + candidate_source_mode="intraday_first", + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_confirmation_return_pct=0.0, + min_morning_gain_pct=0.01, + min_entry_volume=50_000, + candidate_final_max_per_day=2, + candidate_require_event_flag=True, + candidate_allowed_event_types=["earnings_release"], + ) + all_intraday = { + "2026-01-05": { + "AAA": [ + {"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.2, "low": 9.9, "close": 10.1, "volume": 30_000}, + {"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.1, "high": 10.3, "low": 10.0, "close": 10.2, "volume": 30_000}, + {"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.2, "high": 10.5, "low": 10.1, "close": 10.4, "volume": 30_000}, + {"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.4, "high": 10.7, "low": 10.3, "close": 10.6, "volume": 30_000}, + {"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.6, "high": 10.8, "low": 10.5, "close": 10.7, "volume": 30_000}, + ], + "BBB": [ + {"timestamp": "2026-01-05T14:30:00+00:00", "open": 20.0, "high": 20.1, "low": 19.9, "close": 20.0, "volume": 40_000}, + {"timestamp": "2026-01-05T14:35:00+00:00", "open": 20.0, "high": 20.2, "low": 19.9, "close": 20.1, "volume": 40_000}, + {"timestamp": "2026-01-05T14:40:00+00:00", "open": 20.1, "high": 20.7, "low": 20.0, "close": 20.5, "volume": 40_000}, + {"timestamp": "2026-01-05T14:45:00+00:00", "open": 20.5, "high": 21.0, "low": 20.4, "close": 20.9, "volume": 40_000}, + {"timestamp": "2026-01-05T14:50:00+00:00", "open": 20.9, "high": 21.3, "low": 20.8, "close": 21.1, "volume": 40_000}, + ], + } + } + daily_enrichment = { + "AAA": { + "2026-01-05": { + "event_flag": True, + "event_score": 1.0, + "event_types": ["management_change"], + "gap_pct": 0.01, + "avg_daily_vol_14d": 1_000_000.0, + } + }, + "BBB": { + "2026-01-05": { + "event_flag": True, + "event_score": 1.0, + "event_types": ["earnings_release"], + "gap_pct": 0.01, + "avg_daily_vol_14d": 1_000_000.0, + } + }, + } + + result = momentum_intraday_first_candidates( + all_intraday, + ["2026-01-05"], + strategy, + daily_enrichment=daily_enrichment, + max_per_day=2, + ) + + assert result == {"2026-01-05": ["BBB"]} + + def test_momentum_intraday_first_candidates_can_use_weighted_ranking() -> None: strategy = StrategyParams( candidate_source_mode="intraday_first", @@ -348,6 +516,151 @@ def test_momentum_intraday_first_candidates_weighted_ranking_can_use_prior_dolla assert result == {"2026-01-05": ["BBB"]} +def test_momentum_intraday_first_candidates_weighted_ranking_can_use_sector_thrust() -> None: + strategy = StrategyParams( + candidate_source_mode="intraday_first", + candidate_intraday_rank_mode="weighted", + candidate_intraday_weight_gain=0.2, + candidate_intraday_weight_confirmation=0.2, + candidate_intraday_weight_entry_dollar_volume=0.1, + candidate_intraday_weight_sector_thrust=1.0, + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_confirmation_return_pct=0.0, + min_morning_gain_pct=0.01, + candidate_final_max_per_day=1, + use_sector_thrust_sleeve=True, + sector_thrust_min_members=2, + sector_thrust_min_gain_pct=0.01, + sector_thrust_min_confirmation_return_pct=0.003, + sector_thrust_min_entry_dollar_volume=50_000_000.0, + sector_thrust_min_avg_dollar_vol_30d=500_000_000.0, + sector_thrust_min_sector_avg_confirmation_return_pct=0.003, + sector_thrust_min_sector_total_entry_dollar_volume=120_000_000.0, + ) + all_intraday = { + "2026-01-05": { + "ALLY_A": [ + {"timestamp": "2026-01-05T14:30:00+00:00", "open": 100.0, "high": 100.2, "low": 99.9, "close": 100.0, "volume": 180_000}, + {"timestamp": "2026-01-05T14:35:00+00:00", "open": 100.0, "high": 100.8, "low": 99.9, "close": 100.6, "volume": 180_000}, + {"timestamp": "2026-01-05T14:40:00+00:00", "open": 100.6, "high": 101.2, "low": 100.5, "close": 101.0, "volume": 180_000}, + {"timestamp": "2026-01-05T14:45:00+00:00", "open": 101.0, "high": 101.7, "low": 100.9, "close": 101.5, "volume": 180_000}, + {"timestamp": "2026-01-05T14:50:00+00:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.6, "volume": 180_000}, + ], + "ALLY_B": [ + {"timestamp": "2026-01-05T14:30:00+00:00", "open": 80.0, "high": 80.1, "low": 79.9, "close": 80.0, "volume": 170_000}, + {"timestamp": "2026-01-05T14:35:00+00:00", "open": 80.0, "high": 80.6, "low": 79.9, "close": 80.4, "volume": 170_000}, + {"timestamp": "2026-01-05T14:40:00+00:00", "open": 80.4, "high": 80.9, "low": 80.3, "close": 80.8, "volume": 170_000}, + {"timestamp": "2026-01-05T14:45:00+00:00", "open": 80.8, "high": 81.4, "low": 80.7, "close": 81.2, "volume": 170_000}, + {"timestamp": "2026-01-05T14:50:00+00:00", "open": 81.2, "high": 81.5, "low": 81.1, "close": 81.3, "volume": 170_000}, + ], + "SOLO": [ + {"timestamp": "2026-01-05T14:30:00+00:00", "open": 20.0, "high": 20.3, "low": 19.9, "close": 20.1, "volume": 300_000}, + {"timestamp": "2026-01-05T14:35:00+00:00", "open": 20.1, "high": 20.8, "low": 20.0, "close": 20.6, "volume": 300_000}, + {"timestamp": "2026-01-05T14:40:00+00:00", "open": 20.6, "high": 21.1, "low": 20.5, "close": 20.9, "volume": 300_000}, + {"timestamp": "2026-01-05T14:45:00+00:00", "open": 20.9, "high": 21.3, "low": 20.8, "close": 21.1, "volume": 300_000}, + {"timestamp": "2026-01-05T14:50:00+00:00", "open": 21.1, "high": 21.3, "low": 21.0, "close": 21.2, "volume": 300_000}, + ], + } + } + daily_enrichment = { + "ALLY_A": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 5_000_000.0, "avg_dollar_vol_30d": 900_000_000.0}}, + "ALLY_B": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 5_000_000.0, "avg_dollar_vol_30d": 850_000_000.0}}, + "SOLO": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 10_000_000.0, "avg_dollar_vol_30d": 1_200_000_000.0}}, + } + + result = momentum_intraday_first_candidates( + all_intraday, + ["2026-01-05"], + strategy, + daily_enrichment=daily_enrichment, + ticker_sectors={ + "ALLY_A": "Technology", + "ALLY_B": "Technology", + "SOLO": "Energy", + }, + max_per_day=1, + ) + + assert result == {"2026-01-05": ["ALLY_A"]} + + +def test_momentum_intraday_first_candidates_can_use_liquid_continuation_rank_mode() -> None: + strategy = StrategyParams( + candidate_source_mode="intraday_first", + candidate_intraday_rank_mode="liquid_continuation", + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_confirmation_return_pct=0.0, + min_morning_gain_pct=0.004, + candidate_final_max_per_day=1, + use_liquid_largecap_sleeve=True, + liquid_largecap_min_gain_pct=0.004, + liquid_largecap_max_gain_pct=0.03, + liquid_largecap_min_confirmation_return_pct=0.0005, + liquid_largecap_min_entry_dollar_volume=50_000_000.0, + liquid_largecap_min_avg_dollar_vol_30d=2_000_000_000.0, + liquid_largecap_max_entropy_20d=0.90, + use_moderate_gap_liquid_sleeve=True, + moderate_gap_liquid_min_gap_pct=0.002, + moderate_gap_liquid_max_gap_pct=0.04, + moderate_gap_liquid_min_gain_pct=0.005, + moderate_gap_liquid_max_gain_pct=0.04, + moderate_gap_liquid_min_confirmation_return_pct=0.001, + moderate_gap_liquid_min_entry_dollar_volume=25_000_000.0, + moderate_gap_liquid_min_avg_dollar_vol_30d=250_000_000.0, + moderate_gap_liquid_max_avg_dollar_vol_30d=4_000_000_000.0, + moderate_gap_liquid_min_volume_ratio_14d=0.02, + moderate_gap_liquid_max_entropy_20d=0.88, + ) + all_intraday = { + "2026-01-05": { + "LIQ": [ + {"timestamp": "2026-01-05T14:30:00+00:00", "open": 100.0, "high": 100.8, "low": 99.9, "close": 100.5, "volume": 220_000}, + {"timestamp": "2026-01-05T14:35:00+00:00", "open": 100.5, "high": 101.2, "low": 100.4, "close": 101.0, "volume": 220_000}, + {"timestamp": "2026-01-05T14:40:00+00:00", "open": 101.0, "high": 101.8, "low": 100.9, "close": 101.5, "volume": 220_000}, + {"timestamp": "2026-01-05T14:45:00+00:00", "open": 101.5, "high": 102.2, "low": 101.4, "close": 102.0, "volume": 220_000}, + {"timestamp": "2026-01-05T14:50:00+00:00", "open": 102.0, "high": 102.4, "low": 101.9, "close": 102.2, "volume": 220_000}, + ], + "HOT": [ + {"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.4, "low": 9.9, "close": 10.3, "volume": 120_000}, + {"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.3, "high": 10.7, "low": 10.2, "close": 10.6, "volume": 120_000}, + {"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.6, "high": 10.9, "low": 10.5, "close": 10.8, "volume": 120_000}, + {"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.8, "high": 11.0, "low": 10.7, "close": 10.9, "volume": 120_000}, + {"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.9, "high": 11.1, "low": 10.8, "close": 11.0, "volume": 120_000}, + ], + } + } + daily_enrichment = { + "LIQ": { + "2026-01-05": { + "gap_pct": 0.01, + "avg_daily_vol_14d": 5_000_000.0, + "avg_dollar_vol_30d": 3_000_000_000.0, + "entropy_20d": 0.70, + } + }, + "HOT": { + "2026-01-05": { + "gap_pct": 0.02, + "avg_daily_vol_14d": 4_000_000.0, + "avg_dollar_vol_30d": 50_000_000.0, + "entropy_20d": 0.82, + } + }, + } + + result = momentum_intraday_first_candidates( + all_intraday, + ["2026-01-05"], + strategy, + daily_enrichment=daily_enrichment, + max_per_day=2, + ) + + assert result == {"2026-01-05": ["LIQ"]} + + def test_momentum_intraday_first_candidates_can_replace_tail_with_event_reserve() -> None: strategy = StrategyParams( candidate_source_mode="intraday_first", diff --git a/tests/unit/intraday/test_simulator.py b/tests/unit/intraday/test_simulator.py index 228e24f..9acb101 100644 --- a/tests/unit/intraday/test_simulator.py +++ b/tests/unit/intraday/test_simulator.py @@ -880,6 +880,163 @@ def test_select_momentum_sleeves_can_force_moderate_gap_liquid_pick() -> None: assert picks == [("TER", "moderate_gap_liquid")] +def test_select_momentum_sleeves_can_force_sector_thrust_pick() -> None: + strategy = StrategyParams( + top_n=1, + use_five_sleeves=True, + use_sector_thrust_sleeve=True, + five_sleeve_core_weight=0.0, + five_sleeve_gap_weight=0.0, + five_sleeve_volume_weight=0.0, + five_sleeve_entropy_weight=0.0, + five_sleeve_trend_weight=0.0, + sector_thrust_weight=1.0, + five_sleeve_force_count=1, + sector_thrust_min_members=2, + sector_thrust_min_gain_pct=0.015, + sector_thrust_min_confirmation_return_pct=0.004, + sector_thrust_min_entry_dollar_volume=50_000_000.0, + sector_thrust_min_avg_dollar_vol_30d=500_000_000.0, + sector_thrust_min_sector_avg_confirmation_return_pct=0.004, + sector_thrust_min_sector_total_entry_dollar_volume=120_000_000.0, + ) + morning_gains = { + "ALLY_A": { + "gain_pct": 0.03, + "entry_volume": 600_000, + "entry_dollar_volume": 80_000_000.0, + "avg_dollar_vol_30d": 900_000_000.0, + "confirmation_return_pct": 0.006, + }, + "ALLY_B": { + "gain_pct": 0.028, + "entry_volume": 500_000, + "entry_dollar_volume": 70_000_000.0, + "avg_dollar_vol_30d": 850_000_000.0, + "confirmation_return_pct": 0.005, + }, + "SOLO": { + "gain_pct": 0.05, + "entry_volume": 550_000, + "entry_dollar_volume": 90_000_000.0, + "avg_dollar_vol_30d": 1_000_000_000.0, + "confirmation_return_pct": 0.007, + }, + } + + picks = _select_momentum_sleeves( + morning_gains, + strategy, + ticker_sectors={ + "ALLY_A": "Technology", + "ALLY_B": "Technology", + "SOLO": "Energy", + }, + ) + + assert picks == [("ALLY_A", "sector_thrust")] + + +def test_select_momentum_sleeves_can_use_liquid_continuation_selection_mode() -> None: + strategy = StrategyParams( + top_n=2, + momentum_selection_mode="liquid_continuation", + ) + morning_gains = { + "HOT": { + "gain_pct": 0.05, + "confirmation_return_pct": 0.002, + "entry_dollar_volume": 4_000_000.0, + "avg_dollar_vol_30d": 30_000_000.0, + "entropy_20d": 0.82, + "is_liquid_largecap": False, + "is_moderate_gap_liquid": False, + "is_sector_thrust": False, + }, + "LIQ": { + "gain_pct": 0.015, + "confirmation_return_pct": 0.006, + "entry_dollar_volume": 90_000_000.0, + "avg_dollar_vol_30d": 3_000_000_000.0, + "entropy_20d": 0.72, + "is_liquid_largecap": True, + "is_moderate_gap_liquid": True, + "is_sector_thrust": False, + }, + } + + picks = _select_momentum_sleeves(morning_gains, strategy) + + assert picks == [("LIQ", "liquid_continuation_core")] + + +def test_simulate_day_records_sector_thrust_trade_diagnostics() -> None: + strategy = StrategyParams( + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_confirmation_return_pct=0.0, + min_morning_gain_pct=0.01, + top_n=1, + slippage_bps=0.0, + use_five_sleeves=True, + use_sector_thrust_sleeve=True, + five_sleeve_core_weight=0.0, + five_sleeve_gap_weight=0.0, + five_sleeve_volume_weight=0.0, + five_sleeve_entropy_weight=0.0, + five_sleeve_trend_weight=0.0, + sector_thrust_weight=1.0, + five_sleeve_force_count=1, + sector_thrust_min_members=2, + sector_thrust_min_gain_pct=0.015, + sector_thrust_min_confirmation_return_pct=0.004, + sector_thrust_min_entry_dollar_volume=50_000_000.0, + sector_thrust_min_avg_dollar_vol_30d=500_000_000.0, + ) + bars_by_ticker = { + "ALLY_A": _bars( + "2026-01-13", + open_price=100.0, + closes=[100.2, 100.8, 101.2, 101.8, 102.0, 102.5], + volumes=[180_000, 180_000, 180_000, 180_000, 180_000, 180_000], + ), + "ALLY_B": _bars( + "2026-01-13", + open_price=80.0, + closes=[80.2, 80.7, 81.0, 81.4, 81.6, 81.9], + volumes=[170_000, 170_000, 170_000, 170_000, 170_000, 170_000], + ), + "SOLO": _bars( + "2026-01-13", + open_price=50.0, + closes=[50.2, 50.8, 51.2, 51.7, 51.8, 52.0], + volumes=[220_000, 220_000, 220_000, 220_000, 220_000, 220_000], + ), + } + day = simulate_day( + bars_by_ticker, + "2026-01-13", + strategy, + daily_features_by_ticker={ + "ALLY_A": {"avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 900_000_000.0}, + "ALLY_B": {"avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 850_000_000.0}, + "SOLO": {"avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 1_200_000_000.0}, + }, + ticker_sectors={ + "ALLY_A": "Technology", + "ALLY_B": "Technology", + "SOLO": "Energy", + }, + ) + + assert len(day.trades) == 1 + assert day.trades[0].trade_sleeve == "sector_thrust" + assert day.trades[0].is_sector_thrust is True + assert day.trades[0].sector_thrust_member_count == 2 + assert day.trades[0].sector_thrust_total_entry_dollar_volume is not None + assert day.trades[0].sector_thrust_total_entry_dollar_volume > 120_000_000.0 + + def test_simulate_day_can_enable_event_sleeve_only_on_soft_days() -> None: strategy = StrategyParams( entry_minutes_after_open=10, @@ -1120,6 +1277,267 @@ def test_simulate_day_can_apply_tail_risk_scaler_on_weak_support_single_name() - assert len(day.trades) == 1 +def test_tail_risk_event_exemption_requires_support_when_configured() -> None: + strategy = StrategyParams( + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_morning_gain_pct=0.01, + top_n=1, + slippage_bps=0.0, + daily_budget_reset=True, + initial_capital=10_000.0, + use_event_sleeve=True, + event_min_score=1.0, + tail_risk_day_max_trades=1, + tail_risk_day_min_max_gain_pct=0.03, + tail_risk_day_max_support_score=0.35, + tail_risk_day_min_max_confirmation_return_pct=0.01, + tail_risk_day_require_no_event=True, + tail_risk_day_event_exemption_min_support_score=0.35, + tail_risk_day_scale=0.5, + ) + + day = simulate_day( + { + "WEAK_EVENT": _bars( + "2026-02-18", + open_price=10.0, + closes=[10.1, 10.35, 10.5, 10.7, 10.6, 10.2], + volumes=[120_000] * 6, + ), + }, + "2026-02-18", + strategy, + daily_features_by_ticker={ + "WEAK_EVENT": { + "gap_pct": 0.04, + "avg_daily_vol_14d": 1_000_000.0, + "avg_dollar_vol_30d": 25_000_000.0, + "ret_5d": 0.07, + "entropy_20d": 0.82, + "event_flag": True, + "event_score": 1.0, + }, + }, + ) + + assert day.tail_risk_scaler == 0.5 + assert round(day.capital_deployed, 2) == 5000.0 + assert day.trades[0].support_score == 0.2 + + +def test_tail_risk_event_exemption_keeps_supported_event_full_size() -> None: + strategy = StrategyParams( + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_morning_gain_pct=0.01, + top_n=1, + slippage_bps=0.0, + daily_budget_reset=True, + initial_capital=10_000.0, + use_event_sleeve=True, + event_min_score=1.0, + tail_risk_day_max_trades=1, + tail_risk_day_min_max_gain_pct=0.03, + tail_risk_day_max_support_score=0.60, + tail_risk_day_min_max_confirmation_return_pct=0.01, + tail_risk_day_require_no_event=True, + tail_risk_day_event_exemption_min_support_score=0.35, + tail_risk_day_scale=0.5, + ) + + day = simulate_day( + { + "SUPPORTED_EVENT": _bars( + "2026-02-18", + open_price=10.0, + closes=[10.1, 10.35, 10.5, 10.7, 10.6, 10.2], + volumes=[500_000] * 6, + ), + }, + "2026-02-18", + strategy, + daily_features_by_ticker={ + "SUPPORTED_EVENT": { + "gap_pct": 0.04, + "avg_daily_vol_14d": 3_000_000.0, + "avg_dollar_vol_30d": 100_000_000.0, + "ret_5d": 0.07, + "entropy_20d": 0.82, + "event_flag": True, + "event_score": 1.0, + }, + }, + ) + + assert day.tail_risk_scaler == 1.0 + assert round(day.capital_deployed, 2) == 10000.0 + assert day.trades[0].support_score is not None + assert day.trades[0].support_score >= 0.35 + + +def test_low_momentum_single_name_scaler_reduces_weak_single_pick() -> None: + strategy = StrategyParams( + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_morning_gain_pct=0.01, + top_n=1, + slippage_bps=0.0, + daily_budget_reset=True, + initial_capital=10_000.0, + low_momentum_single_name_max_gain_pct=0.025, + low_momentum_single_name_require_no_event=True, + low_momentum_single_name_exempt_largecap=True, + low_momentum_single_name_scale=0.55, + ) + + day = simulate_day( + { + "LOW": _bars( + "2025-12-15", + open_price=100.0, + closes=[100.8, 101.1, 101.4, 101.8, 101.7, 97.0], + volumes=[100_000] * 6, + ), + }, + "2025-12-15", + strategy, + daily_features_by_ticker={ + "LOW": { + "gap_pct": 0.02, + "avg_daily_vol_14d": 1_000_000.0, + "avg_dollar_vol_30d": 150_000_000.0, + "ret_5d": 0.02, + "entropy_20d": 0.75, + "event_flag": False, + "event_score": 0.0, + }, + }, + ) + + assert day.tail_risk_scaler == 0.55 + assert round(day.capital_deployed, 2) == 5500.0 + assert len(day.trades) == 1 + + +def test_soft_day_sparse_scaler_reduces_unsupported_soft_basket() -> None: + strategy = StrategyParams( + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_morning_gain_pct=0.01, + top_n=1, + slippage_bps=0.0, + daily_budget_reset=True, + initial_capital=10_000.0, + market_regime_gap_threshold=-0.03, + market_regime_gap_ticker="SPY", + regime_size_scale_low=-0.02, + regime_size_scale_high=0.0, + regime_size_scale_min=0.5, + soft_day_scaler_threshold=0.8, + soft_day_sparse_max_trades=2, + soft_day_sparse_require_no_event=True, + soft_day_sparse_exempt_largecap=True, + soft_day_sparse_exempt_moderate_gap_liquid=True, + soft_day_sparse_scale=0.7, + ) + + day = simulate_day( + { + "FRAGILE": _bars( + "2026-01-28", + open_price=20.0, + closes=[20.1, 20.3, 20.5, 20.7, 20.9, 19.8], + volumes=[100_000] * 6, + ), + }, + "2026-01-28", + strategy, + daily_features_by_ticker={ + "SPY": {"prev_close": 100.0, "today_open": 98.0}, + "FRAGILE": { + "gap_pct": 0.015, + "avg_daily_vol_14d": 1_000_000.0, + "avg_dollar_vol_30d": 120_000_000.0, + "ret_5d": 0.01, + "entropy_20d": 0.75, + "event_flag": False, + "event_score": 0.0, + }, + }, + ) + + assert day.is_soft_day is True + assert day.soft_day_sparse_scaler == 0.7 + assert day.tail_risk_scaler == 0.7 + assert round(day.capital_deployed, 2) == 3500.0 + + +def test_soft_day_sparse_scaler_keeps_supported_moderate_liquid_basket_full_size() -> None: + strategy = StrategyParams( + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_morning_gain_pct=0.01, + top_n=1, + slippage_bps=0.0, + daily_budget_reset=True, + initial_capital=10_000.0, + market_regime_gap_threshold=-0.03, + market_regime_gap_ticker="SPY", + regime_size_scale_low=-0.02, + regime_size_scale_high=0.0, + regime_size_scale_min=0.5, + soft_day_scaler_threshold=0.8, + soft_day_sparse_max_trades=2, + soft_day_sparse_require_no_event=True, + soft_day_sparse_exempt_largecap=True, + soft_day_sparse_exempt_moderate_gap_liquid=True, + soft_day_sparse_scale=0.7, + use_moderate_gap_liquid_sleeve=True, + moderate_gap_liquid_min_gap_pct=0.005, + moderate_gap_liquid_max_gap_pct=0.025, + moderate_gap_liquid_min_gain_pct=0.015, + moderate_gap_liquid_max_gain_pct=0.04, + moderate_gap_liquid_min_confirmation_return_pct=0.005, + moderate_gap_liquid_min_entry_dollar_volume=20_000_000.0, + moderate_gap_liquid_min_avg_dollar_vol_30d=250_000_000.0, + moderate_gap_liquid_max_avg_dollar_vol_30d=2_000_000_000.0, + moderate_gap_liquid_min_volume_ratio_14d=0.04, + moderate_gap_liquid_max_entropy_20d=0.86, + ) + + day = simulate_day( + { + "TER": _bars( + "2026-01-29", + open_price=100.0, + closes=[100.4, 101.0, 101.7, 102.3, 102.7, 103.0], + volumes=[500_000] * 6, + ), + }, + "2026-01-29", + strategy, + daily_features_by_ticker={ + "SPY": {"prev_close": 100.0, "today_open": 98.0}, + "TER": { + "gap_pct": 0.012, + "avg_daily_vol_14d": 8_000_000.0, + "avg_dollar_vol_30d": 750_000_000.0, + "ret_5d": 0.03, + "entropy_20d": 0.79, + "event_flag": False, + "event_score": 0.0, + }, + }, + ) + + assert day.is_soft_day is True + assert day.soft_day_sparse_scaler == 1.0 + assert day.tail_risk_scaler == 1.0 + assert round(day.capital_deployed, 2) == 5000.0 + assert day.trades[0].is_moderate_gap_liquid is True + + def test_simulate_day_keeps_full_size_for_supported_single_name() -> None: strategy = StrategyParams( entry_minutes_after_open=15, @@ -1210,6 +1628,364 @@ def test_select_momentum_sleeves_can_add_liquid_largecap_fallback_when_sparse() assert ("LQ", "liquid_largecap_fallback") in picks +def test_simulate_day_can_add_liquid_cluster_engine_without_disturbing_base_basket() -> None: + strategy = StrategyParams( + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_morning_gain_pct=0.04, + min_confirmation_return_pct=0.003, + top_n=1, + slippage_bps=0.0, + daily_budget_reset=True, + initial_capital=10_000.0, + use_liquid_cluster_engine=True, + liquid_cluster_capital_fraction=0.25, + liquid_cluster_max_positions=1, + liquid_cluster_min_members=2, + liquid_cluster_min_gain_pct=0.015, + liquid_cluster_max_gain_pct=0.04, + liquid_cluster_min_confirmation_return_pct=0.003, + liquid_cluster_min_entry_dollar_volume=30_000_000.0, + liquid_cluster_min_avg_dollar_vol_30d=300_000_000.0, + liquid_cluster_min_volume_ratio_14d=0.10, + liquid_cluster_max_entropy_20d=0.80, + liquid_cluster_min_sector_avg_confirmation_return_pct=0.003, + liquid_cluster_min_sector_total_entry_dollar_volume=100_000_000.0, + ) + + day = simulate_day( + { + "CORE": _bars( + "2026-02-03", + open_price=100.0, + closes=[100.5, 102.0, 103.0, 106.0, 106.4, 107.0], + volumes=[250_000] * 6, + ), + "CL_A": _bars( + "2026-02-03", + open_price=50.0, + closes=[50.2, 50.7, 50.9, 51.2, 51.3, 51.4], + volumes=[400_000] * 6, + ), + "CL_B": _bars( + "2026-02-03", + open_price=60.0, + closes=[60.2, 60.7, 60.9, 61.3, 61.4, 61.5], + volumes=[350_000] * 6, + ), + }, + "2026-02-03", + strategy, + daily_features_by_ticker={ + "CORE": { + "gap_pct": 0.03, + "avg_daily_vol_14d": 2_000_000.0, + "avg_dollar_vol_30d": 900_000_000.0, + "ret_5d": 0.12, + "entropy_20d": 0.40, + }, + "CL_A": { + "gap_pct": 0.015, + "avg_daily_vol_14d": 3_000_000.0, + "avg_dollar_vol_30d": 800_000_000.0, + "ret_5d": 0.05, + "entropy_20d": 0.45, + }, + "CL_B": { + "gap_pct": 0.012, + "avg_daily_vol_14d": 2_500_000.0, + "avg_dollar_vol_30d": 750_000_000.0, + "ret_5d": 0.04, + "entropy_20d": 0.48, + }, + }, + ticker_sectors={ + "CORE": "Energy", + "CL_A": "Technology", + "CL_B": "Technology", + }, + ) + + assert day.trades[0].ticker == "CORE" + assert day.trades[0].trade_sleeve == "core" + assert day.trades[1].ticker in {"CL_A", "CL_B"} + assert day.trades[1].trade_sleeve == "liquid_cluster_engine" + assert day.trades[1].is_liquid_cluster is True + assert day.trades[1].liquid_cluster_sector == "Technology" + assert day.capital_deployed == 10_000.0 + + +def test_simulate_day_can_add_event_day_liquid_sleeve_without_changing_base_basket() -> None: + strategy = StrategyParams( + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_morning_gain_pct=0.04, + min_confirmation_return_pct=0.003, + top_n=1, + use_five_sleeves=False, + slippage_bps=0.0, + daily_budget_reset=True, + initial_capital=10_000.0, + candidate_allowed_event_types=["earnings_release"], + use_event_day_liquid_sleeve=True, + event_day_liquid_capital_fraction=0.25, + event_day_liquid_max_positions=1, + event_day_liquid_min_event_names=1, + event_day_liquid_min_event_score=1.0, + event_day_liquid_min_event_support_score=0.2, + event_day_liquid_min_gain_pct=0.005, + event_day_liquid_max_gain_pct=0.03, + event_day_liquid_min_confirmation_return_pct=0.003, + event_day_liquid_min_entry_dollar_volume=30_000_000.0, + event_day_liquid_min_avg_dollar_vol_30d=500_000_000.0, + event_day_liquid_max_entropy_20d=0.80, + event_day_liquid_min_support_score=0.50, + liquid_largecap_min_gain_pct=0.005, + liquid_largecap_max_gain_pct=0.03, + liquid_largecap_min_confirmation_return_pct=0.003, + liquid_largecap_min_entry_dollar_volume=30_000_000.0, + liquid_largecap_min_avg_dollar_vol_30d=500_000_000.0, + liquid_largecap_max_entropy_20d=0.80, + ) + + day = simulate_day( + { + "CORE": _bars( + "2026-02-05", + open_price=100.0, + closes=[100.5, 102.0, 103.2, 105.0, 105.6, 106.0], + volumes=[250_000] * 6, + ), + "EVT": _bars( + "2026-02-05", + open_price=50.0, + closes=[50.3, 51.0, 51.8, 52.4, 52.7, 53.0], + volumes=[250_000] * 6, + ), + "LQ": _bars( + "2026-02-05", + open_price=200.0, + closes=[200.4, 201.0, 201.7, 202.6, 202.9, 203.5], + volumes=[250_000] * 6, + ), + }, + "2026-02-05", + strategy, + daily_features_by_ticker={ + "CORE": { + "gap_pct": 0.02, + "avg_daily_vol_14d": 2_000_000.0, + "avg_dollar_vol_30d": 1_000_000_000.0, + "ret_5d": 0.12, + "entropy_20d": 0.40, + }, + "EVT": { + "gap_pct": 0.03, + "avg_daily_vol_14d": 1_500_000.0, + "avg_dollar_vol_30d": 600_000_000.0, + "ret_5d": 0.08, + "entropy_20d": 0.45, + "event_flag": True, + "event_score": 2.0, + "event_types": ["earnings_release"], + }, + "LQ": { + "gap_pct": 0.01, + "avg_daily_vol_14d": 3_000_000.0, + "avg_dollar_vol_30d": 2_000_000_000.0, + "ret_5d": 0.03, + "entropy_20d": 0.40, + }, + }, + ) + + assert [trade.ticker for trade in day.trades] == ["CORE", "LQ"] + assert [trade.trade_sleeve for trade in day.trades] == ["core", "event_day_liquid"] + assert day.capital_deployed == 10_000.0 + + +def test_event_day_liquid_activation_can_use_broader_raw_event_types_than_core_event_filters() -> None: + strategy = StrategyParams( + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_morning_gain_pct=0.04, + min_confirmation_return_pct=0.003, + top_n=1, + use_five_sleeves=False, + slippage_bps=0.0, + daily_budget_reset=True, + initial_capital=10_000.0, + candidate_allowed_event_types=["earnings_release"], + use_event_day_liquid_sleeve=True, + event_day_liquid_capital_fraction=0.25, + event_day_liquid_max_positions=1, + event_day_liquid_allowed_event_types=["unknown"], + event_day_liquid_min_event_names=1, + event_day_liquid_min_event_score=1.0, + event_day_liquid_min_gain_pct=0.005, + event_day_liquid_max_gain_pct=0.03, + event_day_liquid_min_confirmation_return_pct=0.003, + event_day_liquid_min_entry_dollar_volume=30_000_000.0, + event_day_liquid_min_avg_dollar_vol_30d=500_000_000.0, + event_day_liquid_max_entropy_20d=0.80, + event_day_liquid_min_support_score=0.50, + liquid_largecap_min_gain_pct=0.005, + liquid_largecap_max_gain_pct=0.03, + liquid_largecap_min_confirmation_return_pct=0.003, + liquid_largecap_min_entry_dollar_volume=30_000_000.0, + liquid_largecap_min_avg_dollar_vol_30d=500_000_000.0, + liquid_largecap_max_entropy_20d=0.80, + ) + + day = simulate_day( + { + "CORE": _bars( + "2026-02-06", + open_price=100.0, + closes=[100.5, 102.0, 103.2, 105.0, 105.6, 106.0], + volumes=[250_000] * 6, + ), + "RAW_EVT": _bars( + "2026-02-06", + open_price=50.0, + closes=[50.2, 50.9, 51.6, 52.2, 52.5, 52.9], + volumes=[250_000] * 6, + ), + "LQ": _bars( + "2026-02-06", + open_price=200.0, + closes=[200.4, 201.0, 201.7, 202.6, 202.9, 203.5], + volumes=[250_000] * 6, + ), + }, + "2026-02-06", + strategy, + daily_features_by_ticker={ + "CORE": { + "gap_pct": 0.02, + "avg_daily_vol_14d": 2_000_000.0, + "avg_dollar_vol_30d": 1_000_000_000.0, + "ret_5d": 0.12, + "entropy_20d": 0.40, + }, + "RAW_EVT": { + "gap_pct": 0.03, + "avg_daily_vol_14d": 1_500_000.0, + "avg_dollar_vol_30d": 600_000_000.0, + "ret_5d": 0.08, + "entropy_20d": 0.45, + "event_flag": True, + "event_score": 2.0, + "event_types": ["unknown"], + }, + "LQ": { + "gap_pct": 0.01, + "avg_daily_vol_14d": 3_000_000.0, + "avg_dollar_vol_30d": 2_000_000_000.0, + "ret_5d": 0.03, + "entropy_20d": 0.40, + }, + }, + ) + + assert [trade.ticker for trade in day.trades] == ["CORE", "LQ"] + assert [trade.trade_sleeve for trade in day.trades] == ["core", "event_day_liquid"] + + +def test_simulate_day_can_add_sector_etf_proxy_sleeve_from_liquid_cluster() -> None: + strategy = StrategyParams( + entry_minutes_after_open=10, + confirmation_minutes_after_entry=5, + min_morning_gain_pct=0.04, + min_confirmation_return_pct=0.003, + top_n=1, + slippage_bps=0.0, + daily_budget_reset=True, + initial_capital=10_000.0, + use_sector_etf_sleeve=True, + sector_etf_capital_fraction=0.25, + sector_etf_max_positions=1, + sector_etf_min_sector_score=0.20, + liquid_cluster_min_members=2, + liquid_cluster_min_gain_pct=0.015, + liquid_cluster_max_gain_pct=0.04, + liquid_cluster_min_confirmation_return_pct=0.003, + liquid_cluster_min_entry_dollar_volume=30_000_000.0, + liquid_cluster_min_avg_dollar_vol_30d=300_000_000.0, + liquid_cluster_min_volume_ratio_14d=0.10, + liquid_cluster_max_entropy_20d=0.80, + liquid_cluster_min_sector_avg_confirmation_return_pct=0.003, + liquid_cluster_min_sector_total_entry_dollar_volume=100_000_000.0, + ) + + day = simulate_day( + { + "CORE": _bars( + "2026-02-04", + open_price=100.0, + closes=[100.5, 102.0, 103.0, 106.0, 106.4, 107.0], + volumes=[250_000] * 6, + ), + "CL_A": _bars( + "2026-02-04", + open_price=50.0, + closes=[50.2, 50.7, 50.9, 51.2, 51.3, 51.4], + volumes=[400_000] * 6, + ), + "CL_B": _bars( + "2026-02-04", + open_price=60.0, + closes=[60.2, 60.7, 60.9, 61.3, 61.4, 61.5], + volumes=[350_000] * 6, + ), + }, + "2026-02-04", + strategy, + daily_features_by_ticker={ + "CORE": { + "gap_pct": 0.03, + "avg_daily_vol_14d": 2_000_000.0, + "avg_dollar_vol_30d": 900_000_000.0, + "ret_5d": 0.12, + "entropy_20d": 0.40, + }, + "CL_A": { + "gap_pct": 0.015, + "avg_daily_vol_14d": 3_000_000.0, + "avg_dollar_vol_30d": 800_000_000.0, + "ret_5d": 0.05, + "entropy_20d": 0.45, + }, + "CL_B": { + "gap_pct": 0.012, + "avg_daily_vol_14d": 2_500_000.0, + "avg_dollar_vol_30d": 750_000_000.0, + "ret_5d": 0.04, + "entropy_20d": 0.48, + }, + }, + ticker_sectors={ + "CORE": "Energy", + "CL_A": "Technology", + "CL_B": "Technology", + }, + sector_proxy_bars_by_ticker={ + "XLK": _bars( + "2026-02-04", + open_price=200.0, + closes=[200.3, 201.0, 201.3, 202.0, 202.1, 202.6], + volumes=[150_000] * 6, + ), + }, + ) + + assert [trade.ticker for trade in day.trades] == ["CORE", "XLK"] + assert [trade.trade_sleeve for trade in day.trades] == ["core", "sector_etf"] + assert day.trades[1].liquid_cluster_sector == "Technology" + assert day.trades[1].sector_proxy_ticker == "XLK" + assert day.capital_deployed == 10_000.0 + + def test_compute_morning_gains_applies_entry_dollar_volume_filter() -> None: strategy = StrategyParams( entry_minutes_after_open=10,