From 0228216783d943fd5c49195475ffdf1a8e24de24 Mon Sep 17 00:00:00 2001 From: Matt Faltyn Date: Sun, 27 Sep 2026 22:31:08 +0200 Subject: [PATCH] Fix long-horizon forecast evidence and filter parsing --- .../stocksweeper/forecast/physical_contest.py | 9 +++-- backend/tests/test_physical_capture.py | 35 ++++++++++++++++++- backend/tests/test_physical_contest.py | 20 +++++++++++ frontend/src/App.test.ts | 8 +++++ frontend/src/ItmChain.browser.test.tsx | 5 +++ frontend/src/filters.ts | 3 ++ 6 files changed, 76 insertions(+), 4 deletions(-) diff --git a/backend/src/stocksweeper/forecast/physical_contest.py b/backend/src/stocksweeper/forecast/physical_contest.py index 29c6f10..32fa268 100644 --- a/backend/src/stocksweeper/forecast/physical_contest.py +++ b/backend/src/stocksweeper/forecast/physical_contest.py @@ -326,9 +326,6 @@ def forecast_candidates( return {name: ShadowForecast(None, current.reason, 0, 0) for name in names} assert current.spot is not None and current.daily_volatility is not None horizon = current.horizon_sessions - if horizon > MAX_HORIZON: - reason = "shadow_horizon_unsupported" - return {name: ShadowForecast(None, reason, 0, 0) for name in names} started = perf_counter() baseline_prices = tuple( sorted( @@ -347,6 +344,12 @@ def forecast_candidates( results = { "lognormal_ewma": ShadowForecast(baseline, None, 0, (perf_counter() - started) * 1000) } + if horizon > MAX_HORIZON: + results.update({ + name: ShadowForecast(None, "shadow_horizon_unsupported", 0, 0) + for name in names[1:] + }) + return results try: frame = self.forecaster.prices.read(ticker) assert frame is not None diff --git a/backend/tests/test_physical_capture.py b/backend/tests/test_physical_capture.py index c9359fd..259613f 100644 --- a/backend/tests/test_physical_capture.py +++ b/backend/tests/test_physical_capture.py @@ -16,7 +16,7 @@ from options_api.outcomes import TERMS_NOTE from options_api.physical_shadow_capture import MODEL_VERSIONS, PhysicalShadowCapture from stocksweeper.forecast.ledger import ForecastIssuance, ForecastLabel, ForecastLedger -from stocksweeper.forecast.physical_contest import ShadowForecast +from stocksweeper.forecast.physical_contest import PhysicalShadowForecaster, ShadowForecast from stocksweeper.forecast.calendar import SessionCalendar from stocksweeper.forecast.evidence_reports import ledger_band_rows from stocksweeper.forecast.physical_evaluation import evaluate_band @@ -193,6 +193,39 @@ def test_one_challenger_rejection_does_not_erase_available_baseline(tmp_path) -> assert rows["gjr_garch_t"]["unavailable_reason"] == "gjr_nonconverged" +def test_long_horizon_capture_does_not_record_false_baseline_outage(tmp_path) -> None: + expiry = date(2026, 11, 20) + distribution = replace( + _distribution("lognormal_ewma"), expiry_session=expiry, horizon_sessions=40 + ) + issue = replace( + _issue(), + contract_key=make_watch_key("IREN", "IREN", "call", expiry.isoformat(), Decimal(100)), + expiration=expiry, + expiry_session=expiry, + method="lognormal_ewma", + model_version=MODEL_VERSIONS["lognormal_ewma"], + itm_probability=0.5, + otm_probability=0.5, + atm_probability=0.0, + ) + capture = _capture(tmp_path) + capture.predictive.ledger.record_batch([(issue, distribution)]) + capture.forecaster = PhysicalShadowForecaster( + SimpleNamespace(forecast=lambda *_args, **_kwargs: distribution) + ) + capture._capture_sync([(issue, distribution)]) + + coverage = ForecastLedger(tmp_path).coverage() + baseline = [row for row in coverage if row["model_version"] == distribution.model_version] + assert baseline and all(row["status"] == "available" for row in baseline) + assert { + row["unavailable_reason"] + for row in coverage + if row["model_version"] != distribution.model_version + } == {"shadow_horizon_unsupported"} + + def test_shadow_failure_cannot_mask_valid_live_method(tmp_path) -> None: capture = _capture(tmp_path) base = _distribution("empirical_scaled") diff --git a/backend/tests/test_physical_contest.py b/backend/tests/test_physical_contest.py index 8bbca96..d440cb3 100644 --- a/backend/tests/test_physical_contest.py +++ b/backend/tests/test_physical_contest.py @@ -106,6 +106,26 @@ def test_gjr_requires_long_split_safe_history_and_scenarios_are_seeded(): ) +@pytest.mark.parametrize("horizon", (25, 26, 40)) +def test_baseline_remains_available_beyond_challenger_horizon(tmp_path, horizon): + session = date(2026, 9, 25) + forecaster = PredictiveForecaster(tmp_path, _Prices(_bars(90, session))) + forecaster.prepare("AAPL", session) + expiry = SessionCalendar().sessions(date(2026, 9, 28), date(2026, 12, 31))[horizon - 1] + now = datetime(2026, 9, 26, 12, tzinfo=UTC) + assert forecaster.forecast("AAPL", now, expiry).status == "available" + + forecasts = PhysicalShadowForecaster(forecaster).forecast_candidates("AAPL", now, expiry) + assert forecasts["lognormal_ewma"].distribution is not None + assert forecasts["lognormal_ewma"].reason is None + if horizon > 25: + assert all( + forecasts[name].distribution is None + and forecasts[name].reason == "shadow_horizon_unsupported" + for name in ("empirical_scaled", "student_t_ewma", "gjr_garch_t") + ) + + def test_invalid_candidate_scenarios_leave_baseline_available(tmp_path, monkeypatch): session = date(2026, 9, 25) forecaster = PredictiveForecaster(tmp_path, _Prices(_bars(220, session))) diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts index 345bae2..c6ef331 100644 --- a/frontend/src/App.test.ts +++ b/frontend/src/App.test.ts @@ -205,6 +205,14 @@ describe("row filters", () => { expect(parseThreshold("NaN")).toBeNull() }) + it("rejects formatting that changes the meaning of a threshold", () => { + for (const value of ["10%5", "1,2", "1,,000", "1 0", "40$", "%%40"]) { + expect(parseThreshold(value)).toBeNull() + } + expect(parseThreshold("$-40.5")).toEqual({ value: -405n, scale: 1 }) + expect(parseThreshold("1,000.50%")).toEqual({ value: 100050n, scale: 2 }) + }) + it("fails only the matching active filter for null and non-finite metrics", () => { const missing = { called_pnl_cents: null, simple_apr_pct_tenths: null, drop_to_breakeven_pct_tenths: 192, dte: 7 } const nonFinite = { called_pnl_cents: Number.NaN, simple_apr_pct_tenths: Number.POSITIVE_INFINITY, drop_to_breakeven_pct_tenths: 102, dte: 7 } diff --git a/frontend/src/ItmChain.browser.test.tsx b/frontend/src/ItmChain.browser.test.tsx index 1a89ebc..58e117e 100644 --- a/frontend/src/ItmChain.browser.test.tsx +++ b/frontend/src/ItmChain.browser.test.tsx @@ -774,6 +774,11 @@ describe("chain interactions", () => { expect(screen.getByRole("button", { name: /Filters/ }).textContent).toContain("Fix 1") expect(screen.getByRole("button", { name: "Clear all" })).toBeTruthy() + fireEvent.change(screen.getByLabelText("Min APR net (%)"), { target: { value: "10%5" } }) + expect(screen.getByLabelText("Min APR net (%)").getAttribute("aria-invalid")).toBe("true") + expect(screen.getByRole("button", { name: "Clear invalid input APR net (%) 10%5" })).toBeTruthy() + expect(screen.getByRole("button", { name: /Filters/ }).textContent).toContain("Fix 1") + fireEvent.change(screen.getByLabelText("Min Called P&L ($)"), { target: { value: "0" } }) expect(screen.getAllByText("$50.00")[0]).toBeTruthy() expect(screen.queryByText("$40.50")).toBeNull() diff --git a/frontend/src/filters.ts b/frontend/src/filters.ts index de91364..84ac1b2 100644 --- a/frontend/src/filters.ts +++ b/frontend/src/filters.ts @@ -2,7 +2,10 @@ import { meetsScaledMaximum, meetsScaledMinimum, parseExactToken, type ExactDeci import { STRATEGIES } from "./strategy" import type { Side } from "./types" +const THRESHOLD_TOKEN = /^\s*(?:[+-]?\s*\$?|\$\s*[+-]?)\s*(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?\s*%?\s*$/ + export function parseThreshold(raw: string): ExactDecimal | null { + if (!THRESHOLD_TOKEN.test(raw)) return null return parseExactToken(raw, /[$%,\s]/g) }