diff --git a/src/us_equity_strategies/research/soxl_core_optimization.py b/src/us_equity_strategies/research/soxl_core_optimization.py index 5539cbb..480943b 100644 --- a/src/us_equity_strategies/research/soxl_core_optimization.py +++ b/src/us_equity_strategies/research/soxl_core_optimization.py @@ -915,20 +915,50 @@ def simulate_rsi2_mean_reversion_candidate(source: OfflineInput, candidate_id: s return tuple(points) -def _select_rsi2_mean_reversion_winner(validation: dict[str, Sequence[dict[str, float | int | None | str]]]) -> str: +def _rsi2_metrics_with_unified_soxx(points: Sequence[DailyPoint], soxx: Sequence[InputRow], raw_start: int, raw_end: int) -> dict[str, Any]: + metrics = dict(_volatility_window_metrics(points, raw_start, raw_end)) + selected_points = points[raw_start - BASELINE_WINDOW_DAYS:raw_end - BASELINE_WINDOW_DAYS + 1] + selected_soxx = soxx[raw_start - 1:raw_end + 1] + return_observation_count = raw_end - raw_start + 1 + if len(selected_points) != return_observation_count or len(selected_soxx) != return_observation_count + 1: + _fail("WINDOW_BOUNDARY_INVALID") + peak = selected_soxx[0].close + soxx_drawdown = 0.0 + for row in selected_soxx[1:]: + peak = max(peak, row.close) + soxx_drawdown = min(soxx_drawdown, row.close / peak - 1.0) + soxx_cumulative_return = selected_soxx[-1].close / selected_soxx[0].close - 1.0 + metrics.update({ + "exposure_session_count": sum(point.quantity > 0.0 for point in selected_points), + "activity_observed": any(point.quantity > 0.0 or point.gross_traded_notional_at_open > 0.0 for point in selected_points), + "soxx_close_path_cumulative_return": soxx_cumulative_return, + "soxx_close_path_cagr": (1.0 + soxx_cumulative_return) ** (252.0 / return_observation_count) - 1.0, + "soxx_close_path_max_drawdown": soxx_drawdown, + "matches_or_beats_soxx_drawdown": abs(float(metrics["max_drawdown"])) <= abs(soxx_drawdown), + }) + return metrics + + +def _select_rsi2_mean_reversion_winner(validation: dict[str, Sequence[dict[str, Any]]]) -> str | None: if tuple(validation) != RSI2_MEAN_REVERSION_CANDIDATES: _fail("VALIDATION_METRICS_INVALID") - ranked: list[tuple[tuple[float, float, float, float, float, int], str]] = [] + baseline = validation["UNSCALED_SMA200"] + if len(baseline) != 3 or not all(item.get("activity_observed") is True for item in baseline): + return None + ranked: list[tuple[tuple[float, float, float, float, int], str]] = [] for index, candidate_id in enumerate(RSI2_MEAN_REVERSION_CANDIDATES): metrics = validation[candidate_id] if len(metrics) != 3: _fail("VALIDATION_METRICS_INVALID") + if not all(item.get("activity_observed") is True for item in metrics): + continue + if candidate_id != "UNSCALED_SMA200" and not _all_strictly_better(metrics, baseline): + continue ranked.append(( ( + -_median(_metric_values(metrics, "cagr")), _median([abs(value) for value in _metric_values(metrics, "max_drawdown")]), -_median(_metric_values(metrics, "expected_shortfall_95")), - _median(_metric_values(metrics, "annualized_volatility")), - -_median(_metric_values(metrics, "cagr")), _median(_metric_values(metrics, "turnover")), index, ), @@ -937,11 +967,24 @@ def _select_rsi2_mean_reversion_winner(validation: dict[str, Sequence[dict[str, return min(ranked)[1] +def _rsi2_wfa_qualifying_count(candidate: Sequence[dict[str, Any]], baseline: Sequence[dict[str, Any]]) -> int: + if len(candidate) != 3 or len(baseline) != 3: + _fail("VALIDATION_METRICS_INVALID") + return sum( + item["activity_observed"] is True and float(item["cumulative_return"]) > 0.0 + and abs(float(item["max_drawdown"])) < abs(float(reference["max_drawdown"])) + and float(item["expected_shortfall_95"]) > float(reference["expected_shortfall_95"]) + for item, reference in zip(candidate, baseline, strict=True) + ) + + def _invalid_rsi2_mean_reversion(code: str) -> dict[str, Any]: return { "schema": RSI2_MEAN_REVERSION_SCHEMA, "evidence_valid": False, "failure_codes": [code], "outcome": "NO_IMPROVEMENT", "research_recommendation": None, "research_only": True, "live_adoption_authorized": False, "size_zero_required": True, - "plugin_control": dict(RSI2_MEAN_REVERSION_PLUGIN_CONTROL), + "plugin_control": dict(RSI2_MEAN_REVERSION_PLUGIN_CONTROL), "acceptance_contract": "SOXL_CONSTRAINED_COMPOUNDING_ACCEPTANCE_HARDENING_V1", + "acceptance_classes": {"activity": {"selection_windows_all_active": False, "wfa_qualifying_test_window_count": 0, "wfa_at_least_two_of_three": False}, "benchmark_relative_compounding": {"final_holdout_drawdown_no_worse_than_soxx": False, "final_holdout_cumulative_return_strictly_greater_than_soxx": False, "final_holdout_cagr_strictly_greater_than_soxx": False, "eligible": False}}, + "recommendation_eligible": False, "r4a_eligible": False, } @@ -965,41 +1008,56 @@ def run_soxl_rsi2_mean_reversion(source: object, *, plugin_control: object = RSI if [(point.date, point.end_equity.hex(), point.cash.hex(), point.quantity.hex()) for point in zero] != [(point.date, point.equity.hex(), point.cash.hex(), point.soxl_quantity.hex()) for point in baseline.equity_curve]: _fail("SMA200_ZERO_PARITY_FAILED") validation = { - candidate_id: [_volatility_metrics_with_soxx(simulations[candidate_id]["C2_5"], soxx, *WINDOWS[name]) for name in ("F1_VALIDATION", "F2_VALIDATION", "F3_VALIDATION")] + candidate_id: [_rsi2_metrics_with_unified_soxx(simulations[candidate_id]["C2_5"], soxx, *WINDOWS[name]) for name in ("F1_VALIDATION", "F2_VALIDATION", "F3_VALIDATION")] for candidate_id in RSI2_MEAN_REVERSION_CANDIDATES } winner = _select_rsi2_mean_reversion_winner(validation) - exposed = tuple(dict.fromkeys((winner, "UNSCALED_SMA200"))) + exposed = tuple(dict.fromkeys(((winner,) if winner is not None else ()) + ("UNSCALED_SMA200",))) post_lock = { candidate_id: { - name: {scenario.scenario_id: _volatility_metrics_with_soxx(simulations[candidate_id][scenario.scenario_id], soxx, *WINDOWS[name]) for scenario in SCENARIOS} + name: {scenario.scenario_id: _rsi2_metrics_with_unified_soxx(simulations[candidate_id][scenario.scenario_id], soxx, *WINDOWS[name]) for scenario in SCENARIOS} for name in ("F1_TEST", "F2_TEST", "F3_TEST", "FINAL_HOLDOUT") } for candidate_id in exposed } - selected_validation = validation[winner] baseline_validation = validation["UNSCALED_SMA200"] - selected_post, baseline_post = post_lock[winner], post_lock["UNSCALED_SMA200"] - selected_tests = [selected_post[name]["C2_5"] for name in ("F1_TEST", "F2_TEST", "F3_TEST")] + baseline_post = post_lock["UNSCALED_SMA200"] + selected_validation = validation[winner] if winner is not None else () + selected_post = post_lock[winner] if winner is not None else None + selected_tests = [selected_post[name]["C2_5"] for name in ("F1_TEST", "F2_TEST", "F3_TEST")] if selected_post is not None else [] baseline_tests = [baseline_post[name]["C2_5"] for name in ("F1_TEST", "F2_TEST", "F3_TEST")] - final_c2, baseline_final_c2 = selected_post["FINAL_HOLDOUT"]["C2_5"], baseline_post["FINAL_HOLDOUT"]["C2_5"] - final_stress, baseline_final_stress = selected_post["FINAL_HOLDOUT"]["C5_10_STRESS"], baseline_post["FINAL_HOLDOUT"]["C5_10_STRESS"] - final_returns = tuple(point.daily_return for point in simulations[winner]["C2_5"][WINDOWS["FINAL_HOLDOUT"][0] - BASELINE_WINDOW_DAYS:]) - loss_probability = _terminal_loss_probability(final_returns) + final_c2 = selected_post["FINAL_HOLDOUT"]["C2_5"] if selected_post is not None else None + baseline_final_c2 = baseline_post["FINAL_HOLDOUT"]["C2_5"] + final_stress = selected_post["FINAL_HOLDOUT"]["C5_10_STRESS"] if selected_post is not None else None + baseline_final_stress = baseline_post["FINAL_HOLDOUT"]["C5_10_STRESS"] + loss_probability = _terminal_loss_probability(tuple(point.daily_return for point in simulations[winner]["C2_5"][WINDOWS["FINAL_HOLDOUT"][0] - BASELINE_WINDOW_DAYS:])) if winner is not None else None + wfa_qualifying_count = _rsi2_wfa_qualifying_count(selected_tests, baseline_tests) if selected_post is not None else 0 + cagr_comparison_consistent = final_c2 is not None and ( + (float(final_c2["cagr"]) > float(final_c2["soxx_close_path_cagr"])) + == (float(final_c2["cumulative_return"]) > float(final_c2["soxx_close_path_cumulative_return"])) + ) + benchmark_drawdown = final_c2 is not None and abs(float(final_c2["max_drawdown"])) <= abs(float(final_c2["soxx_close_path_max_drawdown"])) + benchmark_return = final_c2 is not None and float(final_c2["cumulative_return"]) > float(final_c2["soxx_close_path_cumulative_return"]) + benchmark_cagr = final_c2 is not None and float(final_c2["cagr"]) > float(final_c2["soxx_close_path_cagr"]) gates = { - "validation_medians_strictly_improve_drawdown_and_es95": _all_strictly_better(selected_validation, baseline_validation), - "wfa_tests_at_least_two_strictly_improve_drawdown_and_es95": sum(abs(float(candidate["max_drawdown"])) < abs(float(reference["max_drawdown"])) and float(candidate["expected_shortfall_95"]) > float(reference["expected_shortfall_95"]) for candidate, reference in zip(selected_tests, baseline_tests, strict=True)) >= 2, - "final_c2_5_strictly_improves_drawdown_and_es95": abs(float(final_c2["max_drawdown"])) < abs(float(baseline_final_c2["max_drawdown"])) and float(final_c2["expected_shortfall_95"]) > float(baseline_final_c2["expected_shortfall_95"]), - "wfa_c2_5_returns_at_least_two_positive": sum(float(item["cumulative_return"]) > 0.0 for item in selected_tests) >= 2, - "final_c2_5_return_strictly_positive": float(final_c2["cumulative_return"]) > 0.0, - "final_c5_10_stress_return_strictly_positive": float(final_stress["cumulative_return"]) > 0.0, - "mc_c2_5_terminal_loss_probability_strictly_below_half": loss_probability < 0.5, - "final_c5_10_stress_strictly_improves_drawdown_and_es95": abs(float(final_stress["max_drawdown"])) < abs(float(baseline_final_stress["max_drawdown"])) and float(final_stress["expected_shortfall_95"]) > float(baseline_final_stress["expected_shortfall_95"]), + "validation_medians_strictly_improve_drawdown_and_es95": winner is not None and _all_strictly_better(selected_validation, baseline_validation), + "wfa_tests_at_least_two_simultaneous_predicates": wfa_qualifying_count >= 2, + "final_c2_5_strictly_improves_drawdown_and_es95": final_c2 is not None and abs(float(final_c2["max_drawdown"])) < abs(float(baseline_final_c2["max_drawdown"])) and float(final_c2["expected_shortfall_95"]) > float(baseline_final_c2["expected_shortfall_95"]), + "final_c2_5_return_strictly_positive": final_c2 is not None and float(final_c2["cumulative_return"]) > 0.0, + "final_c5_10_stress_return_strictly_positive": final_stress is not None and float(final_stress["cumulative_return"]) > 0.0, + "mc_c2_5_terminal_loss_probability_strictly_below_half": loss_probability is not None and loss_probability < 0.5, + "final_c5_10_stress_strictly_improves_drawdown_and_es95": final_stress is not None and abs(float(final_stress["max_drawdown"])) < abs(float(baseline_final_stress["max_drawdown"])) and float(final_stress["expected_shortfall_95"]) > float(baseline_final_stress["expected_shortfall_95"]), "no_lookahead": True, + "soxx_cagr_comparison_consistent_with_cumulative_return": cagr_comparison_consistent, + "selection_windows_all_active": winner is not None, + "final_holdout_benchmark_relative_compounding": bool(benchmark_drawdown and benchmark_return and benchmark_cagr and cagr_comparison_consistent), + } + acceptance_classes = { + "activity": {"selection_windows_all_active": winner is not None, "wfa_qualifying_test_window_count": wfa_qualifying_count, "wfa_at_least_two_of_three": wfa_qualifying_count >= 2}, + "benchmark_relative_compounding": {"final_holdout_drawdown_no_worse_than_soxx": benchmark_drawdown, "final_holdout_cumulative_return_strictly_greater_than_soxx": benchmark_return, "final_holdout_cagr_strictly_greater_than_soxx": benchmark_cagr, "eligible": bool(benchmark_drawdown and benchmark_return and benchmark_cagr and cagr_comparison_consistent)}, } failures = [name.upper() for name, value in gates.items() if not value] - found = winner != "UNSCALED_SMA200" and all(gates.values()) - holdout_soxx_drawdown = _soxx_drawdown(soxx, *WINDOWS["FINAL_HOLDOUT"]) + found = winner is not None and winner != "UNSCALED_SMA200" and all(gates.values()) and acceptance_classes["activity"]["wfa_at_least_two_of_three"] and acceptance_classes["benchmark_relative_compounding"]["eligible"] return { "schema": RSI2_MEAN_REVERSION_SCHEMA, "evidence_valid": True, "failure_codes": failures, "outcome": "CHARACTERIZATION_CANDIDATE_FOUND" if found else "NO_IMPROVEMENT", @@ -1009,8 +1067,9 @@ def run_soxl_rsi2_mean_reversion(source: object, *, plugin_control: object = RSI "baseline": "UNSCALED_SMA200", "candidates": list(RSI2_MEAN_REVERSION_CANDIDATES), "signal_rule": {"trend": "SOXX_CLOSE_STRICTLY_ABOVE_INCLUSIVE_SMA200", "rsi": "WILDER_RSI2", "execution": "NEXT_SOXL_OPEN", "exposure": "LONG_OR_CASH"}, "validation_metrics_c2_5": validation, "locked_winner": winner, "post_lock_metrics": post_lock, - "evidence_gates": gates, - "soxx_drawdown_comparison": {"final_holdout_max_drawdown": holdout_soxx_drawdown, "matches_or_beats_soxx_drawdown": abs(float(final_c2["max_drawdown"])) <= abs(holdout_soxx_drawdown)}, + "evidence_gates": gates, "acceptance_contract": "SOXL_CONSTRAINED_COMPOUNDING_ACCEPTANCE_HARDENING_V1", "acceptance_classes": acceptance_classes, + "recommendation_eligible": found, "r4a_eligible": found, + "soxx_drawdown_comparison": {"final_holdout_max_drawdown": final_c2["soxx_close_path_max_drawdown"] if final_c2 is not None else None, "matches_or_beats_soxx_drawdown": benchmark_drawdown}, } except OptimizationError as exc: return _invalid_rsi2_mean_reversion(str(exc)) diff --git a/tests/test_soxl_rsi2_mean_reversion.py b/tests/test_soxl_rsi2_mean_reversion.py index 9647257..4c2c533 100644 --- a/tests/test_soxl_rsi2_mean_reversion.py +++ b/tests/test_soxl_rsi2_mean_reversion.py @@ -12,12 +12,15 @@ from us_equity_strategies.research.soxl_soxx_typed_baseline_result import run_typed_baseline from us_equity_strategies.research.soxl_core_optimization import ( BASELINE_WINDOW_DAYS, + DailyPoint, RSI2_MEAN_REVERSION_CANDIDATES, RSI2_MEAN_REVERSION_PLUGIN_CONTROL, RSI2_MEAN_REVERSION_SCHEMA, SCENARIOS, OptimizationError, _rsi2_values, + _rsi2_metrics_with_unified_soxx, + _rsi2_wfa_qualifying_count, _select_rsi2_mean_reversion_winner, load_persisted_rsi2_mean_reversion_result, persist_rsi2_mean_reversion_result, @@ -112,7 +115,7 @@ def test_rsi_state_machine_costs_and_exposure_bounds() -> None: def test_validation_selection_tie_and_runner_isolation(monkeypatch: pytest.MonkeyPatch) -> None: - metric = {"max_drawdown": -0.1, "expected_shortfall_95": -0.02, "annualized_volatility": 0.2, "cagr": 0.1, "turnover": 1.0} + metric = {"max_drawdown": -0.1, "expected_shortfall_95": -0.02, "annualized_volatility": 0.2, "cagr": 0.1, "turnover": 1.0, "activity_observed": True} validation = {candidate: [metric] * 3 for candidate in RSI2_MEAN_REVERSION_CANDIDATES} assert _select_rsi2_mean_reversion_winner(validation) == "UNSCALED_SMA200" monkeypatch.setattr("us_equity_strategies.research.soxl_core_optimization._terminal_loss_probability", lambda _: 0.0) @@ -120,6 +123,9 @@ def test_validation_selection_tie_and_runner_isolation(monkeypatch: pytest.Monke assert result["schema"] == RSI2_MEAN_REVERSION_SCHEMA assert result["research_only"] is True and result["live_adoption_authorized"] is False and result["size_zero_required"] is True assert set(result["post_lock_metrics"]) <= {result["locked_winner"], "UNSCALED_SMA200"} + assert result["outcome"] == "NO_IMPROVEMENT" + assert result["acceptance_classes"]["benchmark_relative_compounding"]["eligible"] is False + assert "FINAL_HOLDOUT_BENCHMARK_RELATIVE_COMPOUNDING" in result["failure_codes"] assert run_soxl_rsi2_mean_reversion(_source(), plugin_control={"state": "ABSENT_DISABLED"})["evidence_valid"] is False assert run_soxl_rsi2_mean_reversion(None)["evidence_valid"] is False @@ -142,3 +148,75 @@ def test_persistence_set_once_strict_readback_and_symlink_rejection(tmp_path: Pa (linked / "soxl_rsi2_mean_reversion_v1.json").symlink_to(tmp_path / "outside") with pytest.raises(OptimizationError, match="OUTPUT_PATH_INVALID"): persist_rsi2_mean_reversion_result(result, linked, source_commit=head, source_blobs=blobs, repo_root=repo) + + +def test_rsi2_acceptance_soxx_path_includes_prior_close_for_return_cagr_and_drawdown() -> None: + points = ( + DailyPoint("2024-01-01", 100.0, 101.0, 0.0, 1.0, 0.01, False, 0.0, 0.0, 0.0), + DailyPoint("2024-01-02", 101.0, 102.0, 0.0, 1.0, 1.0 / 101.0, False, 0.0, 0.0, 0.0), + ) + soxx = tuple(InputRow("SOXX", f"2024-01-{index + 1:02d}", close, close, close, close, 1.0) for index, close in enumerate([100.0] * 199 + [120.0, 110.0, 115.0])) + + metrics = _rsi2_metrics_with_unified_soxx(points, soxx, 200, 201) + + assert metrics["soxx_close_path_cumulative_return"] == pytest.approx(115.0 / 120.0 - 1.0) + assert metrics["soxx_close_path_cagr"] == pytest.approx((115.0 / 120.0) ** (252.0 / 2.0) - 1.0) + assert metrics["soxx_close_path_max_drawdown"] == pytest.approx(110.0 / 120.0 - 1.0) + assert metrics["observation_count"] == 2 + rising_soxx = tuple(InputRow("SOXX", f"2024-03-{index + 1:02d}", close, close, close, close, 1.0) for index, close in enumerate([100.0] * 199 + [100.0, 110.0, 115.0])) + rising = _rsi2_metrics_with_unified_soxx(points, rising_soxx, 200, 201) + assert rising["soxx_close_path_cumulative_return"] == pytest.approx(0.15) + assert rising["soxx_close_path_cagr"] == pytest.approx(1.15 ** (252.0 / 2.0) - 1.0) + + +def test_rsi2_acceptance_activity_filters_zero_activity_and_ranks_by_cagr_first() -> None: + carried = DailyPoint("2024-01-01", 100.0, 101.0, 0.0, 1.0, 0.01, False, 0.0, 0.0, 0.0) + exit_at_open = DailyPoint("2024-01-02", 101.0, 100.0, 100.0, 0.0, -0.01, True, 0.0, 0.0, 101.0) + cash = DailyPoint("2024-01-03", 100.0, 100.0, 100.0, 0.0, 0.0, False, 0.0, 0.0, 0.0) + soxx = tuple(InputRow("SOXX", f"2024-02-{index + 1:02d}", 100.0, 100.0, 100.0, 100.0, 1.0) for index in range(202)) + active = _rsi2_metrics_with_unified_soxx((carried, exit_at_open), soxx, 200, 201) + inactive = _rsi2_metrics_with_unified_soxx((cash, cash), soxx, 200, 201) + assert active["exposure_session_count"] == 1 and active["activity_observed"] is True + assert inactive["exposure_session_count"] == 0 and inactive["activity_observed"] is False + + baseline = {"max_drawdown": -0.2, "expected_shortfall_95": -0.1, "annualized_volatility": 0.1, "cagr": 0.1, "turnover": 1.0, "activity_observed": True} + better = {"max_drawdown": -0.1, "expected_shortfall_95": -0.05, "annualized_volatility": 9.0, "cagr": 0.2, "turnover": 1.0, "activity_observed": True} + inactive_candidate = {**better, "cagr": 99.0, "activity_observed": False} + validation = { + "UNSCALED_SMA200": [baseline] * 3, + "RSI2_ENTRY_5_EXIT_70": [better] * 3, + "RSI2_ENTRY_10_EXIT_70": [inactive_candidate] * 3, + "RSI2_ENTRY_15_EXIT_70": [better] * 3, + } + assert _select_rsi2_mean_reversion_winner(validation) == "RSI2_ENTRY_5_EXIT_70" + validation["UNSCALED_SMA200"] = [{**baseline, "activity_observed": False}] * 3 + assert _select_rsi2_mean_reversion_winner(validation) is None + + +def test_rsi2_wfa_requires_complete_same_window_predicates() -> None: + baseline = {"max_drawdown": -0.2, "expected_shortfall_95": -0.1} + qualifying = {"activity_observed": True, "cumulative_return": 0.01, "max_drawdown": -0.1, "expected_shortfall_95": -0.05} + split = [ + {**qualifying, "cumulative_return": -0.01}, + {**qualifying, "max_drawdown": -0.3}, + {**qualifying, "expected_shortfall_95": -0.2}, + ] + assert _rsi2_wfa_qualifying_count(split, [baseline] * 3) == 0 + assert _rsi2_wfa_qualifying_count([qualifying, qualifying, split[0]], [baseline] * 3) == 2 + + +def test_rsi2_zero_activity_rejection_is_fail_closed_and_consistent(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("us_equity_strategies.research.soxl_core_optimization._terminal_loss_probability", lambda _: pytest.fail("zero activity must not reach Monte Carlo")) + result = run_soxl_rsi2_mean_reversion(_source([1000.0 - index for index in range(753)])) + assert result["outcome"] == "NO_IMPROVEMENT" + assert result["locked_winner"] is None + assert result["research_recommendation"] is None + assert result["recommendation_eligible"] is False and result["r4a_eligible"] is False + assert result["acceptance_classes"] == { + "activity": {"selection_windows_all_active": False, "wfa_qualifying_test_window_count": 0, "wfa_at_least_two_of_three": False}, + "benchmark_relative_compounding": {"final_holdout_drawdown_no_worse_than_soxx": False, "final_holdout_cumulative_return_strictly_greater_than_soxx": False, "final_holdout_cagr_strictly_greater_than_soxx": False, "eligible": False}, + } + assert "VALIDATION_MEDIANS_STRICTLY_IMPROVE_DRAWDOWN_AND_ES95" in result["failure_codes"] + assert "SELECTION_WINDOWS_ALL_ACTIVE" in result["failure_codes"] + assert "FINAL_HOLDOUT_BENCHMARK_RELATIVE_COMPOUNDING" in result["failure_codes"] + assert result["evidence_gates"]["wfa_tests_at_least_two_simultaneous_predicates"] is False