Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions backend/src/stocksweeper/forecast/physical_contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
35 changes: 34 additions & 1 deletion backend/tests/test_physical_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions backend/tests/test_physical_contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/ItmChain.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading