diff --git a/apps/web/routers/backtest.py b/apps/web/routers/backtest.py index fc93dfd..fcb25e6 100644 --- a/apps/web/routers/backtest.py +++ b/apps/web/routers/backtest.py @@ -1226,11 +1226,18 @@ def list_snapshots() -> dict[str, Any]: import pandas as pd # type: ignore def _read_snap(snap_dir: Path) -> dict[str, Any] | None: - train = snap_dir / "train.parquet" - if not train.exists(): + all_dfs = [] + for split in ("train", "valid", "test"): + p = snap_dir / f"{split}.parquet" + if p.exists(): + try: + all_dfs.append(pd.read_parquet(p, columns=["event_date"])) + except Exception: + pass + if not all_dfs: return None try: - df = pd.read_parquet(train, columns=["event_date"]) + df = pd.concat(all_dfs, ignore_index=True) return { "id": snap_dir.name, "start": str(df["event_date"].min()), @@ -1258,8 +1265,8 @@ def list_snapshots() -> dict[str, Any]: for snap_dir in sorted(parquet_dir.iterdir()): if snap_dir.name in seen: continue - # Only include OOT / historical snapshots (skip active live ones) - if "oot" not in snap_dir.name and "2020" not in snap_dir.name and "2021" not in snap_dir.name: + # Only include OOT / historical / test / fix snapshots (skip active live ones) + if "oot" not in snap_dir.name and "2020" not in snap_dir.name and "2021" not in snap_dir.name and "test" not in snap_dir.name and "fix" not in snap_dir.name: continue if not snap_dir.is_dir(): continue @@ -1329,8 +1336,17 @@ def get_form4_capture_presets() -> dict[str, Any]: "reserve_form4_cluster_plus_fresh", "reserve_form4_cluster_plus_fresh_same_day", ] + experimental_v3 = [ + "reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_high", + "reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_ultra", + ] presets = [name for name in curated if name in FORM4_CAPTURE_SLEEVE_PRESETS] - groups = {"Residual Insider Sleeve": presets} + v3_presets = [name for name in experimental_v3 if name in FORM4_CAPTURE_SLEEVE_PRESETS] + groups = { + "Residual Insider Sleeve": presets, + "Residual Insider Sleeve (V3 Experimental)": v3_presets, + } + presets = presets + v3_presets return {"presets": presets, "groups": groups} diff --git a/libs/backtest/domain.py b/libs/backtest/domain.py index bcf2aa9..dc23f62 100644 --- a/libs/backtest/domain.py +++ b/libs/backtest/domain.py @@ -1080,11 +1080,13 @@ IDLE_ALPHA_SLEEVE_PRESETS: dict[str, dict[str, Any]] = { # --------------------------------------------------------------------------- DIVIDEND_CAPTURE_SLEEVE_PRESETS: dict[str, dict[str, Any]] = {} +FORM4_PIT_EVENTS_V1_PATH = "data/reference/form4_daily_events_pit.parquet" +FORM4_PIT_EVENTS_V3_PATH = "data/reference/form4_daily_events_pit_v3.parquet" FORM4_CAPTURE_SLEEVE_PRESETS: dict[str, dict[str, Any]] = { "reserve_form4_cluster": { "enabled": True, - "pit_events_path": "data/reference/form4_daily_events_pit.parquet", + "pit_events_path": FORM4_PIT_EVENTS_V1_PATH, "reserve_pct": 0.04, "min_owner_count": 2, "min_total_value": 5_000_000.0, @@ -1096,7 +1098,7 @@ FORM4_CAPTURE_SLEEVE_PRESETS: dict[str, dict[str, Any]] = { }, "reserve_form4_cluster_plus": { "enabled": True, - "pit_events_path": "data/reference/form4_daily_events_pit.parquet", + "pit_events_path": FORM4_PIT_EVENTS_V1_PATH, "reserve_pct": 0.06, "min_owner_count": 2, "min_total_value": 5_000_000.0, @@ -1108,7 +1110,7 @@ FORM4_CAPTURE_SLEEVE_PRESETS: dict[str, dict[str, Any]] = { }, "reserve_form4_cluster_plus_fresh": { "enabled": True, - "pit_events_path": "data/reference/form4_daily_events_pit.parquet", + "pit_events_path": FORM4_PIT_EVENTS_V1_PATH, "reserve_pct": 0.06, "min_owner_count": 2, "min_total_value": 5_000_000.0, @@ -1122,7 +1124,7 @@ FORM4_CAPTURE_SLEEVE_PRESETS: dict[str, dict[str, Any]] = { }, "reserve_form4_cluster_plus_fresh_same_day": { "enabled": True, - "pit_events_path": "data/reference/form4_daily_events_pit.parquet", + "pit_events_path": FORM4_PIT_EVENTS_V1_PATH, "reserve_pct": 0.06, "min_owner_count": 2, "min_total_value": 5_000_000.0, @@ -1135,6 +1137,39 @@ FORM4_CAPTURE_SLEEVE_PRESETS: dict[str, dict[str, Any]] = { "max_positions": 6, "max_new_per_day": 2, }, + # Experimental lane. Keep v1 presets frozen so existing stacks remain reproducible. + "reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_high": { + "enabled": True, + "pit_events_path": FORM4_PIT_EVENTS_V3_PATH, + "reserve_pct": 0.46, + "min_owner_count": 2, + "min_total_value": 5_000_000.0, + "min_purchase_pct": 0.005, + "min_transaction_count": 2, + "max_lag_days": 2, + "max_min_lag_days": 1, + "max_transaction_span_days": 0, + "symbol_cooldown_days_after_loss": 180, + "hold_days": 20, + "max_positions": 6, + "max_new_per_day": 2, + }, + "reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_ultra": { + "enabled": True, + "pit_events_path": FORM4_PIT_EVENTS_V3_PATH, + "reserve_pct": 0.50, + "min_owner_count": 2, + "min_total_value": 5_000_000.0, + "min_purchase_pct": 0.005, + "min_transaction_count": 2, + "max_lag_days": 2, + "max_min_lag_days": 1, + "max_transaction_span_days": 0, + "symbol_cooldown_days_after_loss": 180, + "hold_days": 20, + "max_positions": 6, + "max_new_per_day": 2, + }, } @@ -1798,6 +1833,9 @@ class Form4CaptureConfig(BaseModel): max_min_lag_days: int | None = None max_transaction_span_days: int | None = None require_officer_or_director: bool = False + symbol_cooldown_days_after_loss: int = 0 + symbol_max_entries_in_lookback: int | None = None + symbol_entry_lookback_days: int = 365 disable_day1_early_failure: bool = False no_progress_days_override: int | None = None no_progress_r_override: float | None = None diff --git a/tests/unit/backtest/test_domain.py b/tests/unit/backtest/test_domain.py index ca3cd24..6fffcf8 100644 --- a/tests/unit/backtest/test_domain.py +++ b/tests/unit/backtest/test_domain.py @@ -15,6 +15,10 @@ from libs.backtest.domain import ( ExitReason, ExecutionConfig, ExperimentManifest, + FORM4_CAPTURE_SLEEVE_PRESETS, + FORM4_PIT_EVENTS_V1_PATH, + FORM4_PIT_EVENTS_V3_PATH, + Form4CaptureConfig, FilledTrade, MetricsBundle, OpenPosition, @@ -244,6 +248,58 @@ class TestConfigModels: assert m.experiment_name == "test_exp" assert m.splits == [] + def test_form4_v1_presets_are_frozen_to_v1_cache(self): + assert FORM4_CAPTURE_SLEEVE_PRESETS["reserve_form4_cluster"]["pit_events_path"] == FORM4_PIT_EVENTS_V1_PATH + assert FORM4_CAPTURE_SLEEVE_PRESETS["reserve_form4_cluster_plus"]["pit_events_path"] == FORM4_PIT_EVENTS_V1_PATH + assert FORM4_CAPTURE_SLEEVE_PRESETS["reserve_form4_cluster_plus_fresh"]["pit_events_path"] == FORM4_PIT_EVENTS_V1_PATH + assert FORM4_CAPTURE_SLEEVE_PRESETS["reserve_form4_cluster_plus_fresh_same_day"]["pit_events_path"] == FORM4_PIT_EVENTS_V1_PATH + + def test_form4_v3_presets_use_separate_cache(self): + assert ( + FORM4_CAPTURE_SLEEVE_PRESETS[ + "reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_high" + ]["pit_events_path"] + == FORM4_PIT_EVENTS_V3_PATH + ) + assert ( + FORM4_CAPTURE_SLEEVE_PRESETS[ + "reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_ultra" + ]["pit_events_path"] + == FORM4_PIT_EVENTS_V3_PATH + ) + + def test_form4_v3_aggressive_plus_cooldown180_high_preset_shape(self): + preset = FORM4_CAPTURE_SLEEVE_PRESETS[ + "reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_high" + ] + assert preset["reserve_pct"] == 0.46 + assert preset["min_purchase_pct"] == 0.005 + assert preset["symbol_cooldown_days_after_loss"] == 180 + assert preset["max_transaction_span_days"] == 0 + + def test_form4_v3_aggressive_plus_cooldown180_ultra_preset_shape(self): + preset = FORM4_CAPTURE_SLEEVE_PRESETS[ + "reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_ultra" + ] + assert preset["reserve_pct"] == 0.50 + assert preset["min_purchase_pct"] == 0.005 + assert preset["symbol_cooldown_days_after_loss"] == 180 + assert preset["max_transaction_span_days"] == 0 + + def test_form4_capture_config_supports_symbol_cooldown_fields(self): + config = Form4CaptureConfig.model_validate( + { + "enabled": True, + "symbol_cooldown_days_after_loss": 45, + "symbol_max_entries_in_lookback": 3, + "symbol_entry_lookback_days": 540, + } + ) + + assert config.symbol_cooldown_days_after_loss == 45 + assert config.symbol_max_entries_in_lookback == 3 + assert config.symbol_entry_lookback_days == 540 + class TestEventTypeProfile: def test_defaults(self):