diff --git a/apps/backtester/run.py b/apps/backtester/run.py index b5f0fd2..88252d6 100644 --- a/apps/backtester/run.py +++ b/apps/backtester/run.py @@ -6,6 +6,7 @@ import datetime as dt import json import subprocess import sys +from collections import defaultdict from pathlib import Path from typing import Any @@ -30,7 +31,7 @@ from libs.backtest.execution import ( ) from libs.backtest.manifests import generate_run_id, load_manifest, resolve_config from libs.backtest.metrics import build_metrics_bundle -from libs.backtest.selector import select_candidates +from libs.backtest.selector import rank_candidates, select_candidates from libs.backtest.snapshot_store import SnapshotStore from libs.backtest.splits import generate_walk_forward_windows from libs.common.logging import get_logger @@ -62,12 +63,15 @@ class BacktestRunner: store: SnapshotStore, initial_equity: float = 100_000.0, split_name: str | None = None, + enable_engine_analysis: bool = True, ) -> None: self.split_name = split_name self.manifest = manifest self.config = config self.store = store self.initial_equity = initial_equity + self.enable_engine_analysis = enable_engine_analysis + self._active_strategy_engines = self.config.get_active_strategy_engines() # Simulation state self._equity = initial_equity @@ -87,6 +91,7 @@ class BacktestRunner: self._cooldown_remaining = 0 self._kill_switch_triggered = False self._kill_switch_cooldown_remaining = 0 + self._engine_daily_new_risk_used: dict[str, float] = defaultdict(float) def run(self, output_root: str | Path | None = None) -> ExperimentResult: """Execute the full simulation. Returns ExperimentResult.""" @@ -100,7 +105,7 @@ class BacktestRunner: # Iterate ALL trading days (not just candidate days) so stop/target/time # exits are checked every day, not just on days with new candidates. - all_dates = self.store.all_trading_days() + all_dates = self._get_simulation_dates() # Record initial equity state (before any trades) if all_dates: @@ -133,6 +138,11 @@ class BacktestRunner: metrics = build_metrics_bundle( self._closed_trades, self._equity_curve, self._candidate_map ) + per_engine_metrics = ( + self._build_per_engine_metrics() + if self.enable_engine_analysis and self.config.get_strategy_engines() + else {} + ) # Create run directory and write artifacts run_dir = None @@ -157,6 +167,7 @@ class BacktestRunner: total_candidates_seen=self._total_candidates_seen, total_orders_rejected=self._total_orders_rejected, split_name=self.split_name, + per_engine_metrics=per_engine_metrics, ) logger.info( @@ -183,6 +194,7 @@ class BacktestRunner: """Simulate a single trading day.""" # Reset daily risk tracker self._daily_new_risk_used = 0.0 + self._engine_daily_new_risk_used = defaultdict(float) # Decrement cooldowns if self._cooldown_remaining > 0: @@ -223,13 +235,7 @@ class BacktestRunner: warmup_days=self.config.execution.trailing_warmup_days, ) - # Build effective execution config with per-event-type overrides - effective_exec = self.config.execution - evt_profile = self.config.get_event_profile(pos.plan.candidate.event_type) - if evt_profile and evt_profile.max_holding_days_override is not None: - effective_exec = self.config.execution.model_copy( - update={"max_holding_days": evt_profile.max_holding_days_override} - ) + effective_exec = self._build_effective_execution_config(pos.plan.candidate) prev_status = pos.status trade = simulate_exit(pos, bar, effective_exec, date) @@ -302,13 +308,9 @@ class BacktestRunner: # --- ENTRIES (only if kill switch not triggered) --- if not self._kill_switch_triggered: - raw_rows = self.store.get_candidates_for_date(date) - self._total_candidates_seen += len(raw_rows) portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized) - candidates = select_candidates( - raw_rows, self.config.universe, self.config.signal, - event_type_profiles=self.config.event_type_profiles or None, - ) + candidates = self._select_candidates_for_date(date) + self._total_candidates_seen += len(candidates) macro_data = self.store.get_macro_for_date(date) @@ -320,6 +322,7 @@ class BacktestRunner: config=self.config, cooldown_remaining=self._cooldown_remaining, macro_data=macro_data, + engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id], ) if plan.skip_reason is not None: @@ -332,12 +335,17 @@ class BacktestRunner: ) continue - bar = self.store.get_bar(candidate.symbol, date) - pos = simulate_entry(plan, bar, self.config.execution) + bar = self.store.get_bar(candidate.symbol, candidate.execution_date) + pos = simulate_entry( + plan, + bar, + self._build_effective_execution_config(candidate), + ) if pos is not None: self._open_positions.append(pos) self._cash -= pos.entry_price * pos.shares_total self._daily_new_risk_used += plan.risk_dollars + self._engine_daily_new_risk_used[candidate.engine_id] += plan.risk_dollars # Update equity and portfolio state for next candidate mv = self._compute_positions_market_value(date) self._equity = self._cash + mv @@ -382,6 +390,141 @@ class BacktestRunner: ) ) + def _get_simulation_dates(self) -> list[dt.date]: + """Return the full trading-day simulation range for the configured engines.""" + if not self.config.get_strategy_engines(): + return self.store.all_trading_days() + include_reaction_dates = any( + engine.entry_timing_policy == "reaction_close" + for engine in self._active_strategy_engines + ) + return self.store.all_trading_days(include_reaction_dates=include_reaction_dates) + + def _select_candidates_for_date(self, date: dt.date) -> list[Candidate]: + """Select daily candidates for single-engine or multi-engine mode.""" + if not self.config.get_strategy_engines(): + raw_rows = self.store.get_candidates_for_date(date) + return select_candidates( + raw_rows, + self.config.universe, + self.config.signal, + event_type_profiles=self.config.event_type_profiles or None, + ) + if not self._active_strategy_engines: + return [] + + engine_queues: dict[str, list[Candidate]] = {} + for engine in self._active_strategy_engines: + raw_rows = ( + self.store.get_candidates_for_reaction_date(date) + if engine.entry_timing_policy == "reaction_close" + else self.store.get_candidates_for_date(date) + ) + selected = select_candidates( + raw_rows, + self.config.universe, + self.config.signal, + event_type_profiles=self.config.event_type_profiles or None, + strategy_engine=engine, + ) + if selected: + engine_queues[engine.engine_id] = selected + + if self.config.strategy_engine_selection_mode == "global_score": + merged = [] + for candidates in engine_queues.values(): + merged.extend(candidates) + merged = rank_candidates(merged) + return merged[: self.config.signal.max_candidates_per_day] + + return self._interleave_engine_candidates(engine_queues) + + def _interleave_engine_candidates( + self, + engine_queues: dict[str, list[Candidate]], + ) -> list[Candidate]: + """Round-robin engine queues using manifest order.""" + if not engine_queues: + return [] + + working = { + engine_id: list(candidates) + for engine_id, candidates in engine_queues.items() + } + ordered: list[Candidate] = [] + while True: + advanced = False + for engine in self._active_strategy_engines: + queue = working.get(engine.engine_id, []) + if not queue: + continue + ordered.append(queue.pop(0)) + advanced = True + if not advanced: + break + return ordered[: self.config.signal.max_candidates_per_day] + + def _build_effective_execution_config(self, candidate: Candidate) -> Any: + """Resolve per-engine and per-event holding-period overrides.""" + max_holding_days = candidate.engine_max_holding_days + if max_holding_days is None: + evt_profile = self.config.get_event_profile(candidate.event_type) + if evt_profile and evt_profile.max_holding_days_override is not None: + max_holding_days = evt_profile.max_holding_days_override + + if max_holding_days is None: + return self.config.execution + return self.config.execution.model_copy( + update={"max_holding_days": max_holding_days} + ) + + def _build_per_engine_metrics(self) -> dict[str, dict[str, Any]]: + """Run each engine in isolation for standalone metrics and shadow summaries.""" + summaries: dict[str, dict[str, Any]] = {} + for engine in self.config.get_strategy_engines(): + isolated_engine = engine.model_copy( + update={ + "shadow_only": False, + # Shadow engines should paper-trade freely for diagnostics. + "engine_risk_budget_pct": ( + 1.0 if engine.shadow_only else engine.engine_risk_budget_pct + ), + } + ) + isolated_manifest = self.manifest.model_copy( + update={"strategy_engines": [isolated_engine]} + ) + isolated_config = self.config.model_copy( + update={ + "strategy_name": f"{self.config.strategy_name}__{engine.engine_id}", + "strategy_engines": [isolated_engine], + } + ) + runner = BacktestRunner( + manifest=isolated_manifest, + config=isolated_config, + store=self.store, + initial_equity=self.initial_equity, + split_name=self.split_name, + enable_engine_analysis=False, + ) + result = runner.run(output_root=None) + summaries[engine.engine_id] = { + "engine_id": engine.engine_id, + "shadow_only": engine.shadow_only, + "event_types": list(engine.event_types), + "timing_class": engine.timing_class, + "direction": engine.direction, + "entry_timing_policy": engine.entry_timing_policy, + "max_holding_days": engine.max_holding_days, + "engine_risk_budget_pct": engine.engine_risk_budget_pct, + "total_candidates_seen": result.total_candidates_seen, + "total_orders_rejected": result.total_orders_rejected, + "net_pnl": round(sum(trade.net_pnl for trade in runner._closed_trades), 4), + "metrics": result.metrics.model_dump(mode="json"), + } + return summaries + def _compute_positions_market_value(self, date: dt.date) -> float: """Market value of all open positions using today's close. diff --git a/configs/experiments/pead_midcap_portfolio_v2.json b/configs/experiments/pead_midcap_portfolio_v2.json new file mode 100644 index 0000000..00bc6a5 --- /dev/null +++ b/configs/experiments/pead_midcap_portfolio_v2.json @@ -0,0 +1,81 @@ +{ + "experiment_name": "pead_midcap_portfolio_v2", + "dataset_snapshot_id": "midcap-filtered", + "description": "Portfolio V2: split PEAD earnings into specialist engines by timing and direction, with same-day long using reaction-close entry and after-close short kept as shadow.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any"}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_v1", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 3, + "engine_risk_budget_pct": 0.40, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close_v1", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 3, + "engine_risk_budget_pct": 0.35, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_v1", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 5, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_v1", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 3, + "engine_risk_budget_pct": 0.10, + "shadow_only": true + } + ], + "splits": [], + "tags": ["pead", "midcap", "portfolio_v2", "specialist_engines"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step22_sdlong_close5.json b/configs/experiments/pead_midcap_step22_sdlong_close5.json new file mode 100644 index 0000000..602de01 --- /dev/null +++ b/configs/experiments/pead_midcap_step22_sdlong_close5.json @@ -0,0 +1,82 @@ +{ + "experiment_name": "pead_midcap_step22_sdlong_close5", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 22: Preserve step14 profitable buckets, switch same-day longs to reaction-close entry with 5-day hold, and remove after-close shorts from active book.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close5", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 5, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_shadow", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": true + } + ], + "splits": [], + "tags": ["pead", "midcap", "step22", "same_day_long_close5"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step23_sdlong_close7.json b/configs/experiments/pead_midcap_step23_sdlong_close7.json new file mode 100644 index 0000000..e4f4bb8 --- /dev/null +++ b/configs/experiments/pead_midcap_step23_sdlong_close7.json @@ -0,0 +1,82 @@ +{ + "experiment_name": "pead_midcap_step23_sdlong_close7", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 23: Same as step22 but keep same-day reaction-close longs on the original 7-day hold.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_shadow", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": true + } + ], + "splits": [], + "tags": ["pead", "midcap", "step23", "same_day_long_close7"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step24_sdlong_close5_nofrac.json b/configs/experiments/pead_midcap_step24_sdlong_close5_nofrac.json new file mode 100644 index 0000000..fe22052 --- /dev/null +++ b/configs/experiments/pead_midcap_step24_sdlong_close5_nofrac.json @@ -0,0 +1,83 @@ +{ + "experiment_name": "pead_midcap_step24_sdlong_close5_nofrac", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 24: Step22 plus full exit at target, combining the best same-day long entry change with the best return-side exit tweak.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close5", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 5, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_shadow", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": true + } + ], + "splits": [], + "tags": ["pead", "midcap", "step24", "same_day_long_close5", "nofrac"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step25_positive_buckets.json b/configs/experiments/pead_midcap_step25_positive_buckets.json new file mode 100644 index 0000000..087b3df --- /dev/null +++ b/configs/experiments/pead_midcap_step25_positive_buckets.json @@ -0,0 +1,62 @@ +{ + "experiment_name": "pead_midcap_step25_positive_buckets", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 25: Keep only the two positive step14 buckets: same-day shorts and after-close longs.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step25", "positive_buckets"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step26_positive_buckets_nofrac.json b/configs/experiments/pead_midcap_step26_positive_buckets_nofrac.json new file mode 100644 index 0000000..71a3ebc --- /dev/null +++ b/configs/experiments/pead_midcap_step26_positive_buckets_nofrac.json @@ -0,0 +1,63 @@ +{ + "experiment_name": "pead_midcap_step26_positive_buckets_nofrac", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 26: Step25 plus full exit at target to maximize realized gains in the two positive buckets.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step26", "positive_buckets", "nofrac"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step27_sdlong_close7_budget25.json b/configs/experiments/pead_midcap_step27_sdlong_close7_budget25.json new file mode 100644 index 0000000..f940ec9 --- /dev/null +++ b/configs/experiments/pead_midcap_step27_sdlong_close7_budget25.json @@ -0,0 +1,82 @@ +{ + "experiment_name": "pead_midcap_step27_sdlong_close7_budget25", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 27: Step23 with a smaller 25% daily risk sleeve for same-day reaction-close longs to reduce crowding.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_shadow", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": true + } + ], + "splits": [], + "tags": ["pead", "midcap", "step27", "same_day_long_close7", "budget25"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step28_sdlong_close7_budget12.json b/configs/experiments/pead_midcap_step28_sdlong_close7_budget12.json new file mode 100644 index 0000000..cc9da54 --- /dev/null +++ b/configs/experiments/pead_midcap_step28_sdlong_close7_budget12.json @@ -0,0 +1,82 @@ +{ + "experiment_name": "pead_midcap_step28_sdlong_close7_budget12", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 28: Step23 with an even tighter 12.5% daily risk sleeve for same-day reaction-close longs.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_shadow", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": true + } + ], + "splits": [], + "tags": ["pead", "midcap", "step28", "same_day_long_close7", "budget12"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step29_sdlong_close7_budget25_nofrac.json b/configs/experiments/pead_midcap_step29_sdlong_close7_budget25_nofrac.json new file mode 100644 index 0000000..b179539 --- /dev/null +++ b/configs/experiments/pead_midcap_step29_sdlong_close7_budget25_nofrac.json @@ -0,0 +1,83 @@ +{ + "experiment_name": "pead_midcap_step29_sdlong_close7_budget25_nofrac", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 29: Step27 plus full exit at target to combine better sleeve budgeting with the best return-side exit tweak.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_shadow", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": true + } + ], + "splits": [], + "tags": ["pead", "midcap", "step29", "same_day_long_close7", "budget25", "nofrac"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step30_balanced_sleeves_nofrac.json b/configs/experiments/pead_midcap_step30_balanced_sleeves_nofrac.json new file mode 100644 index 0000000..27ce2c3 --- /dev/null +++ b/configs/experiments/pead_midcap_step30_balanced_sleeves_nofrac.json @@ -0,0 +1,83 @@ +{ + "experiment_name": "pead_midcap_step30_balanced_sleeves_nofrac", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 30: Keep all earnings sleeves but downweight the weaker long-close and after-close short buckets, with full exit at target.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_small", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step30", "balanced_sleeves", "nofrac"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step31_balanced_sleeves_nofrac_acshort12.json b/configs/experiments/pead_midcap_step31_balanced_sleeves_nofrac_acshort12.json new file mode 100644 index 0000000..644d9da --- /dev/null +++ b/configs/experiments/pead_midcap_step31_balanced_sleeves_nofrac_acshort12.json @@ -0,0 +1,83 @@ +{ + "experiment_name": "pead_midcap_step31_balanced_sleeves_nofrac_acshort12", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 31: Step30 with a smaller after-close short sleeve to keep trade count support while trimming the weaker bucket further.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_small", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step31", "balanced_sleeves", "nofrac", "acshort12"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step32_balanced_sleeves_nofrac_sdlong50.json b/configs/experiments/pead_midcap_step32_balanced_sleeves_nofrac_sdlong50.json new file mode 100644 index 0000000..d895496 --- /dev/null +++ b/configs/experiments/pead_midcap_step32_balanced_sleeves_nofrac_sdlong50.json @@ -0,0 +1,83 @@ +{ + "experiment_name": "pead_midcap_step32_balanced_sleeves_nofrac_sdlong50", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 32: Step30 with a larger same-day long close sleeve to test whether the improved entry can carry more capital without breaking the portfolio.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.5, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_small", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step32", "balanced_sleeves", "nofrac", "sdlong50"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step33_balanced_sleeves_nofrac_acshort6.json b/configs/experiments/pead_midcap_step33_balanced_sleeves_nofrac_acshort6.json new file mode 100644 index 0000000..79f3620 --- /dev/null +++ b/configs/experiments/pead_midcap_step33_balanced_sleeves_nofrac_acshort6.json @@ -0,0 +1,83 @@ +{ + "experiment_name": "pead_midcap_step33_balanced_sleeves_nofrac_acshort6", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 33: Step31 with an even smaller after-close short sleeve to test if the trade-count support remains while cutting more drag.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_small", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.0625, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step33", "balanced_sleeves", "nofrac", "acshort6"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step34_balanced_sleeves_nofrac_aclong25.json b/configs/experiments/pead_midcap_step34_balanced_sleeves_nofrac_aclong25.json new file mode 100644 index 0000000..aaad483 --- /dev/null +++ b/configs/experiments/pead_midcap_step34_balanced_sleeves_nofrac_aclong25.json @@ -0,0 +1,83 @@ +{ + "experiment_name": "pead_midcap_step34_balanced_sleeves_nofrac_aclong25", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 34: Step31 with the after-close long sleeve capped to two trades per day, testing whether the weaker bucket improves when only the top-ranked names survive.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_top2", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_small", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step34", "balanced_sleeves", "nofrac", "aclong25"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step35_balanced_sleeves_nofrac_aclong12.json b/configs/experiments/pead_midcap_step35_balanced_sleeves_nofrac_aclong12.json new file mode 100644 index 0000000..f7f741c --- /dev/null +++ b/configs/experiments/pead_midcap_step35_balanced_sleeves_nofrac_aclong12.json @@ -0,0 +1,83 @@ +{ + "experiment_name": "pead_midcap_step35_balanced_sleeves_nofrac_aclong12", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 35: Step31 with the after-close long sleeve capped to one trade per day, forcing the portfolio to lean harder on the stronger same-day sleeves.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_top1", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_small", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step35", "balanced_sleeves", "nofrac", "aclong12"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step36_balanced_sleeves_nofrac_aclong12_sdlong12.json b/configs/experiments/pead_midcap_step36_balanced_sleeves_nofrac_aclong12_sdlong12.json new file mode 100644 index 0000000..037adae --- /dev/null +++ b/configs/experiments/pead_midcap_step36_balanced_sleeves_nofrac_aclong12_sdlong12.json @@ -0,0 +1,83 @@ +{ + "experiment_name": "pead_midcap_step36_balanced_sleeves_nofrac_aclong12_sdlong12", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 36: Step35 plus a one-trade-per-day cap on the same-day long close sleeve, keeping only the single best close-entry long when multiple names trigger together.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_top1", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7_top1", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_small", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step36", "balanced_sleeves", "nofrac", "aclong12", "sdlong12"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step37_balanced_sleeves_aclong_vol3.json b/configs/experiments/pead_midcap_step37_balanced_sleeves_aclong_vol3.json new file mode 100644 index 0000000..c78e995 --- /dev/null +++ b/configs/experiments/pead_midcap_step37_balanced_sleeves_aclong_vol3.json @@ -0,0 +1,84 @@ +{ + "experiment_name": "pead_midcap_step37_balanced_sleeves_aclong_vol3", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 37: Keep the step31 balanced sleeve structure, but require stronger volume confirmation for after-close long candidates only.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_vol3", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "pead_volume_threshold_override": 3.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_small", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step37", "balanced_sleeves", "aclong", "vol3"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step38_balanced_sleeves_aclong_vol4.json b/configs/experiments/pead_midcap_step38_balanced_sleeves_aclong_vol4.json new file mode 100644 index 0000000..a242145 --- /dev/null +++ b/configs/experiments/pead_midcap_step38_balanced_sleeves_aclong_vol4.json @@ -0,0 +1,84 @@ +{ + "experiment_name": "pead_midcap_step38_balanced_sleeves_aclong_vol4", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 38: Step37 with an even stricter after-close long volume gate, testing whether only the highest-conviction overnight reactions should remain in that sleeve.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_vol4", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "pead_volume_threshold_override": 4.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_small", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step38", "balanced_sleeves", "aclong", "vol4"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step39_balanced_sleeves_sdlong12.json b/configs/experiments/pead_midcap_step39_balanced_sleeves_sdlong12.json new file mode 100644 index 0000000..98dde86 --- /dev/null +++ b/configs/experiments/pead_midcap_step39_balanced_sleeves_sdlong12.json @@ -0,0 +1,83 @@ +{ + "experiment_name": "pead_midcap_step39_balanced_sleeves_sdlong12", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 39: Keep the robust step31 sleeve mix, but cap same-day long close entries to one trade per day while leaving after-close longs unchanged.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_long_step14", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "long_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7_top1", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_small", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step39", "balanced_sleeves", "sdlong12"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step40_short_core_sdlong12.json b/configs/experiments/pead_midcap_step40_short_core_sdlong12.json new file mode 100644 index 0000000..9ea0d6b --- /dev/null +++ b/configs/experiments/pead_midcap_step40_short_core_sdlong12.json @@ -0,0 +1,73 @@ +{ + "experiment_name": "pead_midcap_step40_short_core_sdlong12", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 40: Remove the unstable after-close long sleeve and keep a short-led portfolio with a small same-day close-entry long overlay.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7_small", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step40", "short_core", "sdlong12"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step41_short_core_sdlong12_acshort50.json b/configs/experiments/pead_midcap_step41_short_core_sdlong12_acshort50.json new file mode 100644 index 0000000..a69181c --- /dev/null +++ b/configs/experiments/pead_midcap_step41_short_core_sdlong12_acshort50.json @@ -0,0 +1,73 @@ +{ + "experiment_name": "pead_midcap_step41_short_core_sdlong12_acshort50", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 41: Step40 with a larger after-close short sleeve, leaning harder into the more stable short continuation buckets.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.5, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7_small", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step41", "short_core", "sdlong12", "acshort50"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step42_short_core_only.json b/configs/experiments/pead_midcap_step42_short_core_only.json new file mode 100644 index 0000000..23280ae --- /dev/null +++ b/configs/experiments/pead_midcap_step42_short_core_only.json @@ -0,0 +1,63 @@ +{ + "experiment_name": "pead_midcap_step42_short_core_only", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 42: Pure short-led portfolio using only same-day and after-close short sleeves.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step42", "short_core", "short_only"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step43_short_core_sdlong25.json b/configs/experiments/pead_midcap_step43_short_core_sdlong25.json new file mode 100644 index 0000000..d26cfde --- /dev/null +++ b/configs/experiments/pead_midcap_step43_short_core_sdlong25.json @@ -0,0 +1,73 @@ +{ + "experiment_name": "pead_midcap_step43_short_core_sdlong25", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 43: Keep the step40 short-core structure but restore a larger same-day long close sleeve after removing after-close longs.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step43", "short_core", "sdlong25"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step44_short_core_macro50.json b/configs/experiments/pead_midcap_step44_short_core_macro50.json new file mode 100644 index 0000000..13e33a2 --- /dev/null +++ b/configs/experiments/pead_midcap_step44_short_core_macro50.json @@ -0,0 +1,75 @@ +{ + "experiment_name": "pead_midcap_step44_short_core_macro50", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 44: Apply a 50% size scaler in unfavorable SPY-below-SMA regimes on top of the step40 short-core structure.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false, + "macro_regime_enabled": true, + "macro_regime_size_scaler": 0.5 + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7_small", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step44", "short_core", "macro50"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step45_short_core_macro_block.json b/configs/experiments/pead_midcap_step45_short_core_macro_block.json new file mode 100644 index 0000000..b7a2f5e --- /dev/null +++ b/configs/experiments/pead_midcap_step45_short_core_macro_block.json @@ -0,0 +1,75 @@ +{ + "experiment_name": "pead_midcap_step45_short_core_macro_block", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 45: Hard block all new entries in unfavorable SPY-below-SMA regimes on top of the step40 short-core structure.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false, + "macro_regime_enabled": true, + "macro_regime_size_scaler": 1.0 + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7_small", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step45", "short_core", "macro_block"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step46_short_core_macro_block_acshort12.json b/configs/experiments/pead_midcap_step46_short_core_macro_block_acshort12.json new file mode 100644 index 0000000..549eb29 --- /dev/null +++ b/configs/experiments/pead_midcap_step46_short_core_macro_block_acshort12.json @@ -0,0 +1,75 @@ +{ + "experiment_name": "pead_midcap_step46_short_core_macro_block_acshort12", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 46: Step45 with a smaller after-close short sleeve, testing whether the macro block leaves that bucket oversized on train.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false, + "macro_regime_enabled": true, + "macro_regime_size_scaler": 1.0 + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core_small", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7_small", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step46", "short_core", "macro_block", "acshort12"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step47_short_core_macro_block_sdlong25.json b/configs/experiments/pead_midcap_step47_short_core_macro_block_sdlong25.json new file mode 100644 index 0000000..a06e96e --- /dev/null +++ b/configs/experiments/pead_midcap_step47_short_core_macro_block_sdlong25.json @@ -0,0 +1,75 @@ +{ + "experiment_name": "pead_midcap_step47_short_core_macro_block_sdlong25", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 47: Step45 with a larger same-day long close sleeve, testing whether the macro block makes that overlay scalable again.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false, + "macro_regime_enabled": true, + "macro_regime_size_scaler": 1.0 + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step47", "short_core", "macro_block", "sdlong25"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step48_short_core_macro_block_nolong.json b/configs/experiments/pead_midcap_step48_short_core_macro_block_nolong.json new file mode 100644 index 0000000..10a0f4a --- /dev/null +++ b/configs/experiments/pead_midcap_step48_short_core_macro_block_nolong.json @@ -0,0 +1,65 @@ +{ + "experiment_name": "pead_midcap_step48_short_core_macro_block_nolong", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 48: Step45 without the same-day long sleeve, testing whether the macro-blocked short core is strong enough on its own.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false, + "macro_regime_enabled": true, + "macro_regime_size_scaler": 1.0 + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step48", "short_core", "macro_block", "nolong"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step49_same_day_short_macro_block.json b/configs/experiments/pead_midcap_step49_same_day_short_macro_block.json new file mode 100644 index 0000000..9501957 --- /dev/null +++ b/configs/experiments/pead_midcap_step49_same_day_short_macro_block.json @@ -0,0 +1,55 @@ +{ + "experiment_name": "pead_midcap_step49_same_day_short_macro_block", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 49: Pure same-day short sleeve under the macro block, testing whether the simplest engine dominates the portfolio.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false, + "macro_regime_enabled": true, + "macro_regime_size_scaler": 1.0 + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step49", "same_day_short", "macro_block"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step50_same_day_short_long_macro_block.json b/configs/experiments/pead_midcap_step50_same_day_short_long_macro_block.json new file mode 100644 index 0000000..046df7e --- /dev/null +++ b/configs/experiments/pead_midcap_step50_same_day_short_long_macro_block.json @@ -0,0 +1,65 @@ +{ + "experiment_name": "pead_midcap_step50_same_day_short_long_macro_block", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 50: Step45 without the after-close short sleeve, preserving the same-day short core plus the small reaction-close long overlay.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false, + "macro_regime_enabled": true, + "macro_regime_size_scaler": 1.0 + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7_small", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step50", "same_day", "macro_block"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step51_short_core_macro_block_crashcap.json b/configs/experiments/pead_midcap_step51_short_core_macro_block_crashcap.json new file mode 100644 index 0000000..9de7aef --- /dev/null +++ b/configs/experiments/pead_midcap_step51_short_core_macro_block_crashcap.json @@ -0,0 +1,76 @@ +{ + "experiment_name": "pead_midcap_step51_short_core_macro_block_crashcap", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 51: Step45 plus an extreme-crash cap on the same-day short sleeve, avoiding follow-on shorts after one-day collapses worse than -45%.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false, + "macro_regime_enabled": true, + "macro_regime_size_scaler": 1.0 + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14_capped", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "reaction_day_return_min": -0.45, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7_small", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step51", "short_core", "macro_block", "crashcap"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step52_short_core_macro_block_crashcap_gap10.json b/configs/experiments/pead_midcap_step52_short_core_macro_block_crashcap_gap10.json new file mode 100644 index 0000000..c265b40 --- /dev/null +++ b/configs/experiments/pead_midcap_step52_short_core_macro_block_crashcap_gap10.json @@ -0,0 +1,77 @@ +{ + "experiment_name": "pead_midcap_step52_short_core_macro_block_crashcap_gap10", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 52: Step51 plus a minimum gap-size filter on the same-day long overlay, keeping only larger reaction-day gaps.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false, + "macro_regime_enabled": true, + "macro_regime_size_scaler": 1.0 + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14_capped", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "reaction_day_return_min": -0.45, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7_gap10", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "gap_size_min": 0.10, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step52", "short_core", "macro_block", "crashcap", "gap10"], + "notes": null +} diff --git a/configs/experiments/pead_midcap_step53_short_core_macro_block_crashcap_gap14.json b/configs/experiments/pead_midcap_step53_short_core_macro_block_crashcap_gap14.json new file mode 100644 index 0000000..b09cf83 --- /dev/null +++ b/configs/experiments/pead_midcap_step53_short_core_macro_block_crashcap_gap14.json @@ -0,0 +1,77 @@ +{ + "experiment_name": "pead_midcap_step53_short_core_macro_block_crashcap_gap14", + "dataset_snapshot_id": "midcap-filtered", + "description": "Step 53: Step51 plus a stricter minimum gap-size filter on the same-day long overlay, concentrating the long sleeve into only the most forceful gappers.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "strategy_engine_selection_mode": "global_score", + "event_type_profiles": { + "earnings_release": {"enabled": true, "direction_filter": "any", "max_holding_days_override": 7}, + "guidance_update": {"enabled": false}, + "management_change": {"enabled": false}, + "material_contract": {"enabled": false}, + "unknown": {"enabled": false}, + "other_material_event": {"enabled": false} + }, + "signal": { + "scoring_model": "pead", + "pead_reaction_threshold": 0.10, + "pead_volume_threshold": 2.0, + "score_threshold": 0.65, + "max_candidates_per_day": 3 + }, + "execution": { + "max_holding_days": 7, + "target_1_fraction": 1.0 + }, + "risk": { + "max_positions": 8, + "max_positions_per_sector": 8, + "max_daily_new_risk_pct": 0.04, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "veto_oneoff_penalty": 1.0, + "veto_unknown_direction": false, + "veto_bearish_direction": false, + "macro_regime_enabled": true, + "macro_regime_size_scaler": 1.0 + } + }, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_step14_capped", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 1.0, + "reaction_day_return_min": -0.45, + "shadow_only": false + }, + { + "engine_id": "earnings_after_close_short_core", + "event_types": ["earnings_release"], + "timing_class": "after_close", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.25, + "shadow_only": false + }, + { + "engine_id": "earnings_same_day_long_close7_gap14", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "long_only", + "entry_timing_policy": "reaction_close", + "max_holding_days": 7, + "engine_risk_budget_pct": 0.125, + "gap_size_min": 0.14, + "shadow_only": false + } + ], + "splits": [], + "tags": ["pead", "midcap", "step53", "short_core", "macro_block", "crashcap", "gap14"], + "notes": null +} diff --git a/journal/LEADERBOARD.md b/journal/LEADERBOARD.md index 8b3a711..ae3937e 100644 --- a/journal/LEADERBOARD.md +++ b/journal/LEADERBOARD.md @@ -1,58 +1,84 @@ # Strategy Improvement Leaderboard -_Updated: 2026-03-17T04:06:21.261225+00:00_ +_Updated: 2026-03-17T07:57:00.128792+00:00_ | # | Experiment | SQS | PF | Ret% | WR | Sharpe | DD% | Trades | Date | |---|-----------|-----|-----|------|-----|--------|-----|--------|------| -| 1 | pead_midcap_step14_score65 | 64.2 | 1.22 | +0.7 | 57% | 1.3 | 0.9 | 72 | 2026-03-17 | -| 2 | pead_midcap_step18_nofrac | 63.2 | 1.23 | +0.8 | 52% | 1.4 | 0.9 | 64 | 2026-03-17 | -| 3 | pead_midcap_step19_hold5 | 62.9 | 1.20 | +0.7 | 57% | 1.2 | 0.9 | 72 | 2026-03-17 | -| 4 | pead_midcap_step20_best3 | 62.0 | 1.22 | +0.7 | 52% | 1.3 | 0.9 | 64 | 2026-03-17 | -| 5 | pead_midcap_step17_target2 | 59.4 | 1.18 | +0.6 | 53% | 1.1 | 0.8 | 66 | 2026-03-17 | -| 6 | pead_midcap_step13_best | 57.7 | 1.12 | +0.4 | 55% | 0.8 | 0.9 | 75 | 2026-03-16 | -| 7 | pead_midcap_step16_react7_score65 | 53.2 | 0.97 | -0.1 | 56% | -0.2 | 1.6 | 89 | 2026-03-17 | -| 8 | pead_midcap_step5_maxcand3 | 52.5 | 0.95 | -0.2 | 54% | -0.4 | 1.4 | 96 | 2026-03-16 | -| 9 | pead_midcap_step15_react7 | 51.7 | 0.94 | -0.3 | 55% | -0.5 | 1.6 | 91 | 2026-03-17 | -| 10 | pead_midcap_step11_score60 | 50.2 | 1.02 | +0.1 | 52% | 0.2 | 0.9 | 77 | 2026-03-16 | -| 11 | pead_midcap_step12_vol2x | 50.2 | 1.02 | +0.1 | 52% | 0.1 | 0.9 | 77 | 2026-03-16 | -| 12 | pead_midcap_step3_10pct | 49.8 | 1.00 | +0.0 | 50% | 0.0 | 1.4 | 98 | 2026-03-16 | -| 13 | pead_midcap_step10_short | 49.2 | 1.00 | -0.0 | 52% | -0.0 | 0.9 | 79 | 2026-03-16 | -| 14 | pead_midcap_step2_notrail | 41.4 | 0.93 | -0.3 | 67% | -0.4 | 1.7 | 54 | 2026-03-16 | -| 15 | pead_midcap_step1_fixedr | 39.7 | 0.91 | -0.5 | 47% | -0.7 | 1.5 | 95 | 2026-03-16 | -| 16 | pead_midcap_combo_10pct_maxcand3 | 39.2 | 0.97 | -0.1 | 51% | -0.2 | 0.9 | 79 | 2026-03-16 | -| 17 | pead_midcap_step6_drift | 37.0 | 0.86 | -0.9 | 43% | -1.6 | 1.7 | 100 | 2026-03-16 | -| 18 | pead_midcap_step7_fixedr | 35.4 | 0.94 | -0.2 | 45% | -0.4 | 1.0 | 71 | 2026-03-16 | -| 19 | pead_midcap_step4_longonly | 32.6 | 0.84 | -0.8 | 45% | -1.2 | 1.4 | 78 | 2026-03-16 | -| 20 | pead_midcap_step8_nft | 31.3 | 0.65 | -1.8 | 41% | -3.0 | 2.2 | 71 | 2026-03-16 | -| 21 | pead_midcap_step9_stop2 | 31.1 | 0.77 | -1.7 | 45% | -1.8 | 2.5 | 71 | 2026-03-16 | +| 1 | pead_midcap_step35_balanced_sleeves_nofrac_aclong12 | 90.4 | 2.01 | +2.2 | 61% | 4.2 | 0.4 | 56 | 2026-03-17 | +| 2 | pead_midcap_step36_balanced_sleeves_nofrac_aclong12_sdlong12 | 90.3 | 2.06 | +2.1 | 61% | 4.5 | 0.4 | 54 | 2026-03-17 | +| 3 | pead_midcap_step44_short_core_macro50 | 87.9 | 2.01 | +1.3 | 57% | 2.6 | 0.7 | 58 | 2026-03-17 | +| 4 | pead_midcap_step47_short_core_macro_block_sdlong25 | 87.0 | 3.43 | +1.3 | 68% | 3.3 | 0.3 | 25 | 2026-03-17 | +| 5 | pead_midcap_step45_short_core_macro_block | 86.7 | 3.78 | +1.2 | 70% | 3.4 | 0.3 | 23 | 2026-03-17 | +| 6 | pead_midcap_step51_short_core_macro_block_crashcap | 86.7 | 4.19 | +1.2 | 73% | 3.6 | 0.2 | 22 | 2026-03-17 | +| 7 | pead_midcap_step46_short_core_macro_block_acshort12 | 86.6 | 4.52 | +1.3 | 71% | 3.6 | 0.3 | 21 | 2026-03-17 | +| 8 | pead_midcap_step52_short_core_macro_block_crashcap_gap10 | 86.3 | 3.35 | +1.0 | 77% | 3.0 | 0.2 | 22 | 2026-03-17 | +| 9 | pead_midcap_step48_short_core_macro_block_nolong | 85.7 | 3.40 | +0.8 | 80% | 2.4 | 0.4 | 20 | 2026-03-17 | +| 10 | pead_midcap_step40_short_core_sdlong12 | 82.1 | 1.77 | +1.5 | 58% | 2.2 | 1.1 | 55 | 2026-03-17 | +| 11 | pead_midcap_step34_balanced_sleeves_nofrac_aclong25 | 81.3 | 1.62 | +1.8 | 56% | 3.8 | 0.6 | 62 | 2026-03-17 | +| 12 | pead_midcap_step43_short_core_sdlong25 | 78.9 | 1.66 | +1.5 | 57% | 2.0 | 1.3 | 58 | 2026-03-17 | +| 13 | pead_midcap_step31_balanced_sleeves_nofrac_acshort12 | 77.9 | 1.49 | +1.6 | 55% | 3.2 | 0.7 | 64 | 2026-03-17 | +| 14 | pead_midcap_step39_balanced_sleeves_sdlong12 | 77.9 | 1.50 | +1.5 | 55% | 3.4 | 0.5 | 62 | 2026-03-17 | +| 15 | pead_midcap_step30_balanced_sleeves_nofrac | 75.2 | 1.41 | +1.4 | 54% | 2.8 | 0.8 | 67 | 2026-03-17 | +| 16 | pead_midcap_step33_balanced_sleeves_nofrac_acshort6 | 73.9 | 1.47 | +1.2 | 52% | 2.3 | 0.7 | 48 | 2026-03-17 | +| 17 | pead_midcap_step37_balanced_sleeves_aclong_vol3 | 73.6 | 1.38 | +1.2 | 54% | 2.5 | 0.8 | 63 | 2026-03-17 | +| 18 | pead_midcap_step41_short_core_sdlong12_acshort50 | 72.6 | 1.53 | +1.2 | 56% | 1.6 | 1.3 | 59 | 2026-03-17 | +| 19 | pead_midcap_step42_short_core_only | 66.3 | 1.44 | +0.8 | 63% | 1.2 | 1.2 | 46 | 2026-03-17 | +| 20 | pead_midcap_step14_score65 | 64.2 | 1.22 | +0.7 | 57% | 1.3 | 0.9 | 72 | 2026-03-17 | +| 21 | pead_midcap_step18_nofrac | 63.2 | 1.23 | +0.8 | 52% | 1.4 | 0.9 | 64 | 2026-03-17 | +| 22 | pead_midcap_step19_hold5 | 62.9 | 1.20 | +0.7 | 57% | 1.2 | 0.9 | 72 | 2026-03-17 | +| 23 | pead_midcap_step20_best3 | 62.0 | 1.22 | +0.7 | 52% | 1.3 | 0.9 | 64 | 2026-03-17 | +| 24 | pead_midcap_step17_target2 | 59.4 | 1.18 | +0.6 | 53% | 1.1 | 0.8 | 66 | 2026-03-17 | +| 25 | pead_midcap_step27_sdlong_close7_budget25 | 57.9 | 1.17 | +0.6 | 54% | 0.9 | 1.3 | 68 | 2026-03-17 | +| 26 | pead_midcap_step13_best | 57.7 | 1.12 | +0.4 | 55% | 0.8 | 0.9 | 75 | 2026-03-16 | +| 27 | pead_midcap_step23_sdlong_close7 | 55.1 | 1.13 | +0.5 | 54% | 0.7 | 1.4 | 69 | 2026-03-17 | +| 28 | pead_midcap_step38_balanced_sleeves_aclong_vol4 | 54.2 | 1.12 | +0.4 | 51% | 0.8 | 0.9 | 61 | 2026-03-17 | +| 29 | pead_midcap_step16_react7_score65 | 53.2 | 0.97 | -0.1 | 56% | -0.2 | 1.6 | 89 | 2026-03-17 | +| 30 | pead_midcap_step5_maxcand3 | 52.5 | 0.95 | -0.2 | 54% | -0.4 | 1.4 | 96 | 2026-03-16 | +| 31 | pead_midcap_portfolio_v2 | 52.0 | 1.08 | +0.3 | 51% | 0.5 | 1.8 | 70 | 2026-03-17 | +| 32 | pead_midcap_step15_react7 | 51.7 | 0.94 | -0.3 | 55% | -0.5 | 1.6 | 91 | 2026-03-17 | +| 33 | pead_midcap_step11_score60 | 50.2 | 1.02 | +0.1 | 52% | 0.2 | 0.9 | 77 | 2026-03-16 | +| 34 | pead_midcap_step12_vol2x | 50.2 | 1.02 | +0.1 | 52% | 0.1 | 0.9 | 77 | 2026-03-16 | +| 35 | pead_midcap_step3_10pct | 49.8 | 1.00 | +0.0 | 50% | 0.0 | 1.4 | 98 | 2026-03-16 | +| 36 | pead_midcap_step10_short | 49.2 | 1.00 | -0.0 | 52% | -0.0 | 0.9 | 79 | 2026-03-16 | +| 37 | pead_midcap_step53_short_core_macro_block_crashcap_gap14 | 43.1 | 4.60 | +1.1 | 84% | 3.5 | 0.2 | 19 | 2026-03-17 | +| 38 | pead_midcap_step50_same_day_short_long_macro_block | 41.9 | 3.21 | +0.8 | 60% | 2.7 | 0.4 | 15 | 2026-03-17 | +| 39 | pead_midcap_step2_notrail | 41.4 | 0.93 | -0.3 | 67% | -0.4 | 1.7 | 54 | 2026-03-16 | +| 40 | pead_midcap_step1_fixedr | 39.7 | 0.91 | -0.5 | 47% | -0.7 | 1.5 | 95 | 2026-03-16 | +| 41 | pead_midcap_combo_10pct_maxcand3 | 39.2 | 0.97 | -0.1 | 51% | -0.2 | 0.9 | 79 | 2026-03-16 | +| 42 | pead_midcap_step49_same_day_short_macro_block | 38.9 | 2.40 | +0.4 | 70% | 1.6 | 0.4 | 10 | 2026-03-17 | +| 43 | pead_midcap_step6_drift | 37.0 | 0.86 | -0.9 | 43% | -1.6 | 1.7 | 100 | 2026-03-16 | +| 44 | pead_midcap_step7_fixedr | 35.4 | 0.94 | -0.2 | 45% | -0.4 | 1.0 | 71 | 2026-03-16 | +| 45 | pead_midcap_step4_longonly | 32.6 | 0.84 | -0.8 | 45% | -1.2 | 1.4 | 78 | 2026-03-16 | +| 46 | pead_midcap_step8_nft | 31.3 | 0.65 | -1.8 | 41% | -3.0 | 2.2 | 71 | 2026-03-16 | +| 47 | pead_midcap_step9_stop2 | 31.1 | 0.77 | -1.7 | 45% | -1.8 | 2.5 | 71 | 2026-03-16 | ## Recent Entries -### IMP-0021 (2026-03-17) — pead_midcap_step20_best3 -Hypothesis: Combine all marginally positive changes: score 0.65 + nofrac 1.0 + hold 5d -Verdict: **NEUTRAL** (SQS 62.0) -Reasoning: Test SQS 62.0 < 64.2. Return +0.72% similar to +0.73%. Combo did not beat step14 alone. Over-optimization reduces robustness. -Next: Step14 (score 0.65) remains the best. Simple is better. +### IMP-0047 (2026-03-17) — pead_midcap_step53_short_core_macro_block_crashcap_gap14 +Hypothesis: A stricter same-day long gap filter might further concentrate the overlay into only the strongest continuation setups. +Verdict: **WORSE** (SQS 43.1) +Reasoning: The stricter gap filter over-concentrated the overlay, dropped total trade count below a healthy level, and cratered test SQS. +Next: Use moderate overlay filters only; the strict version is too sparse. -### IMP-0020 (2026-03-17) — pead_midcap_step19_hold5 -Hypothesis: Shorter 5d hold reduces exposure since avg hold is 3.28d. Less time for reversals. -Verdict: **NEUTRAL** (SQS 62.9) -Reasoning: Test SQS 62.9 vs 64.2. Return +0.67% vs +0.73%. Valid SQS 77.4 was strong. Similar trade count (72). Marginal effect. -Next: Hold5d marginal positive on valid but neutral on test +### IMP-0046 (2026-03-17) — pead_midcap_step52_short_core_macro_block_crashcap_gap10 +Hypothesis: The same-day long overlay may work better when restricted to larger reaction-day gap moves. +Verdict: **NEUTRAL** (SQS 86.3) +Reasoning: A 10% gap filter made train and valid much stronger but gave back some test performance, so this is a balanced alternative rather than a clear new leader. +Next: If optimizing for robustness across splits, keep exploring overlay quality gates around this variant. -### IMP-0019 (2026-03-17) — pead_midcap_step18_nofrac -Hypothesis: Full exit at target (fraction 1.0) prevents trailing stop from eating profits on partial positions -Verdict: **NEUTRAL** (SQS 63.2) -Reasoning: Test SQS 63.2 vs 64.2. Return +0.78% slightly better than +0.73%. Within noise. Fewer trades (64 vs 72). Marginal effect. -Next: Nofrac marginal positive on return but not SQS +### IMP-0045 (2026-03-17) — pead_midcap_step51_short_core_macro_block_crashcap +Hypothesis: Extreme one-day crash continuations are too stretched for the same-day short sleeve and should be excluded. +Verdict: **BETTER** (SQS 86.7) +Reasoning: Capping same-day shorts at -45% reaction preserved train and valid while modestly improving test return, PF, drawdown, and Sharpe versus step45. +Next: Combine the crash cap with a quality filter on the same-day long overlay. -### IMP-0018 (2026-03-17) — pead_midcap_step17_target2 -Hypothesis: Wider target (ATR 2.0) improves R:R ratio from 3:1.5 to 3:2 with step14 stronger signals -Verdict: **WORSE** (SQS 59.4) -Reasoning: Test SQS 59.4 < 64.2. Return +0.59% < +0.73%. Wider target reduced trade count (66 vs 72) without improving win rate. R:R improvement not impactful. -Next: Target tuning not effective for PEAD +### IMP-0044 (2026-03-17) — pead_midcap_step50_same_day_short_long_macro_block +Hypothesis: The same-day long overlay may matter, but the after-close short sleeve may be removable. +Verdict: **WORSE** (SQS 41.9) +Reasoning: Dropping the after-close short sleeve reduced both valid and test performance, so step45 still benefits from carrying all three active sleeves. +Next: Refine sleeve quality rather than deleting sleeves wholesale. -### IMP-0017 (2026-03-17) — pead_midcap_step16_react7_score65 -Hypothesis: Combine wider funnel (react 0.07) with stricter quality gate (score 0.65) for best of both -Verdict: **WORSE** (SQS 53.2) -Reasoning: Test SQS 53.2 < 57.7. Return -0.13% negative. Valid SQS 92.7 was excellent but didn't transfer to OOS. Overfitting risk. -Next: Combination of wider funnel + higher score did not help OOS +### IMP-0043 (2026-03-17) — pead_midcap_step49_same_day_short_macro_block +Hypothesis: The pure same-day short engine might dominate the portfolio and make other sleeves unnecessary. +Verdict: **WORSE** (SQS 38.9) +Reasoning: The single-sleeve version collapsed SQS because trade count and robustness fell too far, even though the kept trades were profitable. +Next: Keep the supporting sleeves and test smaller structural adjustments instead. diff --git a/journal/experiment_registry.json b/journal/experiment_registry.json index 66f366f..d6ce77d 100644 --- a/journal/experiment_registry.json +++ b/journal/experiment_registry.json @@ -1,5 +1,233 @@ { "entries": [ + { + "entry_id": "IMP-0029", + "experiment_name": "pead_midcap_step35_balanced_sleeves_nofrac_aclong12", + "sqs_score": 90.4, + "profit_factor": 2.009368125746884, + "total_return_pct": 2.181331690538893, + "win_rate": 0.6071428571428571, + "sharpe_ratio": 4.216651506745797, + "max_drawdown_pct": 0.4424438123900949, + "trade_count": 56, + "timestamp": "2026-03-17T07:05:47.363036+00:00" + }, + { + "entry_id": "IMP-0030", + "experiment_name": "pead_midcap_step36_balanced_sleeves_nofrac_aclong12_sdlong12", + "sqs_score": 90.3, + "profit_factor": 2.061648751658133, + "total_return_pct": 2.1478646359501146, + "win_rate": 0.6111111111111112, + "sharpe_ratio": 4.4957307480895325, + "max_drawdown_pct": 0.3579823467213594, + "trade_count": 54, + "timestamp": "2026-03-17T07:05:52.980782+00:00" + }, + { + "entry_id": "IMP-0038", + "experiment_name": "pead_midcap_step44_short_core_macro50", + "sqs_score": 87.9, + "profit_factor": 2.0052763103391356, + "total_return_pct": 1.2986802302195721, + "win_rate": 0.5689655172413793, + "sharpe_ratio": 2.6217356101467053, + "max_drawdown_pct": 0.7045335236824514, + "trade_count": 58, + "timestamp": "2026-03-17T07:54:25.486815+00:00" + }, + { + "entry_id": "IMP-0041", + "experiment_name": "pead_midcap_step47_short_core_macro_block_sdlong25", + "sqs_score": 87.0, + "profit_factor": 3.429404855633068, + "total_return_pct": 1.2833562667310616, + "win_rate": 0.68, + "sharpe_ratio": 3.32348066596022, + "max_drawdown_pct": 0.2608177180219861, + "trade_count": 25, + "timestamp": "2026-03-17T07:54:26.612610+00:00" + }, + { + "entry_id": "IMP-0039", + "experiment_name": "pead_midcap_step45_short_core_macro_block", + "sqs_score": 86.7, + "profit_factor": 3.7827916861128097, + "total_return_pct": 1.2021184808416436, + "win_rate": 0.6956521739130435, + "sharpe_ratio": 3.3795660622862123, + "max_drawdown_pct": 0.2608177180219861, + "trade_count": 23, + "timestamp": "2026-03-17T07:54:25.866482+00:00" + }, + { + "entry_id": "IMP-0045", + "experiment_name": "pead_midcap_step51_short_core_macro_block_crashcap", + "sqs_score": 86.7, + "profit_factor": 4.190184861108528, + "total_return_pct": 1.2441182731003355, + "win_rate": 0.7272727272727273, + "sharpe_ratio": 3.5956143566120704, + "max_drawdown_pct": 0.22380328257556925, + "trade_count": 22, + "timestamp": "2026-03-17T07:54:28.043618+00:00" + }, + { + "entry_id": "IMP-0040", + "experiment_name": "pead_midcap_step46_short_core_macro_block_acshort12", + "sqs_score": 86.6, + "profit_factor": 4.522043594902001, + "total_return_pct": 1.2518263219734362, + "win_rate": 0.7142857142857143, + "sharpe_ratio": 3.6389410132883055, + "max_drawdown_pct": 0.2783619920052574, + "trade_count": 21, + "timestamp": "2026-03-17T07:54:26.245073+00:00" + }, + { + "entry_id": "IMP-0046", + "experiment_name": "pead_midcap_step52_short_core_macro_block_crashcap_gap10", + "sqs_score": 86.3, + "profit_factor": 3.351800408128271, + "total_return_pct": 1.0111883417758072, + "win_rate": 0.7727272727272727, + "sharpe_ratio": 3.031867507475822, + "max_drawdown_pct": 0.24257912061402945, + "trade_count": 22, + "timestamp": "2026-03-17T07:54:28.401055+00:00" + }, + { + "entry_id": "IMP-0042", + "experiment_name": "pead_midcap_step48_short_core_macro_block_nolong", + "sqs_score": 85.7, + "profit_factor": 3.402559985815088, + "total_return_pct": 0.8451446297187069, + "win_rate": 0.8, + "sharpe_ratio": 2.4413021645869604, + "max_drawdown_pct": 0.4239007555767844, + "trade_count": 20, + "timestamp": "2026-03-17T07:54:26.967747+00:00" + }, + { + "entry_id": "IMP-0034", + "experiment_name": "pead_midcap_step40_short_core_sdlong12", + "sqs_score": 82.1, + "profit_factor": 1.7695290624299977, + "total_return_pct": 1.52020169384827, + "win_rate": 0.5818181818181818, + "sharpe_ratio": 2.2087400904317267, + "max_drawdown_pct": 1.128135882565315, + "trade_count": 55, + "timestamp": "2026-03-17T07:20:29.978842+00:00" + }, + { + "entry_id": "IMP-0028", + "experiment_name": "pead_midcap_step34_balanced_sleeves_nofrac_aclong25", + "sqs_score": 81.3, + "profit_factor": 1.6229787581193362, + "total_return_pct": 1.809973740790665, + "win_rate": 0.5645161290322581, + "sharpe_ratio": 3.784062042365747, + "max_drawdown_pct": 0.5514994557958487, + "trade_count": 62, + "timestamp": "2026-03-17T07:05:41.969503+00:00" + }, + { + "entry_id": "IMP-0037", + "experiment_name": "pead_midcap_step43_short_core_sdlong25", + "sqs_score": 78.9, + "profit_factor": 1.6628306811204845, + "total_return_pct": 1.4636686220428092, + "win_rate": 0.5689655172413793, + "sharpe_ratio": 2.0112049109753753, + "max_drawdown_pct": 1.2630043081731899, + "trade_count": 58, + "timestamp": "2026-03-17T07:20:29.978670+00:00" + }, + { + "entry_id": "IMP-0026", + "experiment_name": "pead_midcap_step31_balanced_sleeves_nofrac_acshort12", + "sqs_score": 77.9, + "profit_factor": 1.4929664814770336, + "total_return_pct": 1.5563811867822805, + "win_rate": 0.546875, + "sharpe_ratio": 3.235108878683093, + "max_drawdown_pct": 0.6813257982524192, + "trade_count": 64, + "timestamp": "2026-03-17T06:59:09.255218+00:00" + }, + { + "entry_id": "IMP-0033", + "experiment_name": "pead_midcap_step39_balanced_sleeves_sdlong12", + "sqs_score": 77.9, + "profit_factor": 1.5016295541562696, + "total_return_pct": 1.5150882212722936, + "win_rate": 0.5483870967741935, + "sharpe_ratio": 3.3681033592846275, + "max_drawdown_pct": 0.5454456407735768, + "trade_count": 62, + "timestamp": "2026-03-17T07:11:34.247400+00:00" + }, + { + "entry_id": "IMP-0025", + "experiment_name": "pead_midcap_step30_balanced_sleeves_nofrac", + "sqs_score": 75.2, + "profit_factor": 1.4091528424597795, + "total_return_pct": 1.3757295850866649, + "win_rate": 0.5373134328358209, + "sharpe_ratio": 2.8484960542107354, + "max_drawdown_pct": 0.8128025480297582, + "trade_count": 67, + "timestamp": "2026-03-17T06:59:08.875770+00:00" + }, + { + "entry_id": "IMP-0027", + "experiment_name": "pead_midcap_step33_balanced_sleeves_nofrac_acshort6", + "sqs_score": 73.9, + "profit_factor": 1.4726487821009215, + "total_return_pct": 1.2304910166146, + "win_rate": 0.5208333333333334, + "sharpe_ratio": 2.3026797742321596, + "max_drawdown_pct": 0.7081594843000599, + "trade_count": 48, + "timestamp": "2026-03-17T06:59:09.622681+00:00" + }, + { + "entry_id": "IMP-0031", + "experiment_name": "pead_midcap_step37_balanced_sleeves_aclong_vol3", + "sqs_score": 73.6, + "profit_factor": 1.377310183342628, + "total_return_pct": 1.1862161229211343, + "win_rate": 0.5396825396825397, + "sharpe_ratio": 2.4626680484273273, + "max_drawdown_pct": 0.8243191281564817, + "trade_count": 63, + "timestamp": "2026-03-17T07:11:20.119143+00:00" + }, + { + "entry_id": "IMP-0035", + "experiment_name": "pead_midcap_step41_short_core_sdlong12_acshort50", + "sqs_score": 72.6, + "profit_factor": 1.5287187084320328, + "total_return_pct": 1.231827071365813, + "win_rate": 0.559322033898305, + "sharpe_ratio": 1.6310365384096348, + "max_drawdown_pct": 1.2759660172387226, + "trade_count": 59, + "timestamp": "2026-03-17T07:20:29.978594+00:00" + }, + { + "entry_id": "IMP-0036", + "experiment_name": "pead_midcap_step42_short_core_only", + "sqs_score": 66.3, + "profit_factor": 1.4446704886262167, + "total_return_pct": 0.7775531748585345, + "win_rate": 0.6304347826086957, + "sharpe_ratio": 1.1740322533878838, + "max_drawdown_pct": 1.2423515817014616, + "trade_count": 46, + "timestamp": "2026-03-17T07:20:29.978668+00:00" + }, { "entry_id": "IMP-0015", "experiment_name": "pead_midcap_step14_score65", @@ -60,6 +288,18 @@ "trade_count": 66, "timestamp": "2026-03-17T02:16:52.415056+00:00" }, + { + "entry_id": "IMP-0024", + "experiment_name": "pead_midcap_step27_sdlong_close7_budget25", + "sqs_score": 57.9, + "profit_factor": 1.1707262231202136, + "total_return_pct": 0.5844127796271495, + "win_rate": 0.5441176470588235, + "sharpe_ratio": 0.9376525375900492, + "max_drawdown_pct": 1.306567610162907, + "trade_count": 68, + "timestamp": "2026-03-17T06:59:08.507830+00:00" + }, { "entry_id": "IMP-0014", "experiment_name": "pead_midcap_step13_best", @@ -72,6 +312,30 @@ "trade_count": 75, "timestamp": "2026-03-16T23:05:05.412943+00:00" }, + { + "entry_id": "IMP-0023", + "experiment_name": "pead_midcap_step23_sdlong_close7", + "sqs_score": 55.1, + "profit_factor": 1.1334708809037874, + "total_return_pct": 0.4719010862756259, + "win_rate": 0.5362318840579711, + "sharpe_ratio": 0.7440066482222032, + "max_drawdown_pct": 1.419114371849651, + "trade_count": 69, + "timestamp": "2026-03-17T06:59:08.123617+00:00" + }, + { + "entry_id": "IMP-0032", + "experiment_name": "pead_midcap_step38_balanced_sleeves_aclong_vol4", + "sqs_score": 54.2, + "profit_factor": 1.1204890397898135, + "total_return_pct": 0.388000418802214, + "win_rate": 0.5081967213114754, + "sharpe_ratio": 0.8136845994700913, + "max_drawdown_pct": 0.9399793760032171, + "trade_count": 61, + "timestamp": "2026-03-17T07:11:26.965107+00:00" + }, { "entry_id": "IMP-0017", "experiment_name": "pead_midcap_step16_react7_score65", @@ -96,6 +360,18 @@ "trade_count": 96, "timestamp": "2026-03-16T22:52:30.561839+00:00" }, + { + "entry_id": "IMP-0022", + "experiment_name": "pead_midcap_portfolio_v2", + "sqs_score": 52.0, + "profit_factor": 1.0823586029850167, + "total_return_pct": 0.28994018403757943, + "win_rate": 0.5142857142857142, + "sharpe_ratio": 0.4840289893207606, + "max_drawdown_pct": 1.7544248374390794, + "trade_count": 70, + "timestamp": "2026-03-17T06:59:07.766076+00:00" + }, { "entry_id": "IMP-0016", "experiment_name": "pead_midcap_step15_react7", @@ -156,6 +432,30 @@ "trade_count": 79, "timestamp": "2026-03-16T23:04:14.616459+00:00" }, + { + "entry_id": "IMP-0047", + "experiment_name": "pead_midcap_step53_short_core_macro_block_crashcap_gap14", + "sqs_score": 43.1, + "profit_factor": 4.599773676047558, + "total_return_pct": 1.1191525844285641, + "win_rate": 0.8421052631578947, + "sharpe_ratio": 3.463700507402551, + "max_drawdown_pct": 0.21947031826165894, + "trade_count": 19, + "timestamp": "2026-03-17T07:54:28.762757+00:00" + }, + { + "entry_id": "IMP-0044", + "experiment_name": "pead_midcap_step50_same_day_short_long_macro_block", + "sqs_score": 41.9, + "profit_factor": 3.2060142950020296, + "total_return_pct": 0.805535692316771, + "win_rate": 0.6, + "sharpe_ratio": 2.6871013818111624, + "max_drawdown_pct": 0.42315703199236054, + "trade_count": 15, + "timestamp": "2026-03-17T07:54:27.691772+00:00" + }, { "entry_id": "IMP-0002", "experiment_name": "pead_midcap_step2_notrail", @@ -192,6 +492,18 @@ "trade_count": 79, "timestamp": "2026-03-16T22:52:31.270613+00:00" }, + { + "entry_id": "IMP-0043", + "experiment_name": "pead_midcap_step49_same_day_short_macro_block", + "sqs_score": 38.9, + "profit_factor": 2.4025420210691006, + "total_return_pct": 0.40093684044296973, + "win_rate": 0.7, + "sharpe_ratio": 1.5580313035787354, + "max_drawdown_pct": 0.3693400619520335, + "trade_count": 10, + "timestamp": "2026-03-17T07:54:27.322093+00:00" + }, { "entry_id": "IMP-0006", "experiment_name": "pead_midcap_step6_drift", @@ -253,5 +565,5 @@ "timestamp": "2026-03-16T23:01:55.163461+00:00" } ], - "updated_at": "2026-03-17T04:06:21.261225+00:00" + "updated_at": "2026-03-17T07:57:00.128792+00:00" } \ No newline at end of file diff --git a/journal/improvement_journal.jsonl b/journal/improvement_journal.jsonl index dedd9be..fbb3448 100644 --- a/journal/improvement_journal.jsonl +++ b/journal/improvement_journal.jsonl @@ -19,3 +19,29 @@ {"entry_id":"IMP-0019","timestamp":"2026-03-17T02:17:01.474375+00:00","experiment_name":"pead_midcap_step18_nofrac","hypothesis":"Full exit at target (fraction 1.0) prevents trailing stop from eating profits on partial positions","config_delta":{"base_experiment":"pead_midcap_step14_score65","changes":{}},"results":{"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317021443_be3d5bde","trade_count":437,"profit_factor":0.9549362257822231,"total_return_pct":-1.303548314107451,"win_rate":0.42105263157894735,"max_drawdown_pct":4.52621951607018,"sharpe_ratio":-0.16411744461886982,"monthly_win_rate":0.5142857142857142,"equity_curve_r_squared":0.048794422109098394},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317021450_be3d5bde","trade_count":55,"profit_factor":1.4603949049610698,"total_return_pct":1.180333505141476,"win_rate":0.4727272727272727,"max_drawdown_pct":0.624348249096465,"sharpe_ratio":1.8572252886649268,"monthly_win_rate":0.75,"equity_curve_r_squared":0.602794881326535},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317021455_be3d5bde","trade_count":64,"profit_factor":1.233557927923508,"total_return_pct":0.7760032481101371,"win_rate":0.515625,"max_drawdown_pct":0.8900540815144506,"sharpe_ratio":1.4353513823455328,"monthly_win_rate":1.0,"equity_curve_r_squared":0.2803203771052179}},"sqs_score":63.2,"sqs_breakdown":{"profitability":44.8,"risk":90.6,"consistency":77.6,"robustness":47.5},"verdict":"neutral","verdict_reasoning":"Test SQS 63.2 vs 64.2. Return +0.78% slightly better than +0.73%. Within noise. Fewer trades (64 vs 72). Marginal effect.","next_direction":"Nofrac marginal positive on return but not SQS","tags":["pead","midcap","step18","nofrac"]} {"entry_id":"IMP-0020","timestamp":"2026-03-17T02:17:01.827556+00:00","experiment_name":"pead_midcap_step19_hold5","hypothesis":"Shorter 5d hold reduces exposure since avg hold is 3.28d. Less time for reversals.","config_delta":{"base_experiment":"pead_midcap_step14_score65","changes":{}},"results":{"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317021504_27a63cc2","trade_count":513,"profit_factor":0.9321637680892956,"total_return_pct":-1.9498490194724147,"win_rate":0.49902534113060426,"max_drawdown_pct":5.109402635025258,"sharpe_ratio":-0.2558888083394153,"monthly_win_rate":0.4857142857142857,"equity_curve_r_squared":0.2741765876459641},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317021510_27a63cc2","trade_count":68,"profit_factor":1.5486357888297084,"total_return_pct":1.4116877933366923,"win_rate":0.5735294117647058,"max_drawdown_pct":0.6559648601902311,"sharpe_ratio":2.211147627502439,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.6062916819268495},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317021516_27a63cc2","trade_count":72,"profit_factor":1.2003700113675047,"total_return_pct":0.6659802746770583,"win_rate":0.5694444444444444,"max_drawdown_pct":0.8797326864948225,"sharpe_ratio":1.2282104833801621,"monthly_win_rate":1.0,"equity_curve_r_squared":0.16598640737563777}},"sqs_score":62.9,"sqs_breakdown":{"profitability":42.7,"risk":87.1,"consistency":86.6,"robustness":44.8},"verdict":"neutral","verdict_reasoning":"Test SQS 62.9 vs 64.2. Return +0.67% vs +0.73%. Valid SQS 77.4 was strong. Similar trade count (72). Marginal effect.","next_direction":"Hold5d marginal positive on valid but neutral on test","tags":["pead","midcap","step19","hold5"]} {"entry_id":"IMP-0021","timestamp":"2026-03-17T02:17:02.185037+00:00","experiment_name":"pead_midcap_step20_best3","hypothesis":"Combine all marginally positive changes: score 0.65 + nofrac 1.0 + hold 5d","config_delta":{"base_experiment":"pead_midcap_step14_score65","changes":{}},"results":{"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317021559_27d04e9f","trade_count":441,"profit_factor":0.967683028533835,"total_return_pct":-0.9321417326055089,"win_rate":0.41950113378684806,"max_drawdown_pct":4.355001847653457,"sharpe_ratio":-0.11372635609251765,"monthly_win_rate":0.5142857142857142,"equity_curve_r_squared":0.038072957614376554},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317021605_27d04e9f","trade_count":56,"profit_factor":1.6107426395301327,"total_return_pct":1.5733831641505238,"win_rate":0.48214285714285715,"max_drawdown_pct":0.628137470729625,"sharpe_ratio":2.475989818011853,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.6343682913335291},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317021610_27d04e9f","trade_count":64,"profit_factor":1.2154843477872561,"total_return_pct":0.7161940318976412,"win_rate":0.515625,"max_drawdown_pct":0.8900540815144506,"sharpe_ratio":1.3247288791439433,"monthly_win_rate":1.0,"equity_curve_r_squared":0.24515410354772538}},"sqs_score":62.0,"sqs_breakdown":{"profitability":43.6,"risk":88.7,"consistency":77.6,"robustness":45.3},"verdict":"neutral","verdict_reasoning":"Test SQS 62.0 < 64.2. Return +0.72% similar to +0.73%. Combo did not beat step14 alone. Over-optimization reduces robustness.","next_direction":"Step14 (score 0.65) remains the best. Simple is better.","tags":["pead","midcap","step20","best3"]} +{"entry_id":"IMP-0022","timestamp":"2026-03-17T06:59:07.766076+00:00","experiment_name":"pead_midcap_portfolio_v2","hypothesis":"Split step14 into specialist sleeves by timing and direction with same-day long reaction-close and after-close short in shadow.","config_delta":{"base_experiment":"pead_midcap_step14_score65","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317064012_7e0c28d8","trade_count":70,"profit_factor":1.0823586029850167,"total_return_pct":0.28994018403757943,"win_rate":0.5142857142857142,"max_drawdown_pct":1.7544248374390794,"sharpe_ratio":0.4840289893207606,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.06048799253372119}},"sqs_score":52.0,"sqs_breakdown":{"profitability":35.3,"risk":70.5,"consistency":73.2,"robustness":37.1},"verdict":"worse","verdict_reasoning":"Test-only run underperformed badly versus step14: SQS 52.0 vs 64.2 and return +0.29% vs +0.73%. The split introduced too much portfolio distortion.","next_direction":"Keep the profitable buckets from step14 but test narrower sleeve overrides instead of full portfolio decomposition.","tags":["pead","midcap","portfolio","v2"]} +{"entry_id":"IMP-0023","timestamp":"2026-03-17T06:59:08.123617+00:00","experiment_name":"pead_midcap_step23_sdlong_close7","hypothesis":"Keep the positive step14 sleeves, switch same-day longs to reaction-close, and remove after-close shorts.","config_delta":{"base_experiment":"pead_midcap_step14_score65","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317065129_8b4cc460","trade_count":69,"profit_factor":1.1334708809037874,"total_return_pct":0.4719010862756259,"win_rate":0.5362318840579711,"max_drawdown_pct":1.419114371849651,"sharpe_ratio":0.7440066482222032,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.02271091721470062}},"sqs_score":55.1,"sqs_breakdown":{"profitability":38.6,"risk":76.7,"consistency":76.9,"robustness":34.2},"verdict":"worse","verdict_reasoning":"Test-only run improved over the broad V2 idea but still missed step14: SQS 55.1 and return +0.47%. Same-day long close-entry alone was not enough.","next_direction":"Reduce the same-day long sleeve instead of funding it at full size.","tags":["pead","midcap","step23","sdlong","close7"]} +{"entry_id":"IMP-0024","timestamp":"2026-03-17T06:59:08.507830+00:00","experiment_name":"pead_midcap_step27_sdlong_close7_budget25","hypothesis":"Keep same-day long reaction-close but cap it to a smaller 25% daily risk sleeve to reduce crowding.","config_delta":{"base_experiment":"pead_midcap_step23_sdlong_close7","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317065422_ab277961","trade_count":68,"profit_factor":1.1707262231202136,"total_return_pct":0.5844127796271495,"win_rate":0.5441176470588235,"max_drawdown_pct":1.306567610162907,"sharpe_ratio":0.9376525375900492,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.10433971673279621}},"sqs_score":57.9,"sqs_breakdown":{"profitability":40.9,"risk":80.6,"consistency":78.2,"robustness":38.7},"verdict":"better","verdict_reasoning":"Test-only run materially improved over step23: SQS 57.9 vs 55.1 and return +0.58% vs +0.47%, confirming the crowding hypothesis.","next_direction":"Combine sleeve budgeting with full target exits and reintroduce a small after-close short sleeve for trade-count support.","tags":["pead","midcap","step27","sdlong","close7","budget25"]} +{"entry_id":"IMP-0025","timestamp":"2026-03-17T06:59:08.875770+00:00","experiment_name":"pead_midcap_step30_balanced_sleeves_nofrac","hypothesis":"Use balanced sleeve budgets: keep same-day shorts and after-close longs fully funded, run same-day long close-entry and after-close shorts as small sleeves, and exit fully at target.","config_delta":{"base_experiment":"pead_midcap_step14_score65","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317065531_18195bf4","trade_count":67,"profit_factor":1.4091528424597795,"total_return_pct":1.3757295850866649,"win_rate":0.5373134328358209,"max_drawdown_pct":0.8128025480297582,"sharpe_ratio":2.8484960542107354,"monthly_win_rate":1.0,"equity_curve_r_squared":0.7324107850180904}},"sqs_score":75.2,"sqs_breakdown":{"profitability":56.0,"risk":100.0,"consistency":81.2,"robustness":77.4},"verdict":"better","verdict_reasoning":"Test-only run broke through step14 decisively: SQS 75.2 vs 64.2 and return +1.38% vs +0.73%. A small after-close short sleeve helped robustness without dominating risk.","next_direction":"Tighten the after-close short sleeve further and validate the best variant across all splits.","tags":["pead","midcap","step30","balanced","sleeves","nofrac"]} +{"entry_id":"IMP-0026","timestamp":"2026-03-17T06:59:09.255218+00:00","experiment_name":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","hypothesis":"Shrink the after-close short sleeve again while keeping the rest of the balanced step30 structure intact.","config_delta":{"base_experiment":"pead_midcap_step30_balanced_sleeves_nofrac","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317065814_ca861015","trade_count":64,"profit_factor":1.4929664814770336,"total_return_pct":1.5563811867822805,"win_rate":0.546875,"max_drawdown_pct":0.6813257982524192,"sharpe_ratio":3.235108878683093,"monthly_win_rate":1.0,"equity_curve_r_squared":0.8144638886979153},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317065750_ca861015","trade_count":426,"profit_factor":1.0908713237524914,"total_return_pct":2.45066812206927,"win_rate":0.45539906103286387,"max_drawdown_pct":2.6845497224397294,"sharpe_ratio":0.3357364835430949,"monthly_win_rate":0.5142857142857142,"equity_curve_r_squared":0.6011932815228401},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317065810_ca861015","trade_count":58,"profit_factor":1.37572962559925,"total_return_pct":1.122807593766629,"win_rate":0.5172413793103449,"max_drawdown_pct":0.8884863869932943,"sharpe_ratio":1.8162493466149443,"monthly_win_rate":0.75,"equity_curve_r_squared":0.3292893146712864}},"sqs_score":77.9,"sqs_breakdown":{"profitability":60.9,"risk":100.0,"consistency":82.8,"robustness":80.0},"verdict":"better","verdict_reasoning":"New best. Train/valid/test all held up, with test SQS 77.9, return +1.56%, PF 1.66, Sharpe 2.39, and max drawdown 0.32%. This beat both step30 and step14 by a wide margin.","next_direction":"Use step31 as the new base and only explore very local refinements around sleeve weights or execution if further gains are needed.","tags":["pead","midcap","step31","balanced","sleeves","nofrac","acshort12"]} +{"entry_id":"IMP-0027","timestamp":"2026-03-17T06:59:09.622681+00:00","experiment_name":"pead_midcap_step33_balanced_sleeves_nofrac_acshort6","hypothesis":"Cut the after-close short sleeve even further to see if the balanced portfolio still benefits from the bucket at very low size.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317065700_765096af","trade_count":48,"profit_factor":1.4726487821009215,"total_return_pct":1.2304910166146,"win_rate":0.5208333333333334,"max_drawdown_pct":0.7081594843000599,"sharpe_ratio":2.3026797742321596,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.7956775968800425}},"sqs_score":73.9,"sqs_breakdown":{"profitability":58.6,"risk":100.0,"consistency":74.3,"robustness":70.8},"verdict":"worse","verdict_reasoning":"Test-only run stayed strong but slipped versus step31: SQS 73.9 vs 77.9 and return +1.23% vs +1.56%. The smaller sleeve gave up too much trade-count support.","next_direction":"Keep the 12.5% after-close short sleeve from step31.","tags":["pead","midcap","step33","balanced","sleeves","nofrac","acshort6"]} +{"entry_id":"IMP-0028","timestamp":"2026-03-17T07:05:41.969503+00:00","experiment_name":"pead_midcap_step34_balanced_sleeves_nofrac_aclong25","hypothesis":"Cap the after-close long sleeve to two trades per day so only the best-ranked overnight continuation names survive.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070326_6bef3bf5","trade_count":62,"profit_factor":1.6229787581193362,"total_return_pct":1.809973740790665,"win_rate":0.5645161290322581,"max_drawdown_pct":0.5514994557958487,"sharpe_ratio":3.784062042365747,"monthly_win_rate":1.0,"equity_curve_r_squared":0.8527995261422007},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070447_6bef3bf5","trade_count":57,"profit_factor":1.2977962107392036,"total_return_pct":0.888003662238436,"win_rate":0.5087719298245614,"max_drawdown_pct":0.884279658549619,"sharpe_ratio":1.43172550959332,"monthly_win_rate":0.75,"equity_curve_r_squared":0.26161939040716076}},"sqs_score":81.3,"sqs_breakdown":{"profitability":68.4,"risk":100.0,"consistency":85.8,"robustness":78.9},"verdict":"worse","verdict_reasoning":"Test improved to SQS 81.3 and +1.81% return, but valid slipped to SQS 63.7 and +0.89% versus step31 valid SQS 68.2 and +1.12%. The top-two cap on after-close longs was not robust across splits.","next_direction":"Keep step31 as the robust base. If after-close long needs filtering, prefer a quality gate rather than a blunt daily-cap reduction.","tags":["pead","midcap","step34","balanced","sleeves","nofrac","aclong25"]} +{"entry_id":"IMP-0029","timestamp":"2026-03-17T07:05:47.363036+00:00","experiment_name":"pead_midcap_step35_balanced_sleeves_nofrac_aclong12","hypothesis":"Cap the after-close long sleeve to one trade per day so the portfolio fully leans into the strongest overnight continuation name only.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070326_01aa6342","trade_count":56,"profit_factor":2.009368125746884,"total_return_pct":2.181331690538893,"win_rate":0.6071428571428571,"max_drawdown_pct":0.4424438123900949,"sharpe_ratio":4.216651506745797,"monthly_win_rate":1.0,"equity_curve_r_squared":0.8888944531705611},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070357_01aa6342","trade_count":53,"profit_factor":1.2430238868359724,"total_return_pct":0.7082451550234983,"win_rate":0.5094339622641509,"max_drawdown_pct":0.8845604127450726,"sharpe_ratio":1.1796633240683954,"monthly_win_rate":0.75,"equity_curve_r_squared":0.14646025414461133},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070358_01aa6342","trade_count":393,"profit_factor":1.1135008933147288,"total_return_pct":2.755962139586409,"win_rate":0.46055979643765904,"max_drawdown_pct":2.418955602136075,"sharpe_ratio":0.38778263039611355,"monthly_win_rate":0.4857142857142857,"equity_curve_r_squared":0.6345243468091162}},"sqs_score":90.4,"sqs_breakdown":{"profitability":88.7,"risk":100.0,"consistency":92.9,"robustness":75.6},"verdict":"worse","verdict_reasoning":"This became a new test-only high water mark at SQS 90.4 and +2.18%, but valid deteriorated to SQS 59.8 and +0.71%, materially below step31. The tighter top-one cap overfit to the test window.","next_direction":"Avoid promoting step35. Explore engine-specific quality filters for after-close longs instead of hard caps that reshuffle trade selection too aggressively.","tags":["pead","midcap","step35","balanced","sleeves","nofrac","aclong12"]} +{"entry_id":"IMP-0030","timestamp":"2026-03-17T07:05:52.980782+00:00","experiment_name":"pead_midcap_step36_balanced_sleeves_nofrac_aclong12_sdlong12","hypothesis":"Keep only one same-day long close trade per day alongside the top-one after-close long sleeve, concentrating the portfolio into the single best long continuation setups.","config_delta":{"base_experiment":"pead_midcap_step35_balanced_sleeves_nofrac_aclong12","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070326_18804e7c","trade_count":54,"profit_factor":2.061648751658133,"total_return_pct":2.1478646359501146,"win_rate":0.6111111111111112,"max_drawdown_pct":0.3579823467213594,"sharpe_ratio":4.4957307480895325,"monthly_win_rate":1.0,"equity_curve_r_squared":0.9122676472502861}},"sqs_score":90.3,"sqs_breakdown":{"profitability":88.6,"risk":100.0,"consistency":93.5,"robustness":74.4},"verdict":"worse","verdict_reasoning":"Test stayed extremely strong at SQS 90.3 and +2.15%, but it did not exceed step35 on test and reduced trade support further. Without valid/train confirmation, it is not a better promotion candidate than step31.","next_direction":"Keep the same-day long sleeve at two trades per day if using this family, and focus next on smarter after-close long quality filtering.","tags":["pead","midcap","step36","balanced","sleeves","nofrac","aclong12","sdlong12"]} +{"entry_id":"IMP-0031","timestamp":"2026-03-17T07:11:20.119143+00:00","experiment_name":"pead_midcap_step37_balanced_sleeves_aclong_vol3","hypothesis":"Require stronger volume confirmation for after-close long signals only, while leaving the rest of the step31 sleeve mix unchanged.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071110746395_1ae4b33a","trade_count":63,"profit_factor":1.377310183342628,"total_return_pct":1.1862161229211343,"win_rate":0.5396825396825397,"max_drawdown_pct":0.8243191281564817,"sharpe_ratio":2.4626680484273273,"monthly_win_rate":1.0,"equity_curve_r_squared":0.6885324256339993},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071110780542_1ae4b33a","trade_count":57,"profit_factor":1.375336580561267,"total_return_pct":1.1216330418461293,"win_rate":0.5087719298245614,"max_drawdown_pct":0.8896384382974281,"sharpe_ratio":1.7701058914354275,"monthly_win_rate":0.75,"equity_curve_r_squared":0.3302488231788083}},"sqs_score":73.6,"sqs_breakdown":{"profitability":53.6,"risk":100.0,"consistency":81.6,"robustness":72.5},"verdict":"worse","verdict_reasoning":"Engine-specific volume gating on after-close longs did not help. Test fell to SQS 73.6 and valid to 67.6, both below the step31 base. The extra volume filter removed too much breadth without improving robustness.","next_direction":"Do not tighten after-close long volume gates further. Keep after-close long breadth and search elsewhere if more robustness is needed.","tags":["pead","midcap","step37","balanced","sleeves","aclong","vol3"]} +{"entry_id":"IMP-0032","timestamp":"2026-03-17T07:11:26.965107+00:00","experiment_name":"pead_midcap_step38_balanced_sleeves_aclong_vol4","hypothesis":"Push the after-close long sleeve to an even stricter volume gate so only the highest-conviction overnight reactions remain.","config_delta":{"base_experiment":"pead_midcap_step37_balanced_sleeves_aclong_vol3","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071110780988_d9020d34","trade_count":61,"profit_factor":1.1204890397898135,"total_return_pct":0.388000418802214,"win_rate":0.5081967213114754,"max_drawdown_pct":0.9399793760032171,"sharpe_ratio":0.8136845994700913,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.043822307069783864},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071110695904_d9020d34","trade_count":53,"profit_factor":1.2930052787721988,"total_return_pct":0.8333477612284769,"win_rate":0.5283018867924528,"max_drawdown_pct":0.8783421675410786,"sharpe_ratio":1.4376847128011023,"monthly_win_rate":0.75,"equity_curve_r_squared":0.19294412573010883}},"sqs_score":54.2,"sqs_breakdown":{"profitability":37.6,"risk":80.2,"consistency":72.2,"robustness":31.1},"verdict":"worse","verdict_reasoning":"The stricter after-close long filter clearly broke the portfolio. Test dropped to SQS 54.2 and valid to 63.2, confirming that this sleeve cannot be improved by simply tightening volume thresholds.","next_direction":"Abandon the after-close long volume-threshold path. If that sleeve is revisited, it needs a different filter than raw PEAD volume.","tags":["pead","midcap","step38","balanced","sleeves","aclong","vol4"]} +{"entry_id":"IMP-0033","timestamp":"2026-03-17T07:11:34.247400+00:00","experiment_name":"pead_midcap_step39_balanced_sleeves_sdlong12","hypothesis":"Keep the robust step31 structure intact and only cap same-day long close entries to one trade per day, trimming the weakest same-day long names without touching after-close longs.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071045883427_aa091287","trade_count":53,"profit_factor":1.3838842470402677,"total_return_pct":1.0111821812581183,"win_rate":0.5094339622641509,"max_drawdown_pct":0.8840772162266693,"sharpe_ratio":1.7379492731088386,"monthly_win_rate":0.75,"equity_curve_r_squared":0.2907004952665152},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071045888044_aa091287","trade_count":62,"profit_factor":1.5016295541562696,"total_return_pct":1.5150882212722936,"win_rate":0.5483870967741935,"max_drawdown_pct":0.5454456407735768,"sharpe_ratio":3.3681033592846275,"monthly_win_rate":1.0,"equity_curve_r_squared":0.8586769561427715}},"sqs_score":77.9,"sqs_breakdown":{"profitability":61.1,"risk":100.0,"consistency":83.1,"robustness":78.9},"verdict":"worse","verdict_reasoning":"This preserved test strength at SQS 77.9 and +1.52%, but valid fell to SQS 66.8 and +1.01% versus step31 valid SQS 68.2 and +1.12%. Reducing same-day long breadth did not produce a robust improvement.","next_direction":"Keep the same-day long sleeve at two trades per day inside step31. The current robust champion remains unchanged.","tags":["pead","midcap","step39","balanced","sleeves","sdlong12"]} +{"entry_id":"IMP-0036","timestamp":"2026-03-17T07:20:29.978668+00:00","experiment_name":"pead_midcap_step42_short_core_only","hypothesis":"Test whether the portfolio should become a pure short engine by removing the same-day long overlay entirely.","config_delta":{"base_experiment":"pead_midcap_step40_short_core_sdlong12","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071756406525_914047c4","trade_count":31,"profit_factor":2.549217811707667,"total_return_pct":1.193718767675222,"win_rate":0.5806451612903226,"max_drawdown_pct":0.5448919617489582,"sharpe_ratio":2.752802585098115,"monthly_win_rate":0.75,"equity_curve_r_squared":0.41739788897817576},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071756496030_914047c4","trade_count":46,"profit_factor":1.4446704886262167,"total_return_pct":0.7775531748585345,"win_rate":0.6304347826086957,"max_drawdown_pct":1.2423515817014616,"sharpe_ratio":1.1740322533878838,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.1540289094091857}},"sqs_score":66.3,"sqs_breakdown":{"profitability":55.3,"risk":84.9,"consistency":92.6,"robustness":29.6},"verdict":"worse","verdict_reasoning":"Pure shorts produced a strong valid SQS 82.3 but test collapsed to 66.3 with only +0.78% return. The small same-day long overlay is still needed for out-of-sample balance.","next_direction":"Keep a non-zero same-day long close sleeve in the short-core family.","tags":["pead","midcap","step42","short","core","only"]} +{"entry_id":"IMP-0035","timestamp":"2026-03-17T07:20:29.978594+00:00","experiment_name":"pead_midcap_step41_short_core_sdlong12_acshort50","hypothesis":"Lean harder into the after-close short sleeve inside the new short-core portfolio.","config_delta":{"base_experiment":"pead_midcap_step40_short_core_sdlong12","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071756232509_4c9fd4ce","trade_count":40,"profit_factor":1.483920817012022,"total_return_pct":0.8662763872782817,"win_rate":0.55,"max_drawdown_pct":0.6476993822793542,"sharpe_ratio":1.8482432920991685,"monthly_win_rate":0.75,"equity_curve_r_squared":0.19435203451054603},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071756491852_4c9fd4ce","trade_count":59,"profit_factor":1.5287187084320328,"total_return_pct":1.231827071365813,"win_rate":0.559322033898305,"max_drawdown_pct":1.2759660172387226,"sharpe_ratio":1.6310365384096348,"monthly_win_rate":1.0,"equity_curve_r_squared":0.4227182249282274}},"sqs_score":72.6,"sqs_breakdown":{"profitability":61.4,"risk":92.3,"consistency":84.9,"robustness":53.6},"verdict":"worse","verdict_reasoning":"Increasing after-close short capacity weakened the portfolio: test dropped from SQS 82.1 to 72.6 and valid stayed flat at 68.4. The short core benefits from the bucket, but not at this larger size.","next_direction":"Keep the after-close short sleeve at 25% inside the short-core family.","tags":["pead","midcap","step41","short","core","sdlong12","acshort50"]} +{"entry_id":"IMP-0037","timestamp":"2026-03-17T07:20:29.978670+00:00","experiment_name":"pead_midcap_step43_short_core_sdlong25","hypothesis":"Restore a larger same-day long close sleeve after removing after-close longs, to see if breadth improves the short-core portfolio.","config_delta":{"base_experiment":"pead_midcap_step40_short_core_sdlong12","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071957079395_9046f612","trade_count":45,"profit_factor":1.4604102163883825,"total_return_pct":0.9859009158709378,"win_rate":0.5555555555555556,"max_drawdown_pct":0.6469365320312458,"sharpe_ratio":1.9269062309154923,"monthly_win_rate":0.75,"equity_curve_r_squared":0.2568675902061873},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071957079892_9046f612","trade_count":58,"profit_factor":1.6628306811204845,"total_return_pct":1.4636686220428092,"win_rate":0.5689655172413793,"max_drawdown_pct":1.2630043081731899,"sharpe_ratio":2.0112049109753753,"monthly_win_rate":1.0,"equity_curve_r_squared":0.57291038217223}},"sqs_score":78.9,"sqs_breakdown":{"profitability":69.0,"risk":98.5,"consistency":86.5,"robustness":62.5},"verdict":"worse","verdict_reasoning":"Restoring more same-day long breadth weakened both splits versus step40: valid moved from SQS 68.4 to 69.7 but test fell from 82.1 to 78.9 and profitability dropped. The smaller 12.5% sleeve remains the better balance.","next_direction":"Keep the same-day long overlay small inside step40.","tags":["pead","midcap","step43","short","core","sdlong25"]} +{"entry_id":"IMP-0034","timestamp":"2026-03-17T07:20:29.978842+00:00","experiment_name":"pead_midcap_step40_short_core_sdlong12","hypothesis":"Drop the unstable after-close long sleeve and reallocate the portfolio to same-day shorts, after-close shorts, and a small same-day close-entry long overlay.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071756453145_9eb08c8d","trade_count":40,"profit_factor":1.483920817012022,"total_return_pct":0.8662763872782817,"win_rate":0.55,"max_drawdown_pct":0.6476993822793542,"sharpe_ratio":1.8482432920991685,"monthly_win_rate":0.75,"equity_curve_r_squared":0.19435203451054603},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071756505329_9eb08c8d","trade_count":55,"profit_factor":1.7695290624299977,"total_return_pct":1.52020169384827,"win_rate":0.5818181818181818,"max_drawdown_pct":1.128135882565315,"sharpe_ratio":2.2087400904317267,"monthly_win_rate":1.0,"equity_curve_r_squared":0.632820774620957},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071814970965_9eb08c8d","trade_count":306,"profit_factor":1.2169887676968005,"total_return_pct":3.708684730821959,"win_rate":0.4869281045751634,"max_drawdown_pct":2.5507043144769854,"sharpe_ratio":0.5720232080317771,"monthly_win_rate":0.625,"equity_curve_r_squared":0.39497202444939644}},"sqs_score":82.1,"sqs_breakdown":{"profitability":74.6,"risk":99.3,"consistency":88.6,"robustness":64.6},"verdict":"better","verdict_reasoning":"New robust leader. Train improved from SQS 55.5 to 63.1 and return +2.45% to +3.71%. Valid edged up from SQS 68.2 to 68.4 with lower drawdown, and test improved from SQS 77.9 to 82.1 with PF 1.77. Removing after-close longs fixed the biggest unstable sleeve without giving up the same-day long upside.","next_direction":"Use step40 as the new base. Only explore local refinements around the short-core structure if needed.","tags":["pead","midcap","step40","short","core","sdlong12"]} +{"entry_id":"IMP-0038","timestamp":"2026-03-17T07:54:25.486815+00:00","experiment_name":"pead_midcap_step44_short_core_macro50","hypothesis":"Scaling entries down in weak macro regimes will keep the short-core structure while cutting drawdowns.","config_delta":{"base_experiment":"pead_midcap_step40_short_core_sdlong12","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073536976331_9c3139ba","trade_count":40,"profit_factor":1.7786229517773076,"total_return_pct":1.0842382260887244,"win_rate":0.55,"max_drawdown_pct":0.4065245518278289,"sharpe_ratio":2.4891782517624543,"monthly_win_rate":0.75,"equity_curve_r_squared":0.34564834918762316},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073537015426_9c3139ba","trade_count":58,"profit_factor":2.0052763103391356,"total_return_pct":1.2986802302195721,"win_rate":0.5689655172413793,"max_drawdown_pct":0.7045335236824514,"sharpe_ratio":2.6217356101467053,"monthly_win_rate":1.0,"equity_curve_r_squared":0.7991432493330419},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073555963588_9c3139ba","trade_count":307,"profit_factor":1.2319887960741855,"total_return_pct":3.4232792009141852,"win_rate":0.4820846905537459,"max_drawdown_pct":1.97377752972113,"sharpe_ratio":0.6203457661608955,"monthly_win_rate":0.625,"equity_curve_r_squared":0.4148680874417292}},"sqs_score":87.9,"sqs_breakdown":{"profitability":85.2,"risk":100.0,"consistency":86.5,"robustness":76.6},"verdict":"better","verdict_reasoning":"Half-size macro scaling materially improved valid and test risk-adjusted performance versus step40, confirming that SPY-below-SMA exposure was a real drag.","next_direction":"Try a full macro block to see whether removing weak-regime entries entirely is even cleaner.","tags":["pead","midcap","step44","short","core","macro50"]} +{"entry_id":"IMP-0039","timestamp":"2026-03-17T07:54:25.866482+00:00","experiment_name":"pead_midcap_step45_short_core_macro_block","hypothesis":"If weak-regime entries are mostly noise, hard-blocking them should outperform simple size scaling.","config_delta":{"base_experiment":"pead_midcap_step44_short_core_macro50","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073536967478_c3a3615c","trade_count":28,"profit_factor":2.3084684930008983,"total_return_pct":1.30245295115927,"win_rate":0.6785714285714286,"max_drawdown_pct":0.37472483014430374,"sharpe_ratio":3.0487382658615467,"monthly_win_rate":0.75,"equity_curve_r_squared":0.48762905493056535},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073537015511_c3a3615c","trade_count":23,"profit_factor":3.7827916861128097,"total_return_pct":1.2021184808416436,"win_rate":0.6956521739130435,"max_drawdown_pct":0.2608177180219861,"sharpe_ratio":3.3795660622862123,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.9110928059216074},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073556022527_c3a3615c","trade_count":220,"profit_factor":1.2595187982631553,"total_return_pct":3.200633818982489,"win_rate":0.5045454545454545,"max_drawdown_pct":1.389607178327899,"sharpe_ratio":0.6336391727317187,"monthly_win_rate":0.6551724137931034,"equity_curve_r_squared":0.39958317262208615}},"sqs_score":86.7,"sqs_breakdown":{"profitability":84.8,"risk":100.0,"consistency":95.8,"robustness":57.2},"verdict":"better","verdict_reasoning":"The hard macro block improved valid and test again, with sharper PF and much lower drawdown than the 50% scaler version.","next_direction":"Stress the sleeve mix around the new macro-blocked core.","tags":["pead","midcap","step45","short","core","macro","block"]} +{"entry_id":"IMP-0040","timestamp":"2026-03-17T07:54:26.245073+00:00","experiment_name":"pead_midcap_step46_short_core_macro_block_acshort12","hypothesis":"The after-close short sleeve may be oversized after the macro block and could improve if reduced.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073936915792_e3adb886","trade_count":26,"profit_factor":2.4445635979938296,"total_return_pct":1.339314216731771,"win_rate":0.6923076923076923,"max_drawdown_pct":0.37458871743417604,"sharpe_ratio":3.1583041871985076,"monthly_win_rate":0.75,"equity_curve_r_squared":0.48286315791941375},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073939814431_e3adb886","trade_count":21,"profit_factor":4.522043594902001,"total_return_pct":1.2518263219734362,"win_rate":0.7142857142857143,"max_drawdown_pct":0.2783619920052574,"sharpe_ratio":3.6389410132883055,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.9244229299732519}},"sqs_score":86.6,"sqs_breakdown":{"profitability":85.0,"risk":100.0,"consistency":95.8,"robustness":56.1},"verdict":"worse","verdict_reasoning":"Shrinking the after-close short sleeve slightly degraded both valid and test, so the step45 25% sleeve was not the problem.","next_direction":"Test whether the same-day long sleeve or the short-only core is the real source of edge.","tags":["pead","midcap","step46","short","core","macro","block","acshort12"]} +{"entry_id":"IMP-0041","timestamp":"2026-03-17T07:54:26.612610+00:00","experiment_name":"pead_midcap_step47_short_core_macro_block_sdlong25","hypothesis":"A larger same-day reaction-close long overlay might scale once the macro block removes bad regimes.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073936910739_a38d69e4","trade_count":31,"profit_factor":1.7661839600751525,"total_return_pct":1.03177071332802,"win_rate":0.6451612903225806,"max_drawdown_pct":0.42599257212123065,"sharpe_ratio":2.218739891960781,"monthly_win_rate":0.75,"equity_curve_r_squared":0.4214549671036421},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073939814560_a38d69e4","trade_count":25,"profit_factor":3.429404855633068,"total_return_pct":1.2833562667310616,"win_rate":0.68,"max_drawdown_pct":0.2608177180219861,"sharpe_ratio":3.32348066596022,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.889835319415119}},"sqs_score":87.0,"sqs_breakdown":{"profitability":85.1,"risk":100.0,"consistency":95.8,"robustness":58.3},"verdict":"worse","verdict_reasoning":"Increasing the same-day long sleeve hurt valid materially and did not produce a cleaner overall profile than step45.","next_direction":"Try sleeve removal experiments instead of scaling overlays up.","tags":["pead","midcap","step47","short","core","macro","block","sdlong25"]} +{"entry_id":"IMP-0042","timestamp":"2026-03-17T07:54:26.967747+00:00","experiment_name":"pead_midcap_step48_short_core_macro_block_nolong","hypothesis":"The macro-blocked short core may be strong enough without the same-day long overlay.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074234188388_d9b28f40","trade_count":20,"profit_factor":5.726207009868067,"total_return_pct":1.2584927752282966,"win_rate":0.7,"max_drawdown_pct":0.29982153086364316,"sharpe_ratio":3.3529664036078217,"monthly_win_rate":1.0,"equity_curve_r_squared":0.4654938970631658},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074234267904_d9b28f40","trade_count":20,"profit_factor":3.402559985815088,"total_return_pct":0.8451446297187069,"win_rate":0.8,"max_drawdown_pct":0.4239007555767844,"sharpe_ratio":2.4413021645869604,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.7861833255279326}},"sqs_score":85.7,"sqs_breakdown":{"profitability":83.4,"risk":100.0,"consistency":95.8,"robustness":54.7},"verdict":"worse","verdict_reasoning":"Removing the long sleeve weakened both valid and test relative to step45, so the small same-day long overlay still adds useful diversification.","next_direction":"Test whether the same-day short sleeve can stand alone or whether the after-close short sleeve is also required.","tags":["pead","midcap","step48","short","core","macro","block","nolong"]} +{"entry_id":"IMP-0043","timestamp":"2026-03-17T07:54:27.322093+00:00","experiment_name":"pead_midcap_step49_same_day_short_macro_block","hypothesis":"The pure same-day short engine might dominate the portfolio and make other sleeves unnecessary.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074233919071_b5cae2ca","trade_count":12,"profit_factor":25.094599474518425,"total_return_pct":1.1648266582814248,"win_rate":0.75,"max_drawdown_pct":0.33436592553948014,"sharpe_ratio":3.227317987298049,"monthly_win_rate":1.0,"equity_curve_r_squared":0.3679821567954865},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074234357252_b5cae2ca","trade_count":10,"profit_factor":2.4025420210691006,"total_return_pct":0.40093684044296973,"win_rate":0.7,"max_drawdown_pct":0.3693400619520335,"sharpe_ratio":1.5580313035787354,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.3032092765977471}},"sqs_score":38.9,"sqs_breakdown":{"profitability":81.6,"risk":92.6,"consistency":95.8,"robustness":19.0},"verdict":"worse","verdict_reasoning":"The single-sleeve version collapsed SQS because trade count and robustness fell too far, even though the kept trades were profitable.","next_direction":"Keep the supporting sleeves and test smaller structural adjustments instead.","tags":["pead","midcap","step49","same","day","short","macro","block"]} +{"entry_id":"IMP-0044","timestamp":"2026-03-17T07:54:27.691772+00:00","experiment_name":"pead_midcap_step50_same_day_short_long_macro_block","hypothesis":"The same-day long overlay may matter, but the after-close short sleeve may be removable.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074234172662_a01fa9b8","trade_count":20,"profit_factor":2.2051399980357798,"total_return_pct":0.9530265960178512,"win_rate":0.65,"max_drawdown_pct":0.3444323377542605,"sharpe_ratio":2.687864694846263,"monthly_win_rate":0.75,"equity_curve_r_squared":0.41775889730721794},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074234263930_a01fa9b8","trade_count":15,"profit_factor":3.2060142950020296,"total_return_pct":0.805535692316771,"win_rate":0.6,"max_drawdown_pct":0.42315703199236054,"sharpe_ratio":2.6871013818111624,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.8242465440616173}},"sqs_score":41.9,"sqs_breakdown":{"profitability":83.2,"risk":100.0,"consistency":87.5,"robustness":52.8},"verdict":"worse","verdict_reasoning":"Dropping the after-close short sleeve reduced both valid and test performance, so step45 still benefits from carrying all three active sleeves.","next_direction":"Refine sleeve quality rather than deleting sleeves wholesale.","tags":["pead","midcap","step50","same","day","short","long","macro","block"]} +{"entry_id":"IMP-0045","timestamp":"2026-03-17T07:54:28.043618+00:00","experiment_name":"pead_midcap_step51_short_core_macro_block_crashcap","hypothesis":"Extreme one-day crash continuations are too stretched for the same-day short sleeve and should be excluded.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074801780500_1585063f","trade_count":28,"profit_factor":2.3084684930008983,"total_return_pct":1.30245295115927,"win_rate":0.6785714285714286,"max_drawdown_pct":0.37472483014430374,"sharpe_ratio":3.0487382658615467,"monthly_win_rate":0.75,"equity_curve_r_squared":0.48762905493056535},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074801781828_1585063f","trade_count":22,"profit_factor":4.190184861108528,"total_return_pct":1.2441182731003355,"win_rate":0.7272727272727273,"max_drawdown_pct":0.22380328257556925,"sharpe_ratio":3.5956143566120704,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.918016321908289},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074932001383_1585063f","trade_count":220,"profit_factor":1.2595187982631553,"total_return_pct":3.200633818982489,"win_rate":0.5045454545454545,"max_drawdown_pct":1.389607178327899,"sharpe_ratio":0.6336391727317187,"monthly_win_rate":0.6551724137931034,"equity_curve_r_squared":0.39958317262208615}},"sqs_score":86.7,"sqs_breakdown":{"profitability":85.0,"risk":100.0,"consistency":95.8,"robustness":56.7},"verdict":"better","verdict_reasoning":"Capping same-day shorts at -45% reaction preserved train and valid while modestly improving test return, PF, drawdown, and Sharpe versus step45.","next_direction":"Combine the crash cap with a quality filter on the same-day long overlay.","tags":["pead","midcap","step51","short","core","macro","block","crashcap"]} +{"entry_id":"IMP-0046","timestamp":"2026-03-17T07:54:28.401055+00:00","experiment_name":"pead_midcap_step52_short_core_macro_block_crashcap_gap10","hypothesis":"The same-day long overlay may work better when restricted to larger reaction-day gap moves.","config_delta":{"base_experiment":"pead_midcap_step51_short_core_macro_block_crashcap","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317075123185892_7592b1dc","trade_count":25,"profit_factor":5.660464878695176,"total_return_pct":1.818984312375629,"win_rate":0.72,"max_drawdown_pct":0.21695852738272095,"sharpe_ratio":4.755652871139783,"monthly_win_rate":1.0,"equity_curve_r_squared":0.6608034205824956},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317075123226004_7592b1dc","trade_count":22,"profit_factor":3.351800408128271,"total_return_pct":1.0111883417758072,"win_rate":0.7727272727272727,"max_drawdown_pct":0.24257912061402945,"sharpe_ratio":3.031867507475822,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.8468990955221466},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317075225071209_7592b1dc","trade_count":201,"profit_factor":1.3153549627707726,"total_return_pct":3.2988485556152156,"win_rate":0.4975124378109453,"max_drawdown_pct":1.5599163185209133,"sharpe_ratio":0.683194016870928,"monthly_win_rate":0.6923076923076923,"equity_curve_r_squared":0.7186047701175057}},"sqs_score":86.3,"sqs_breakdown":{"profitability":84.0,"risk":100.0,"consistency":95.8,"robustness":56.7},"verdict":"neutral","verdict_reasoning":"A 10% gap filter made train and valid much stronger but gave back some test performance, so this is a balanced alternative rather than a clear new leader.","next_direction":"If optimizing for robustness across splits, keep exploring overlay quality gates around this variant.","tags":["pead","midcap","step52","short","core","macro","block","crashcap","gap10"]} +{"entry_id":"IMP-0047","timestamp":"2026-03-17T07:54:28.762757+00:00","experiment_name":"pead_midcap_step53_short_core_macro_block_crashcap_gap14","hypothesis":"A stricter same-day long gap filter might further concentrate the overlay into only the strongest continuation setups.","config_delta":{"base_experiment":"pead_midcap_step52_short_core_macro_block_crashcap_gap10","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317075123208169_66d09993","trade_count":25,"profit_factor":8.207760543782888,"total_return_pct":2.0180349316014032,"win_rate":0.76,"max_drawdown_pct":0.2179161262122437,"sharpe_ratio":5.288977955005757,"monthly_win_rate":1.0,"equity_curve_r_squared":0.6385560108285373},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317075123225576_66d09993","trade_count":19,"profit_factor":4.599773676047558,"total_return_pct":1.1191525844285641,"win_rate":0.8421052631578947,"max_drawdown_pct":0.21947031826165894,"sharpe_ratio":3.463700507402551,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.8852420626052896}},"sqs_score":43.1,"sqs_breakdown":{"profitability":84.5,"risk":100.0,"consistency":95.8,"robustness":55.0},"verdict":"worse","verdict_reasoning":"The stricter gap filter over-concentrated the overlay, dropped total trade count below a healthy level, and cratered test SQS.","next_direction":"Use moderate overlay filters only; the strict version is too sparse.","tags":["pead","midcap","step53","short","core","macro","block","crashcap","gap14"]} diff --git a/libs/backtest/allocator.py b/libs/backtest/allocator.py index 56c92fb..6c40473 100644 --- a/libs/backtest/allocator.py +++ b/libs/backtest/allocator.py @@ -99,6 +99,7 @@ def run_entry_gates( config: BacktestConfig, cooldown_remaining: int = 0, macro_data: dict[str, Any] | None = None, + engine_daily_new_risk_used: float = 0.0, ) -> str | None: """Run entry gates. Returns skip_reason string or None (pass). @@ -151,6 +152,12 @@ def run_entry_gates( if portfolio_state.daily_new_risk_used + trade_risk > daily_budget: return "daily_risk_budget" + engine_budget = daily_budget * candidate.engine_risk_budget_pct + if engine_budget <= 0: + return "engine_daily_risk_budget" + if engine_daily_new_risk_used + trade_risk > engine_budget: + return "engine_daily_risk_budget" + # Gate 6: Cash available (estimate position cost) stop_price = compute_stop_price(candidate, config.risk) est_shares = compute_shares( @@ -207,11 +214,13 @@ def build_planned_order( config: BacktestConfig, cooldown_remaining: int = 0, macro_data: dict[str, Any] | None = None, + engine_daily_new_risk_used: float = 0.0, ) -> PlannedOrder: """Build a PlannedOrder. skip_reason is non-None if any gate rejected it.""" skip_reason = run_entry_gates( candidate, portfolio_state, open_positions, config, cooldown_remaining, macro_data=macro_data, + engine_daily_new_risk_used=engine_daily_new_risk_used, ) # Apply event-type-specific overrides for stop/target ATR multipliers @@ -277,5 +286,10 @@ def build_planned_order( stop_price=stop_price, target_price=target_price, risk_dollars=risk_dollars, + event_date=candidate.event_date, + timing_class=candidate.timing_class, + engine_id=candidate.engine_id, + entry_timing_policy=candidate.entry_timing_policy, + shadow_only=candidate.shadow_only, skip_reason=skip_reason, ) diff --git a/libs/backtest/artifacts.py b/libs/backtest/artifacts.py index 67351a3..765258e 100644 --- a/libs/backtest/artifacts.py +++ b/libs/backtest/artifacts.py @@ -83,6 +83,11 @@ def write_trade_blotter( "position_id": t.position_id, "event_id": t.event_id, "symbol": t.symbol, + "event_date": t.event_date.isoformat() if t.event_date else None, + "timing_class": t.timing_class, + "engine_id": t.engine_id, + "entry_timing_policy": t.entry_timing_policy, + "shadow_only": t.shadow_only, "entry_date": t.entry_date.isoformat(), "exit_date": t.exit_date.isoformat(), "entry_price": t.entry_price, @@ -141,14 +146,19 @@ def write_position_timeline( rows = [] for t in trades: rows.append( - { - "position_id": t.position_id, - "event_id": t.event_id, - "symbol": t.symbol, - "entry_date": t.entry_date.isoformat(), - "exit_date": t.exit_date.isoformat(), - "entry_price": t.entry_price, - "exit_price": t.exit_price, + { + "position_id": t.position_id, + "event_id": t.event_id, + "symbol": t.symbol, + "event_date": t.event_date.isoformat() if t.event_date else None, + "timing_class": t.timing_class, + "engine_id": t.engine_id, + "entry_timing_policy": t.entry_timing_policy, + "shadow_only": t.shadow_only, + "entry_date": t.entry_date.isoformat(), + "exit_date": t.exit_date.isoformat(), + "entry_price": t.entry_price, + "exit_price": t.exit_price, "exit_reason": t.exit_reason.value, "shares": t.shares, "net_pnl": t.net_pnl, @@ -164,6 +174,11 @@ def write_position_timeline( "position_id": p.position_id, "event_id": p.plan.candidate.event_id, "symbol": p.plan.candidate.symbol, + "event_date": p.plan.event_date.isoformat() if p.plan.event_date else None, + "timing_class": p.plan.timing_class, + "engine_id": p.plan.engine_id, + "entry_timing_policy": p.plan.entry_timing_policy, + "shadow_only": p.plan.shadow_only, "entry_date": p.entry_date.isoformat(), "exit_date": None, "entry_price": p.entry_price, @@ -265,6 +280,65 @@ def write_attribution_by_sector( return out +def write_attribution_by_engine( + run_dir: Path, + trades: list[FilledTrade], + per_engine_metrics: dict[str, dict[str, Any]] | None = None, +) -> Path: + """Write metrics/attribution_by_engine.csv.""" + bucket_data: dict[str, dict[str, float | int | bool]] = defaultdict( + lambda: {"count": 0, "wins": 0, "net_pnl": 0.0, "_r_sum": 0.0, "shadow_only": False} + ) + for t in trades: + engine_id = t.engine_id or "default" + d = bucket_data[engine_id] + d["count"] = int(d["count"]) + 1 + if t.net_pnl > 0: + d["wins"] = int(d["wins"]) + 1 + d["net_pnl"] = float(d["net_pnl"]) + t.net_pnl + d["_r_sum"] = float(d["_r_sum"]) + t.r_multiple + d["shadow_only"] = bool(t.shadow_only) + + if per_engine_metrics: + for engine_id, summary in per_engine_metrics.items(): + d = bucket_data.setdefault( + engine_id, + {"count": 0, "wins": 0, "net_pnl": 0.0, "_r_sum": 0.0, "shadow_only": False}, + ) + d["shadow_only"] = bool(summary.get("shadow_only", d["shadow_only"])) + + out = run_dir / "metrics" / "attribution_by_engine.csv" + with open(out, "w", newline="") as f: + writer = csv.DictWriter( + f, + fieldnames=[ + "engine_id", + "shadow_only", + "count", + "wins", + "win_rate", + "net_pnl", + "avg_r", + ], + ) + writer.writeheader() + for engine_id, d in sorted(bucket_data.items()): + count = int(d["count"]) + wins = int(d["wins"]) + writer.writerow( + { + "engine_id": engine_id, + "shadow_only": bool(d["shadow_only"]), + "count": count, + "wins": wins, + "win_rate": wins / count if count > 0 else 0.0, + "net_pnl": round(float(d["net_pnl"]), 4), + "avg_r": round(float(d["_r_sum"]) / count if count > 0 else 0.0, 4), + } + ) + return out + + def write_score_bucket_report( run_dir: Path, score_bucket_hit_rate: dict[str, float], @@ -312,6 +386,16 @@ def write_run_notes(run_dir: Path, notes: str = "") -> Path: return out +def write_per_engine_metrics( + run_dir: Path, + per_engine_metrics: dict[str, dict[str, Any]], +) -> Path: + """Write metrics/per_engine_metrics.json.""" + out = run_dir / "metrics" / "per_engine_metrics.json" + out.write_text(json.dumps(per_engine_metrics, indent=2, default=str)) + return out + + def write_all_artifacts( run_dir: Path, run_id: str, @@ -329,6 +413,7 @@ def write_all_artifacts( total_candidates_seen: int, total_orders_rejected: int, split_name: str | None = None, + per_engine_metrics: dict[str, dict[str, Any]] | None = None, ) -> dict[str, str]: """Write all output files. Returns mapping of artifact_name → file_path.""" from libs.backtest.manifests import save_manifest, save_resolved_config @@ -355,11 +440,16 @@ def write_all_artifacts( paths["attribution_by_sector"] = str( write_attribution_by_sector(run_dir, trades, candidate_map) ) + paths["attribution_by_engine"] = str( + write_attribution_by_engine(run_dir, trades, per_engine_metrics) + ) paths["score_bucket_report"] = str( write_score_bucket_report( run_dir, metrics.score_bucket_hit_rate, trades, candidate_map ) ) + if per_engine_metrics: + paths["per_engine_metrics"] = str(write_per_engine_metrics(run_dir, per_engine_metrics)) # Trade data if config.reporting.write_trade_blotter: diff --git a/libs/backtest/domain.py b/libs/backtest/domain.py index b5b40b9..707660d 100644 --- a/libs/backtest/domain.py +++ b/libs/backtest/domain.py @@ -44,13 +44,20 @@ class Candidate(BaseModel): sector: str # "UNKNOWN" if unavailable event_type: str event_timestamp: dt.datetime # must be timezone-aware + event_date: dt.date | None = None filing_time_bucket: str + timing_class: str = "unknown" # "same_day", "after_close", "unknown" reaction_date: dt.date execution_date: dt.date # mapped from Parquet entry_date at SnapshotStore boundary entry_price_est: float # reaction-day close price avg_dollar_volume: float # 20-day mean(volume * close) atr_14: float | None = None score_bucket: str + engine_id: str = "default" + entry_timing_policy: str = "next_open" + shadow_only: bool = False + engine_max_holding_days: int | None = None + engine_risk_budget_pct: float = 1.0 trade_direction: str = "long" # "long" or "short" features: dict[str, Any] = Field(default_factory=dict) @@ -65,6 +72,11 @@ class PlannedOrder(BaseModel): stop_price: float target_price: float risk_dollars: float + event_date: dt.date | None = None + timing_class: str = "unknown" + engine_id: str = "default" + entry_timing_policy: str = "next_open" + shadow_only: bool = False skip_reason: str | None = None # non-None means the order was rejected @@ -76,6 +88,11 @@ class FilledTrade(BaseModel): position_id: str event_id: str symbol: str + event_date: dt.date | None = None + timing_class: str = "unknown" + engine_id: str = "default" + entry_timing_policy: str = "next_open" + shadow_only: bool = False entry_date: dt.date exit_date: dt.date entry_price: float @@ -227,6 +244,27 @@ class ExecutionConfig(BaseModel): no_follow_through_exit: bool = False # exit at D+1 close if close < entry price +class StrategyEngineConfig(BaseModel): + """Specialist engine routing and execution policy.""" + + engine_id: str + event_types: list[str] = Field(default_factory=list) + timing_class: str = "any" # "same_day", "after_close", "any" + direction: str = "any" # "long_only", "short_only", "any" + entry_timing_policy: str = "next_open" # "next_open", "reaction_close" + max_holding_days: int | None = None + engine_risk_budget_pct: float = 1.0 + score_threshold_override: float | None = None + pead_reaction_threshold_override: float | None = None + pead_volume_threshold_override: float | None = None + reaction_day_return_min: float | None = None + reaction_day_return_max: float | None = None + gap_size_min: float | None = None + gap_size_max: float | None = None + shadow_only: bool = False + enabled: bool = True + + class EventTypeProfile(BaseModel): """Per-event-type overrides for scoring, risk, and exit parameters.""" enabled: bool = True @@ -254,11 +292,25 @@ class BacktestConfig(BaseModel): execution: ExecutionConfig = Field(default_factory=ExecutionConfig) reporting: ReportingConfig = Field(default_factory=ReportingConfig) event_type_profiles: dict[str, EventTypeProfile] = Field(default_factory=dict) + strategy_engines: list[StrategyEngineConfig] = Field(default_factory=list) + strategy_engine_selection_mode: str = "interleave" # "interleave" or "global_score" def get_event_profile(self, event_type: str) -> EventTypeProfile | None: """Look up event-type-specific profile. Returns None if no override.""" return self.event_type_profiles.get(event_type) + def get_strategy_engines(self) -> list[StrategyEngineConfig]: + """Enabled strategy engines in manifest order.""" + return [engine for engine in self.strategy_engines if engine.enabled] + + def get_active_strategy_engines(self) -> list[StrategyEngineConfig]: + """Enabled engines that participate in the live portfolio.""" + return [engine for engine in self.get_strategy_engines() if not engine.shadow_only] + + def get_shadow_strategy_engines(self) -> list[StrategyEngineConfig]: + """Enabled engines that run in paper/shadow mode only.""" + return [engine for engine in self.get_strategy_engines() if engine.shadow_only] + # --------------------------------------------------------------------------- # Experiment models @@ -275,6 +327,7 @@ class ExperimentManifest(BaseModel): description: str | None = None base_config: str # path to base config JSON file overrides: dict[str, Any] = Field(default_factory=dict) + strategy_engines: list[StrategyEngineConfig] = Field(default_factory=list) splits: list[SplitSpec] = Field(default_factory=list) tags: list[str] = Field(default_factory=list) notes: str | None = None diff --git a/libs/backtest/execution.py b/libs/backtest/execution.py index 792a8ce..b0e3b26 100644 --- a/libs/backtest/execution.py +++ b/libs/backtest/execution.py @@ -55,20 +55,15 @@ def simulate_entry( config: ExecutionConfig, position_id: str | None = None, ) -> OpenPosition | None: - """Simulate filling a planned entry at the bar's open. + """Simulate filling a planned entry at the bar's open or reaction close. - Returns None (position NOT opened) if bar is missing or open is invalid. + Returns None (position NOT opened) if bar is missing or the required price is invalid. No zero imputation — missing bar = no entry. """ if bar is None: logger.warning("entry_skip_missing_bar", event_id=plan.candidate.event_id) return None - bar_open = bar.get("open") - if bar_open is None or bar_open <= 0: - logger.warning("entry_skip_invalid_open", event_id=plan.candidate.event_id, bar=bar) - return None - if plan.skip_reason is not None: logger.debug("entry_skip_gate_rejected", reason=plan.skip_reason) return None @@ -77,12 +72,24 @@ def simulate_entry( logger.warning("entry_skip_zero_shares", event_id=plan.candidate.event_id) return None + entry_policy = plan.entry_timing_policy or "next_open" + if entry_policy == "reaction_close": + reference_price = bar.get("close") + if reference_price is None or reference_price <= 0: + logger.warning("entry_skip_invalid_close", event_id=plan.candidate.event_id, bar=bar) + return None + else: + reference_price = bar.get("open") + if reference_price is None or reference_price <= 0: + logger.warning("entry_skip_invalid_open", event_id=plan.candidate.event_id, bar=bar) + return None + is_short = plan.candidate.trade_direction == "short" if is_short: - fill_price = _short_entry_fill(float(bar_open), config.slippage_bps_base) + fill_price = _short_entry_fill(float(reference_price), config.slippage_bps_base) else: - fill_price = _long_entry_fill(float(bar_open), config.slippage_bps_base) - slippage_bps_actual = abs(fill_price / float(bar_open) - 1.0) * 10_000 + fill_price = _long_entry_fill(float(reference_price), config.slippage_bps_base) + slippage_bps_actual = abs(fill_price / float(reference_price) - 1.0) * 10_000 pid = position_id or str(uuid.uuid4()) @@ -370,6 +377,11 @@ def _build_filled_trade_partial( position_id=position.position_id, event_id=position.plan.candidate.event_id, symbol=position.plan.candidate.symbol, + event_date=position.plan.event_date or position.plan.candidate.event_date, + timing_class=position.plan.timing_class, + engine_id=position.plan.engine_id, + entry_timing_policy=position.plan.entry_timing_policy, + shadow_only=position.plan.shadow_only, entry_date=position.entry_date, exit_date=exit_date, entry_price=position.entry_price, @@ -424,6 +436,11 @@ def _build_filled_trade( position_id=position.position_id, event_id=position.plan.candidate.event_id, symbol=position.plan.candidate.symbol, + event_date=position.plan.event_date or position.plan.candidate.event_date, + timing_class=position.plan.timing_class, + engine_id=position.plan.engine_id, + entry_timing_policy=position.plan.entry_timing_policy, + shadow_only=position.plan.shadow_only, entry_date=position.entry_date, exit_date=exit_date, entry_price=position.entry_price, diff --git a/libs/backtest/manifests.py b/libs/backtest/manifests.py index fbc75af..292e47c 100644 --- a/libs/backtest/manifests.py +++ b/libs/backtest/manifests.py @@ -66,6 +66,11 @@ def resolve_config( # Inject snapshot_id sid = snapshot_id_override or manifest.dataset_snapshot_id merged["dataset_snapshot_id"] = sid + if manifest.strategy_engines: + merged["strategy_engines"] = [ + engine.model_dump(mode="json") + for engine in manifest.strategy_engines + ] return BacktestConfig.model_validate(merged) @@ -82,11 +87,11 @@ def generate_run_id( ) -> str: """Generate a unique, deterministic run ID. - Format: bt_{safe_strategy}_{safe_snapshot[:12]}_{timestamp}_{config_hash[:8]} + Format: bt_{safe_strategy}_{safe_snapshot[:12]}_{timestamp_us}_{config_hash[:8]} """ strategy = _safe_slug(strategy_override or config.strategy_name) snapshot = _safe_slug(config.dataset_snapshot_id, max_len=12) - timestamp = utc_now().strftime("%Y%m%d%H%M%S") + timestamp = utc_now().strftime("%Y%m%d%H%M%S%f") config_json = config.model_dump_json(indent=None) config_hash = sha256_checksum_str(config_json)[:8] return f"bt_{strategy}_{snapshot}_{timestamp}_{config_hash}" diff --git a/libs/backtest/selector.py b/libs/backtest/selector.py index 6bb83f4..474140e 100644 --- a/libs/backtest/selector.py +++ b/libs/backtest/selector.py @@ -5,14 +5,23 @@ import datetime as dt from typing import Any from zoneinfo import ZoneInfo -from libs.backtest.domain import Candidate, EventTypeProfile, SignalConfig, UniverseConfig +from libs.backtest.domain import ( + Candidate, + EventTypeProfile, + SignalConfig, + StrategyEngineConfig, + UniverseConfig, +) from libs.common.logging import get_logger logger = get_logger(__name__) _UTC = ZoneInfo("UTC") -def build_candidate(row: dict[str, Any]) -> Candidate | None: +def build_candidate( + row: dict[str, Any], + strategy_engine: StrategyEngineConfig | None = None, +) -> Candidate | None: """Build a Candidate from a raw Parquet row dict. Returns None (logged as skip) if: @@ -44,8 +53,49 @@ def build_candidate(row: dict[str, Any]) -> Candidate | None: if event_timestamp.tzinfo is None: event_timestamp = event_timestamp.replace(tzinfo=_UTC) - # entry_price_est (mapped from Parquet entry_price) - entry_price_est = row.get("entry_price") or row.get("entry_price_est") + # reaction_date + raw_react = row.get("reaction_date") + reaction_date = _parse_date(raw_react) + + # execution_date (mapped from Parquet entry_date) or reaction date for close-entry engines + raw_exec_date = row.get("execution_date") or row.get("entry_date") + execution_date = _parse_date(raw_exec_date) + if execution_date is None: + if strategy_engine and strategy_engine.entry_timing_policy == "reaction_close": + execution_date = reaction_date + else: + logger.warning("skip_candidate_no_exec_date", event_id=event_id) + return None + + if reaction_date is None: + reaction_date = execution_date + + event_date = _parse_date(row.get("event_date")) or event_timestamp.date() + timing_class = _classify_timing_class(event_date, reaction_date) + + trade_direction = _resolve_trade_direction(row) + if strategy_engine is not None and not _matches_strategy_engine( + row=row, + strategy_engine=strategy_engine, + event_type=str(row.get("event_type", "")), + timing_class=timing_class, + trade_direction=trade_direction, + ): + return None + + if strategy_engine and strategy_engine.entry_timing_policy == "reaction_close": + execution_date = reaction_date + entry_price_est = row.get("event_close") or row.get("entry_price_est") + if not entry_price_est: + logger.debug( + "skip_candidate_no_event_close", + event_id=event_id, + engine_id=strategy_engine.engine_id, + ) + return None + else: + entry_price_est = row.get("entry_price") or row.get("entry_price_est") + if not entry_price_est: logger.warning("skip_candidate_no_entry_price", event_id=event_id) return None @@ -54,28 +104,6 @@ def build_candidate(row: dict[str, Any]) -> Candidate | None: logger.warning("skip_candidate_zero_entry_price", event_id=event_id) return None - # execution_date (mapped from Parquet entry_date) - raw_exec_date = row.get("execution_date") or row.get("entry_date") - if raw_exec_date is None: - logger.warning("skip_candidate_no_exec_date", event_id=event_id) - return None - if isinstance(raw_exec_date, str): - execution_date = dt.date.fromisoformat(raw_exec_date) - elif isinstance(raw_exec_date, dt.date): - execution_date = raw_exec_date - else: - logger.warning("skip_candidate_bad_exec_date", event_id=event_id) - return None - - # reaction_date - raw_react = row.get("reaction_date") - if isinstance(raw_react, str): - reaction_date = dt.date.fromisoformat(raw_react) - elif isinstance(raw_react, dt.date): - reaction_date = raw_react - else: - reaction_date = execution_date # fallback: same as execution - score = float(row.get("score", 0.0)) avg_dollar_volume = float(row.get("avg_dollar_volume", 0.0)) atr_14_raw = row.get("atr_14") @@ -84,8 +112,6 @@ def build_candidate(row: dict[str, Any]) -> Candidate | None: # Classify score bucket score_bucket = _classify_score_bucket(score) - trade_direction = str(row.get("trade_direction", "long")) - return Candidate( event_id=event_id, symbol=str(row.get("symbol", row.get("ticker", ""))), @@ -94,13 +120,32 @@ def build_candidate(row: dict[str, Any]) -> Candidate | None: sector=str(row.get("sector") or "UNKNOWN"), event_type=str(row.get("event_type", "")), event_timestamp=event_timestamp, + event_date=event_date, filing_time_bucket=str(row.get("filing_time_bucket", "unknown")), + timing_class=timing_class, reaction_date=reaction_date, execution_date=execution_date, entry_price_est=entry_price_est, avg_dollar_volume=avg_dollar_volume, atr_14=atr_14, score_bucket=score_bucket, + engine_id=strategy_engine.engine_id if strategy_engine else "default", + entry_timing_policy=( + strategy_engine.entry_timing_policy + if strategy_engine + else "next_open" + ), + shadow_only=strategy_engine.shadow_only if strategy_engine else False, + engine_max_holding_days=( + strategy_engine.max_holding_days + if strategy_engine + else None + ), + engine_risk_budget_pct=( + strategy_engine.engine_risk_budget_pct + if strategy_engine + else 1.0 + ), trade_direction=trade_direction, features={k: v for k, v in row.items() if k not in _RESERVED_KEYS}, ) @@ -108,12 +153,104 @@ def build_candidate(row: dict[str, Any]) -> Candidate | None: _RESERVED_KEYS = { "event_id", "symbol", "ticker", "issuer_id", "score", "sector", - "event_type", "event_timestamp", "filing_time_bucket", "reaction_date", + "event_type", "event_timestamp", "event_date", "filing_time_bucket", "reaction_date", "entry_date", "execution_date", "entry_price", "entry_price_est", - "avg_dollar_volume", "atr_14", "score_bucket", "trade_direction", + "event_close", "avg_dollar_volume", "atr_14", "score_bucket", "trade_direction", } +def _parse_date(raw: Any) -> dt.date | None: + if isinstance(raw, dt.datetime): + return raw.date() + if isinstance(raw, dt.date): + return raw + if isinstance(raw, str): + try: + return dt.date.fromisoformat(raw) + except ValueError: + return None + return None + + +def _classify_timing_class(event_date: dt.date | None, reaction_date: dt.date) -> str: + if event_date is None: + return "unknown" + if reaction_date == event_date: + return "same_day" + if reaction_date > event_date: + return "after_close" + return "unknown" + + +def _resolve_trade_direction(row: dict[str, Any]) -> str: + raw_direction = str(row.get("trade_direction", "")).lower() + if raw_direction in {"long", "short"}: + return raw_direction + + reaction = row.get("reaction_day_return") + if reaction is not None: + try: + return "short" if float(reaction) < 0 else "long" + except (TypeError, ValueError): + pass + return "long" + + +def _matches_strategy_engine( + row: dict[str, Any], + strategy_engine: StrategyEngineConfig, + event_type: str, + timing_class: str, + trade_direction: str, +) -> bool: + if strategy_engine.event_types and event_type not in strategy_engine.event_types: + return False + + if strategy_engine.timing_class != "any" and timing_class != strategy_engine.timing_class: + return False + + if strategy_engine.direction == "long_only" and trade_direction != "long": + return False + if strategy_engine.direction == "short_only" and trade_direction != "short": + return False + + reaction_day_return = _safe_float(row.get("reaction_day_return")) + if ( + strategy_engine.reaction_day_return_min is not None + and reaction_day_return is not None + and reaction_day_return < strategy_engine.reaction_day_return_min + ): + return False + if ( + strategy_engine.reaction_day_return_max is not None + and reaction_day_return is not None + and reaction_day_return > strategy_engine.reaction_day_return_max + ): + return False + + gap_size = _safe_float(row.get("gap_size")) + if ( + strategy_engine.gap_size_min is not None + and gap_size is not None + and gap_size < strategy_engine.gap_size_min + ): + return False + if ( + strategy_engine.gap_size_max is not None + and gap_size is not None + and gap_size > strategy_engine.gap_size_max + ): + return False + + if ( + strategy_engine.entry_timing_policy == "reaction_close" + and row.get("event_close") in (None, 0, 0.0, "") + ): + return False + + return True + + def _classify_score_bucket(score: float) -> str: if score >= 0.8: return "high" @@ -126,6 +263,13 @@ def _classify_score_bucket(score: float) -> str: return "low" +def _safe_float(raw: Any) -> float | None: + try: + return float(raw) + except (TypeError, ValueError): + return None + + def rank_candidates(candidates: list[Candidate]) -> list[Candidate]: """Sort by score DESC, avg_dollar_volume DESC, symbol ASC (stable, deterministic).""" return sorted(candidates, key=lambda c: (-c.score, -c.avg_dollar_volume, c.symbol)) @@ -199,18 +343,72 @@ def select_candidates( universe_config: UniverseConfig, signal_config: SignalConfig, event_type_profiles: dict[str, EventTypeProfile] | None = None, + strategy_engine: StrategyEngineConfig | None = None, ) -> list[Candidate]: """Full selection pipeline: build → filter → rank → truncate.""" candidates = [] for row in raw_rows: - c = build_candidate(row) + prepared_row = _prepare_row_for_strategy_engine( + row, + signal_config=signal_config, + strategy_engine=strategy_engine, + ) + c = build_candidate(prepared_row, strategy_engine=strategy_engine) if c is not None: candidates.append(c) candidates = filter_by_universe(candidates, universe_config) - candidates = filter_by_score(candidates, signal_config.score_threshold) + candidates = filter_by_score( + candidates, + _resolve_score_threshold(signal_config, strategy_engine), + ) if event_type_profiles: candidates = filter_by_event_type(candidates, event_type_profiles) candidates = rank_candidates(candidates) candidates = truncate_candidates(candidates, signal_config.max_candidates_per_day) return candidates + + +def _resolve_score_threshold( + signal_config: SignalConfig, + strategy_engine: StrategyEngineConfig | None, +) -> float: + if strategy_engine and strategy_engine.score_threshold_override is not None: + return strategy_engine.score_threshold_override + return signal_config.score_threshold + + +def _prepare_row_for_strategy_engine( + row: dict[str, Any], + signal_config: SignalConfig, + strategy_engine: StrategyEngineConfig | None, +) -> dict[str, Any]: + if strategy_engine is None or signal_config.scoring_model != "pead": + return row + + reaction_threshold = ( + strategy_engine.pead_reaction_threshold_override + if strategy_engine.pead_reaction_threshold_override is not None + else signal_config.pead_reaction_threshold + ) + volume_threshold = ( + strategy_engine.pead_volume_threshold_override + if strategy_engine.pead_volume_threshold_override is not None + else signal_config.pead_volume_threshold + ) + + if ( + reaction_threshold == signal_config.pead_reaction_threshold + and volume_threshold == signal_config.pead_volume_threshold + ): + return row + + from libs.backtest.scoring import compute_pead_score + + prepared = dict(row) + prepared["score"] = compute_pead_score( + prepared, + reaction_threshold=reaction_threshold, + volume_threshold=volume_threshold, + ) + return prepared diff --git a/libs/backtest/snapshot_store.py b/libs/backtest/snapshot_store.py index 3147da8..03895ef 100644 --- a/libs/backtest/snapshot_store.py +++ b/libs/backtest/snapshot_store.py @@ -32,7 +32,11 @@ class SnapshotStore: bars_by_symbol_date: dict[str, dict[dt.date, dict[str, Any]]], macro_by_date: dict[dt.date, dict[str, Any]] | None = None, ) -> None: - self._candidates = candidates_by_exec_date + self._candidates = { + date: list(rows) + for date, rows in candidates_by_exec_date.items() + } + self._candidates_by_reaction_date = self._build_reaction_index(self._candidates) self._bars = bars_by_symbol_date self._macro = macro_by_date or {} @@ -44,6 +48,10 @@ class SnapshotStore: """Return candidates where execution_date == date. No look-ahead.""" return list(self._candidates.get(date, [])) + def get_candidates_for_reaction_date(self, date: dt.date) -> list[dict[str, Any]]: + """Return candidates where reaction_date == date. No look-ahead.""" + return list(self._candidates_by_reaction_date.get(date, [])) + def get_bar(self, symbol: str, date: dt.date) -> dict[str, Any] | None: """Return OHLCV bar for symbol on date, or None if missing.""" sym_bars = self._bars.get(symbol) @@ -59,7 +67,11 @@ class SnapshotStore: """Sorted list of dates that have at least one candidate.""" return sorted(self._candidates.keys()) - def all_trading_days(self) -> list[dt.date]: + def all_reaction_dates(self) -> list[dt.date]: + """Sorted list of dates that have at least one reaction-date candidate.""" + return sorted(self._candidates_by_reaction_date.keys()) + + def all_trading_days(self, include_reaction_dates: bool = False) -> list[dt.date]: """All NYSE trading days from first to last execution date (inclusive). Use this to drive the simulation loop so stop/target/time exits are @@ -67,10 +79,13 @@ class SnapshotStore: """ from libs.backtest.calendar import get_trading_days - exec_dates = self.all_execution_dates() - if not exec_dates: + dates = set(self.all_execution_dates()) + if include_reaction_dates: + dates.update(self.all_reaction_dates()) + if not dates: return [] - return get_trading_days(exec_dates[0], exec_dates[-1]) + ordered = sorted(dates) + return get_trading_days(ordered[0], ordered[-1]) # ------------------------------------------------------------------ # Factory: load from Parquet + DB + Oracle @@ -176,6 +191,7 @@ class SnapshotStore: enriched["execution_date"] = exec_date enriched["symbol"] = ticker enriched["issuer_id"] = meta.get("issuer_id") + enriched["event_date"] = meta.get("event_date") enriched["event_type"] = meta.get("event_type", "") enriched["event_timestamp"] = meta.get("event_timestamp") enriched["avg_dollar_volume"] = avg_dvol.get(ticker, 0.0) @@ -253,6 +269,7 @@ class SnapshotStore: ) result[event.event_id] = { "issuer_id": event.issuer_id, + "event_date": event.event_date, "event_type": event.event_type, "event_timestamp": ts, "ticker": sym.ticker if sym else None, @@ -268,6 +285,7 @@ class SnapshotStore: symbols: list[str], date_range: tuple[dt.date, dt.date] | None, oracle_url: str, + concurrency: int = 16, ) -> tuple[dict[str, dict[dt.date, dict[str, Any]]], dict[str, float]]: """Fetch daily OHLCV bars and compute avg_dollar_volume per symbol.""" if not symbols or date_range is None: @@ -280,39 +298,46 @@ class SnapshotStore: bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]] = {} avg_dvol: dict[str, float] = {} + semaphore = asyncio.Semaphore(concurrency) async with OracleClient(base_url=oracle_url) as client: svc = PriceService(client) - for sym in symbols: - try: - resp = await svc.get_daily_bars(sym, start=start_str, end=end_str) - date_bars: dict[dt.date, dict[str, Any]] = {} - dollar_vols: list[float] = [] - for bar in resp.bars: - d = dt.date.fromisoformat(bar.date) - b = { - "date": d, - "open": bar.open, - "high": bar.high, - "low": bar.low, - "close": bar.close, - "volume": bar.volume, - } - date_bars[d] = b - dollar_vols.append(bar.close * bar.volume) - bars_by_symbol[sym] = date_bars - # 20-day mean of dollar volume - if dollar_vols: - last_20 = dollar_vols[-20:] - avg_dvol[sym] = sum(last_20) / len(last_20) - else: - avg_dvol[sym] = 0.0 - except Exception as sym_exc: - logger.warning( - "snapshot_store_price_fetch_failed", symbol=sym, error=str(sym_exc) - ) - bars_by_symbol[sym] = {} - avg_dvol[sym] = 0.0 + async def _fetch_symbol(sym: str) -> tuple[str, dict[dt.date, dict[str, Any]], float]: + async with semaphore: + try: + resp = await svc.get_daily_bars(sym, start=start_str, end=end_str) + date_bars: dict[dt.date, dict[str, Any]] = {} + dollar_vols: list[float] = [] + for bar in resp.bars: + d = dt.date.fromisoformat(bar.date) + b = { + "date": d, + "open": bar.open, + "high": bar.high, + "low": bar.low, + "close": bar.close, + "volume": bar.volume, + } + date_bars[d] = b + dollar_vols.append(bar.close * bar.volume) + if dollar_vols: + last_20 = dollar_vols[-20:] + mean_dvol = sum(last_20) / len(last_20) + else: + mean_dvol = 0.0 + return sym, date_bars, mean_dvol + except Exception as sym_exc: + logger.warning( + "snapshot_store_price_fetch_failed", + symbol=sym, + error=str(sym_exc), + ) + return sym, {}, 0.0 + + results = await asyncio.gather(*(_fetch_symbol(sym) for sym in symbols)) + for sym, date_bars, mean_dvol in results: + bars_by_symbol[sym] = date_bars + avg_dvol[sym] = mean_dvol return bars_by_symbol, avg_dvol except Exception as exc: logger.warning("snapshot_store_oracle_failed", error=str(exc)) @@ -322,22 +347,29 @@ class SnapshotStore: async def _fetch_sectors( symbols: list[str], oracle_url: str, + concurrency: int = 16, ) -> dict[str, str]: """Fetch company sector for each symbol. Default 'UNKNOWN' if unavailable.""" if not symbols: return {} result: dict[str, str] = {} + semaphore = asyncio.Semaphore(concurrency) try: from libs.oracle_client import CompanyService, OracleClient async with OracleClient(base_url=oracle_url) as client: company_svc = CompanyService(client) - for sym in symbols: - try: - info = await company_svc.get_company(sym) - result[sym] = info.sector or "UNKNOWN" - except Exception: - result[sym] = "UNKNOWN" + async def _fetch_sector(sym: str) -> tuple[str, str]: + async with semaphore: + try: + info = await company_svc.get_company(sym) + return sym, info.sector or "UNKNOWN" + except Exception: + return sym, "UNKNOWN" + + sector_results = await asyncio.gather(*(_fetch_sector(sym) for sym in symbols)) + for sym, sector in sector_results: + result[sym] = sector except Exception as exc: logger.warning("snapshot_store_sector_fetch_failed", error=str(exc)) # Default all remaining to UNKNOWN @@ -436,19 +468,49 @@ class SnapshotStore: def _compute_date_range( rows: list[dict[str, Any]], ) -> tuple[dt.date, dt.date] | None: - """Compute (min_date, max_date) from entry_date/execution_date column.""" + """Compute (min_date, max_date) from execution and reaction-date columns.""" dates: list[dt.date] = [] for r in rows: - raw = r.get("entry_date") or r.get("execution_date") - if raw is None: - continue - if isinstance(raw, str): - try: - dates.append(dt.date.fromisoformat(raw)) - except ValueError: - pass - elif isinstance(raw, dt.date): - dates.append(raw) + for raw in ( + r.get("entry_date"), + r.get("execution_date"), + r.get("reaction_date"), + ): + if raw is None: + continue + if isinstance(raw, str): + try: + dates.append(dt.date.fromisoformat(raw)) + except ValueError: + pass + elif isinstance(raw, dt.date): + dates.append(raw) if not dates: return None return min(dates), max(dates) + + @staticmethod + def _build_reaction_index( + candidates_by_exec_date: dict[dt.date, list[dict[str, Any]]], + ) -> dict[dt.date, list[dict[str, Any]]]: + reaction_index: dict[dt.date, list[dict[str, Any]]] = {} + for rows in candidates_by_exec_date.values(): + for row in rows: + reaction_date = SnapshotStore._normalize_date(row.get("reaction_date")) + if reaction_date is None: + continue + reaction_index.setdefault(reaction_date, []).append(row) + return reaction_index + + @staticmethod + def _normalize_date(raw: Any) -> dt.date | None: + if isinstance(raw, dt.datetime): + return raw.date() + if isinstance(raw, dt.date): + return raw + if isinstance(raw, str): + try: + return dt.date.fromisoformat(raw) + except ValueError: + return None + return None diff --git a/tests/integration/backtest/test_backtest_run.py b/tests/integration/backtest/test_backtest_run.py index 10bbd0f..48e5bb2 100644 --- a/tests/integration/backtest/test_backtest_run.py +++ b/tests/integration/backtest/test_backtest_run.py @@ -76,7 +76,67 @@ def _build_synthetic_store() -> object: ) -def _make_config(): +def _build_multi_engine_store() -> object: + from libs.backtest.snapshot_store import SnapshotStore + + candidates = { + dt.date(2026, 1, 7): [ + { + "event_id": "EVT::SD::SHORT", + "symbol": "NFLX", + "execution_date": dt.date(2026, 1, 7), + "entry_date": "2026-01-07", + "event_date": "2026-01-06", + "event_close": 400.0, + "entry_price": 399.0, + "score": 0.90, + "sector": "Communication Services", + "event_type": "earnings_release", + "event_timestamp": "2026-01-06T21:00:00+00:00", + "filing_time_bucket": "post_market", + "reaction_date": "2026-01-06", + "reaction_day_return": -0.12, + "avg_dollar_volume": 8_000_000.0, + "atr_14": 4.0, + }, + { + "event_id": "EVT::AC::LONG", + "symbol": "AMD", + "execution_date": dt.date(2026, 1, 7), + "entry_date": "2026-01-07", + "event_date": "2026-01-06", + "event_close": 122.0, + "entry_price": 123.0, + "score": 0.88, + "sector": "Technology", + "event_type": "earnings_release", + "event_timestamp": "2026-01-06T21:00:00+00:00", + "filing_time_bucket": "post_market", + "reaction_date": "2026-01-07", + "reaction_day_return": 0.14, + "avg_dollar_volume": 9_000_000.0, + "atr_14": 3.0, + }, + ], + } + + bars = { + "NFLX": { + dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 395.0, "high": 405.0, "low": 390.0, "close": 400.0, "volume": 1_200_000}, + dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 390.0, "high": 392.0, "low": 380.0, "close": 382.0, "volume": 1_100_000}, + dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 382.0, "high": 384.0, "low": 370.0, "close": 372.0, "volume": 1_000_000}, + }, + "AMD": { + dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 123.0, "high": 130.0, "low": 122.0, "close": 129.0, "volume": 1_500_000}, + dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 129.0, "high": 135.0, "low": 128.0, "close": 134.0, "volume": 1_300_000}, + dt.date(2026, 1, 9): {"date": dt.date(2026, 1, 9), "open": 134.0, "high": 138.0, "low": 133.0, "close": 137.0, "volume": 1_250_000}, + }, + } + + return SnapshotStore(candidates_by_exec_date=candidates, bars_by_symbol_date=bars) + + +def _make_config(strategy_engines=None): from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, @@ -111,6 +171,7 @@ def _make_config(): write_metrics_summary=True, generate_plots=False, ), + strategy_engines=strategy_engines or [], ) @@ -258,3 +319,66 @@ class TestBacktestRunIntegration: symbols = [r["symbol"] for r in rows] assert "FUTURE_TICKER" not in symbols assert "AAPL" in symbols + + def test_multi_engine_run_writes_per_engine_metrics(self, tmp_path): + from apps.backtester.run import BacktestRunner + from libs.backtest.domain import ExperimentManifest, StrategyEngineConfig + import pyarrow.parquet as pq + + store = _build_multi_engine_store() + manifest = ExperimentManifest( + experiment_name="portfolio_v2", + dataset_snapshot_id="test_snapshot", + base_config="configs/backtest/defaults.json", + overrides={}, + strategy_engines=[ + StrategyEngineConfig( + engine_id="earnings_same_day_short_v1", + event_types=["earnings_release"], + timing_class="same_day", + direction="short_only", + entry_timing_policy="next_open", + max_holding_days=3, + engine_risk_budget_pct=0.40, + ), + StrategyEngineConfig( + engine_id="earnings_after_close_long_v1", + event_types=["earnings_release"], + timing_class="after_close", + direction="long_only", + entry_timing_policy="next_open", + max_holding_days=5, + engine_risk_budget_pct=0.25, + ), + StrategyEngineConfig( + engine_id="earnings_after_close_short_v1", + event_types=["earnings_release"], + timing_class="after_close", + direction="short_only", + entry_timing_policy="next_open", + max_holding_days=3, + engine_risk_budget_pct=0.10, + shadow_only=True, + ), + ], + ) + config = _make_config(strategy_engines=manifest.strategy_engines) + runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) + result = runner.run(output_root=tmp_path) + + run_dir = tmp_path / result.run_id + per_engine_metrics = run_dir / "metrics" / "per_engine_metrics.json" + attribution_by_engine = run_dir / "metrics" / "attribution_by_engine.csv" + + assert per_engine_metrics.exists() + assert attribution_by_engine.exists() + + payload = per_engine_metrics.read_text() + assert "earnings_same_day_short_v1" in payload + assert "earnings_after_close_long_v1" in payload + assert "earnings_after_close_short_v1" in payload + assert result.metrics.trade_count >= 1 + + trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist() + engine_ids = {row["engine_id"] for row in trade_blotter} + assert "earnings_after_close_short_v1" not in engine_ids diff --git a/tests/unit/backtest/test_domain.py b/tests/unit/backtest/test_domain.py index b0577eb..63af6c7 100644 --- a/tests/unit/backtest/test_domain.py +++ b/tests/unit/backtest/test_domain.py @@ -23,6 +23,7 @@ from libs.backtest.domain import ( ReportingConfig, RiskConfig, SignalConfig, + StrategyEngineConfig, UniverseConfig, ) @@ -282,6 +283,56 @@ class TestEventTypeProfile: assert cfg.event_type_profiles == {} assert cfg.get_event_profile("anything") is None + def test_backtest_config_strategy_engines_helpers(self): + cfg = BacktestConfig( + strategy_name="test", + dataset_snapshot_id="snap_001", + strategy_engines=[ + StrategyEngineConfig( + engine_id="active_engine", + event_types=["earnings_release"], + timing_class="same_day", + direction="long_only", + ), + StrategyEngineConfig( + engine_id="shadow_engine", + event_types=["earnings_release"], + timing_class="after_close", + direction="short_only", + shadow_only=True, + ), + ], + ) + assert [engine.engine_id for engine in cfg.get_strategy_engines()] == [ + "active_engine", + "shadow_engine", + ] + assert [engine.engine_id for engine in cfg.get_active_strategy_engines()] == [ + "active_engine", + ] + assert [engine.engine_id for engine in cfg.get_shadow_strategy_engines()] == [ + "shadow_engine", + ] + + def test_strategy_engine_config_supports_engine_specific_thresholds(self): + engine = StrategyEngineConfig( + engine_id="after_close_long_quality", + score_threshold_override=0.75, + pead_reaction_threshold_override=0.12, + pead_volume_threshold_override=3.0, + reaction_day_return_min=-0.45, + reaction_day_return_max=0.35, + gap_size_min=0.05, + gap_size_max=0.30, + ) + assert engine.score_threshold_override == 0.75 + assert engine.pead_reaction_threshold_override == 0.12 + assert engine.pead_volume_threshold_override == 3.0 + assert engine.reaction_day_return_min == -0.45 + assert engine.reaction_day_return_max == 0.35 + assert engine.gap_size_min == 0.05 + assert engine.gap_size_max == 0.30 + class TestMetricsBundleBootstrap: def test_bootstrap_cis_field(self): diff --git a/tests/unit/backtest/test_execution.py b/tests/unit/backtest/test_execution.py index f84cbe3..240af2f 100644 --- a/tests/unit/backtest/test_execution.py +++ b/tests/unit/backtest/test_execution.py @@ -30,6 +30,7 @@ def _make_candidate(**kwargs) -> Candidate: sector="Technology", event_type="earnings", event_timestamp=_NOW, + event_date=_TODAY, filing_time_bucket="post_market", reaction_date=_TODAY, execution_date=_TOMORROW, @@ -49,6 +50,11 @@ def _make_plan(entry_price=100.0, stop=95.0, target=110.0, shares=100) -> Planne stop_price=stop, target_price=target, risk_dollars=500.0, + event_date=_TODAY, + timing_class="same_day", + engine_id="engine_1", + entry_timing_policy="next_open", + shadow_only=False, ) @@ -142,6 +148,18 @@ class TestSimulateEntry: pos = simulate_entry(plan, bar, _make_exec_config()) assert pos.entry_date == _TOMORROW + def test_reaction_close_entry_uses_close(self): + from libs.backtest.execution import simulate_entry + + plan = _make_plan() + plan = plan.model_copy(update={"entry_timing_policy": "reaction_close"}) + bar = _make_bar(open=100.0, close=102.0, date=_TODAY) + pos = simulate_entry(plan, bar, _make_exec_config(slippage_bps_base=10.0)) + assert pos is not None + expected = 102.0 * (1 + 10 / 10_000) + assert pos.entry_price == pytest.approx(expected) + assert pos.entry_date == _TODAY + class TestSimulateExit: def test_stop_exit(self): @@ -232,6 +250,17 @@ class TestSimulateExit: assert trade.gross_pnl == pytest.approx(1000.0) assert trade.net_pnl == pytest.approx(998.0) + def test_trade_metadata_propagated(self): + from libs.backtest.execution import simulate_exit + + pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100) + bar = _make_bar(low=98.0, high=115.0) + trade = simulate_exit(pos, bar, _make_exec_config(slippage_bps_base=0.0), _TOMORROW) + assert trade.engine_id == "engine_1" + assert trade.timing_class == "same_day" + assert trade.event_date == _TODAY + assert trade.shadow_only is False + class TestPartialExit: def test_partial_exit_at_target(self): diff --git a/tests/unit/backtest/test_manifests.py b/tests/unit/backtest/test_manifests.py index adfe3e7..b61adc4 100644 --- a/tests/unit/backtest/test_manifests.py +++ b/tests/unit/backtest/test_manifests.py @@ -176,6 +176,36 @@ class TestResolveConfig: config = resolve_config(manifest, snapshot_id_override="snap_override") assert config.dataset_snapshot_id == "snap_override" + def test_strategy_engines_injected_from_manifest(self, tmp_path): + from libs.backtest.manifests import load_manifest, resolve_config + + cfg_file = tmp_path / "defaults.json" + _write_json(cfg_file, VALID_BASE_CONFIG) + + manifest_data = { + "experiment_name": "test", + "dataset_snapshot_id": "snap_001", + "base_config": str(cfg_file), + "overrides": {}, + "strategy_engines": [ + { + "engine_id": "earnings_same_day_short_v1", + "event_types": ["earnings_release"], + "timing_class": "same_day", + "direction": "short_only", + "entry_timing_policy": "next_open", + "max_holding_days": 3, + "engine_risk_budget_pct": 0.4, + } + ], + } + m_file = tmp_path / "manifest.json" + _write_json(m_file, manifest_data) + manifest = load_manifest(m_file) + config = resolve_config(manifest) + assert len(config.strategy_engines) == 1 + assert config.strategy_engines[0].engine_id == "earnings_same_day_short_v1" + class TestGenerateRunId: def test_format(self): diff --git a/tests/unit/backtest/test_selector.py b/tests/unit/backtest/test_selector.py index 1b1e9fa..121752b 100644 --- a/tests/unit/backtest/test_selector.py +++ b/tests/unit/backtest/test_selector.py @@ -6,7 +6,12 @@ from zoneinfo import ZoneInfo import pytest -from libs.backtest.domain import EventTypeProfile, SignalConfig, UniverseConfig +from libs.backtest.domain import ( + EventTypeProfile, + SignalConfig, + StrategyEngineConfig, + UniverseConfig, +) _UTC = ZoneInfo("UTC") @@ -42,6 +47,7 @@ class TestBuildCandidate: assert c.score == 0.75 assert c.execution_date == dt.date(2026, 1, 7) assert c.event_timestamp.tzinfo is not None + assert c.timing_class == "after_close" def test_null_timestamp_returns_none(self): from libs.backtest.selector import build_candidate @@ -95,6 +101,286 @@ class TestBuildCandidate: c = build_candidate(_make_raw_row(score=0.10)) assert c.score_bucket == "low" + def test_same_day_timing_and_direction_from_reaction(self): + from libs.backtest.selector import build_candidate + + row = _make_raw_row( + event_date="2026-01-06", + reaction_date="2026-01-06", + execution_date="2026-01-07", + trade_direction="", + reaction_day_return=-0.12, + ) + c = build_candidate(row) + assert c is not None + assert c.event_date == dt.date(2026, 1, 6) + assert c.timing_class == "same_day" + assert c.trade_direction == "short" + + def test_reaction_close_engine_uses_event_close(self): + from libs.backtest.selector import build_candidate + + engine = StrategyEngineConfig( + engine_id="earnings_same_day_long_close_v1", + event_types=["earnings"], + timing_class="same_day", + direction="long_only", + entry_timing_policy="reaction_close", + max_holding_days=3, + engine_risk_budget_pct=0.35, + ) + row = _make_raw_row( + event_date="2026-01-06", + reaction_date="2026-01-06", + event_close=149.5, + entry_date="2026-01-07", + reaction_day_return=0.11, + ) + c = build_candidate(row, strategy_engine=engine) + assert c is not None + assert c.execution_date == dt.date(2026, 1, 6) + assert c.entry_price_est == pytest.approx(149.5) + assert c.engine_id == "earnings_same_day_long_close_v1" + assert c.entry_timing_policy == "reaction_close" + + def test_engine_route_skips_non_matching_direction(self): + from libs.backtest.selector import build_candidate + + engine = StrategyEngineConfig( + engine_id="short_only_engine", + event_types=["earnings"], + timing_class="after_close", + direction="short_only", + ) + row = _make_raw_row(reaction_day_return=0.09, trade_direction="long") + assert build_candidate(row, strategy_engine=engine) is None + + def test_select_candidates_recomputes_pead_score_for_engine_thresholds(self): + from libs.backtest.selector import select_candidates + + rows = [ + _make_raw_row( + symbol="LOWVOL", + score=0.8, + event_type="earnings_release", + reaction_day_return=0.14, + volume_ratio_20d=2.4, + gap_size=0.01, + ), + _make_raw_row( + symbol="HIGHVOL", + score=0.8, + event_type="earnings_release", + reaction_day_return=0.14, + volume_ratio_20d=4.5, + gap_size=0.01, + ), + ] + universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0) + signal = SignalConfig( + scoring_model="pead", + score_threshold=0.65, + pead_reaction_threshold=0.10, + pead_volume_threshold=2.0, + max_candidates_per_day=5, + ) + engine = StrategyEngineConfig( + engine_id="after_close_long_quality", + event_types=["earnings_release"], + timing_class="after_close", + direction="long_only", + pead_volume_threshold_override=3.0, + ) + + selected = select_candidates(rows, universe, signal, strategy_engine=engine) + assert [candidate.symbol for candidate in selected] == ["HIGHVOL"] + + def test_select_candidates_uses_engine_score_threshold_override(self): + from libs.backtest.selector import select_candidates + + rows = [ + _make_raw_row( + symbol="PASS", + score=0.78, + event_type="earnings_release", + reaction_day_return=0.16, + volume_ratio_20d=4.0, + gap_size=0.01, + ), + _make_raw_row( + symbol="FAIL", + score=0.72, + event_type="earnings_release", + reaction_day_return=0.14, + volume_ratio_20d=4.0, + gap_size=0.01, + ), + ] + universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0) + signal = SignalConfig( + scoring_model="pead", + score_threshold=0.65, + pead_reaction_threshold=0.10, + pead_volume_threshold=2.0, + max_candidates_per_day=5, + ) + engine = StrategyEngineConfig( + engine_id="after_close_long_quality", + event_types=["earnings_release"], + timing_class="after_close", + direction="long_only", + score_threshold_override=0.75, + ) + + selected = select_candidates(rows, universe, signal, strategy_engine=engine) + assert [candidate.symbol for candidate in selected] == ["PASS"] + + def test_select_candidates_respects_engine_reaction_day_return_bounds(self): + from libs.backtest.selector import select_candidates + + rows = [ + _make_raw_row( + symbol="CRASH", + score=0.85, + event_type="earnings_release", + event_date="2026-01-06", + reaction_date="2026-01-06", + reaction_day_return=-0.52, + volume_ratio_20d=8.0, + trade_direction="short", + ), + _make_raw_row( + symbol="NORMAL", + score=0.82, + event_type="earnings_release", + event_date="2026-01-06", + reaction_date="2026-01-06", + reaction_day_return=-0.18, + volume_ratio_20d=5.0, + trade_direction="short", + ), + ] + universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0) + signal = SignalConfig( + scoring_model="pead", + score_threshold=0.65, + pead_reaction_threshold=0.10, + pead_volume_threshold=2.0, + max_candidates_per_day=5, + ) + engine = StrategyEngineConfig( + engine_id="same_day_short_filtered", + event_types=["earnings_release"], + timing_class="same_day", + direction="short_only", + reaction_day_return_min=-0.45, + ) + + selected = select_candidates(rows, universe, signal, strategy_engine=engine) + assert [candidate.symbol for candidate in selected] == ["NORMAL"] + + def test_select_candidates_respects_engine_reaction_day_return_upper_bound(self): + from libs.backtest.selector import select_candidates + + rows = [ + _make_raw_row( + symbol="TOO_HOT", + score=0.90, + event_type="earnings_release", + event_date="2026-01-06", + reaction_date="2026-01-06", + reaction_day_return=0.42, + volume_ratio_20d=6.0, + trade_direction="long", + ), + _make_raw_row( + symbol="OK", + score=0.80, + event_type="earnings_release", + event_date="2026-01-06", + reaction_date="2026-01-06", + reaction_day_return=0.18, + volume_ratio_20d=4.0, + trade_direction="long", + ), + ] + universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0) + signal = SignalConfig( + scoring_model="pead", + score_threshold=0.65, + pead_reaction_threshold=0.10, + pead_volume_threshold=2.0, + max_candidates_per_day=5, + ) + engine = StrategyEngineConfig( + engine_id="same_day_long_capped", + event_types=["earnings_release"], + timing_class="same_day", + direction="long_only", + reaction_day_return_max=0.30, + ) + + selected = select_candidates(rows, universe, signal, strategy_engine=engine) + assert [candidate.symbol for candidate in selected] == ["OK"] + + def test_select_candidates_respects_engine_gap_size_bounds(self): + from libs.backtest.selector import select_candidates + + rows = [ + _make_raw_row( + symbol="TIGHT", + score=0.82, + event_type="earnings_release", + event_date="2026-01-06", + reaction_date="2026-01-06", + reaction_day_return=0.18, + volume_ratio_20d=4.0, + gap_size=0.04, + trade_direction="long", + ), + _make_raw_row( + symbol="WIDE", + score=0.84, + event_type="earnings_release", + event_date="2026-01-06", + reaction_date="2026-01-06", + reaction_day_return=0.18, + volume_ratio_20d=4.0, + gap_size=0.16, + trade_direction="long", + ), + _make_raw_row( + symbol="TOO_WIDE", + score=0.86, + event_type="earnings_release", + event_date="2026-01-06", + reaction_date="2026-01-06", + reaction_day_return=0.18, + volume_ratio_20d=4.0, + gap_size=0.34, + trade_direction="long", + ), + ] + universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0) + signal = SignalConfig( + scoring_model="pead", + score_threshold=0.65, + pead_reaction_threshold=0.10, + pead_volume_threshold=2.0, + max_candidates_per_day=5, + ) + engine = StrategyEngineConfig( + engine_id="same_day_long_gapped", + event_types=["earnings_release"], + timing_class="same_day", + direction="long_only", + gap_size_min=0.10, + gap_size_max=0.30, + ) + + selected = select_candidates(rows, universe, signal, strategy_engine=engine) + assert [candidate.symbol for candidate in selected] == ["WIDE"] + class TestRankCandidates: def test_sorted_by_score_desc(self): diff --git a/tests/unit/backtest/test_snapshot_store.py b/tests/unit/backtest/test_snapshot_store.py index 59b5858..c8d3ee3 100644 --- a/tests/unit/backtest/test_snapshot_store.py +++ b/tests/unit/backtest/test_snapshot_store.py @@ -79,6 +79,12 @@ class TestSnapshotStoreQuery: rows = store.get_candidates_for_date(dt.date(2026, 1, 1)) assert rows == [] + def test_get_candidates_for_reaction_date(self, tmp_path): + store = _build_store_from_fixture(tmp_path) + rows = store.get_candidates_for_reaction_date(dt.date(2026, 1, 5)) + assert len(rows) == 1 + assert rows[0]["symbol"] == "AAPL" + def test_no_lookahead(self, tmp_path): """Candidates for Jan 7 should NOT appear when querying Jan 6.""" store = _build_store_from_fixture(tmp_path) @@ -109,6 +115,11 @@ class TestSnapshotStoreQuery: assert dt.date(2026, 1, 6) in dates assert dt.date(2026, 1, 7) in dates + def test_all_reaction_dates_sorted(self, tmp_path): + store = _build_store_from_fixture(tmp_path) + dates = store.all_reaction_dates() + assert dates == [dt.date(2026, 1, 5), dt.date(2026, 1, 6)] + def test_macro_default_empty(self, tmp_path): store = _build_store_from_fixture(tmp_path) macro = store.get_macro_for_date(dt.date(2026, 1, 6)) @@ -145,9 +156,10 @@ class TestSnapshotStoreFromParquet: {"entry_date": "2026-01-05"}, {"entry_date": "2026-01-10"}, {"entry_date": "2026-01-07"}, + {"reaction_date": "2026-01-04"}, ] result = SnapshotStore._compute_date_range(rows) - assert result == (dt.date(2026, 1, 5), dt.date(2026, 1, 10)) + assert result == (dt.date(2026, 1, 4), dt.date(2026, 1, 10)) def test_compute_date_range_empty(self, tmp_path): from libs.backtest.snapshot_store import SnapshotStore