diff --git a/README.md b/README.md index 1417ae7..bbd8f5f 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ Use **Watch** on a desktop or mobile chain row, then open `/watchlist`. Watches Before expiry, the chain and watchlist lead with a dated **stock forecast** showing ITM and OTM odds for the real-world expiry-session close. The event is `close > strike` for a call and `close < strike` for a put. Equality remains a separate outcome in the model and API, so the two displayed percentages can total less than 100%. Completed Yahoo `Close` history gives the dated physical forecast. The default is a zero-drift lognormal distribution with 60-session EWMA volatility. Compare models also shows volatility-scaled empirical, Student-t EWMA, GJR-GARCH Student-t, daily-OHLC range/HAR proxy, skewed-t EWMA, EGARCH skewed-t, two-regime switching variance, pooled NGBoost, earnings-jump, IV-informed, and intraday results. A user-selected physical model drives the compact odds and hypothetical risk; no model is automatically chosen from past scores. The new historical methods support 1–25 sessions, while EWMA supports up to one year. The latest completed split-safe bar and verified cache are required. Rights-dependent methods remain visibly unavailable until their input provenance qualifies. Each result shows its availability, input and fit diagnostics, and separately measured accuracy when matured outcomes exist. The comparison reports independent empirical history blocks, a maximum 95% Monte Carlo error for simulated odds, and the spread between comparable completed-close methods. Those describe sampling or method sensitivity, not predictive accuracy. Missing evidence is N/A, not forecast confidence. +Student-t EWMA bounds each simulated terminal price to 1%–100× the starting close to prevent unbounded price tails from dominating full-distribution scores. This changes its model version, so earlier Student-t evidence is not treated as current-version accuracy. + **Market-implied odds** are displayed separately as risk-neutral option-price context, not as a substitute for the physical forecast. The `regimelib` estimator fits one two-state distribution to validated call quotes using the Treasury rate for each expiry, then prices a cash digital. A separate per-expiry decreasing, convex call-price curve is also shown when its quote and one-tick stability checks pass. SSVI is listed separately but remains unavailable until coherent, rights-cleared multi-expiry quotes and defensible American-exercise and dividend treatment exist. Wide bounds remain visible; quote tightness and held-out quote fit describe market-input robustness, not realized forecast accuracy. Sparse or contradictory strips cannot provide a precise market probability. American exercise, dividends, and missing individual quote timestamps still limit interpretation. The physical and risk-neutral probabilities are never combined into a recommendation score. Visible chain pages refresh odds about every five minutes during the regular trading session; a visible watchlist polls for updated cached odds. If a later watchlist poll fails, the last loaded list stays visible with a dated warning and Retry action. After hours, quote-implied valuation uses the latest completed session's official daily close and its dated rate. A watched contract's last valid market value from the latest completed session remains visible as dated context across refreshes and restarts, then disappears when a newer session completes. The compact stock summary shows its quote time, and market-session labels describe the source state at fetch. The fetch time is a snapshot time, not a claim about each option's quote timestamp; fetched and retrieved times include the browser's local timezone, while market quote time is labeled ET. Legacy strategy forecast snapshots remain in the local database for historical continuity but are never served as current odds. **Check expiry results** requests a separate background close-based outcome update. diff --git a/backend/src/stocksweeper/forecast/physical_contest.py b/backend/src/stocksweeper/forecast/physical_contest.py index c3794b4..972f8ca 100644 --- a/backend/src/stocksweeper/forecast/physical_contest.py +++ b/backend/src/stocksweeper/forecast/physical_contest.py @@ -38,7 +38,7 @@ SCENARIOS = 4096 MAX_HORIZON = 25 -STUDENT_VERSION = "student-t-ewma60-shadow-v1" +STUDENT_VERSION = "student-t-ewma60-shadow-v2" GJR_VERSION = "gjr-garch11-t-shadow-v1" EMPIRICAL_SHADOW_VERSION = "empirical-ewma60-shadow-v1" HAR_VERSION = "ohlc-har-proxy-v1" @@ -182,6 +182,10 @@ def _student_terminal( realized = daily * shock total += realized daily = np.sqrt(_EWMA_DECAY * daily**2 + (1 - _EWMA_DECAY) * realized**2) + if not np.isfinite(total).all(): + raise ValueError("student scenarios invalid") + # The unbounded t tail has no finite price mean after exponentiation. + total = np.clip(total, -log(100), log(100)) with np.errstate(over="ignore", invalid="ignore", under="ignore"): terminal = spot * np.exp(total) if not np.isfinite(terminal).all() or np.any(terminal <= 0): diff --git a/backend/src/stocksweeper/forecast/sec_events.py b/backend/src/stocksweeper/forecast/sec_events.py index 38d5449..65e3df3 100644 --- a/backend/src/stocksweeper/forecast/sec_events.py +++ b/backend/src/stocksweeper/forecast/sec_events.py @@ -160,6 +160,7 @@ def _document_text(content: bytes) -> str: def _event_date(text: str) -> date | None: normalized = text.replace(".", "").replace(",", "") + normalized = re.sub(r"^sept(?=\s)", "Sep", normalized, flags=re.IGNORECASE) for layout in ("%B %d %Y", "%b %d %Y"): try: return datetime.strptime(normalized, layout).date() @@ -199,8 +200,10 @@ def parse_forward_schedule( return None context = text[max(0, match.start() - 100) : min(len(text), match.end() + 100)] # A nearby filing timestamp must not become the announced event time. - after_date = text[match.end() : min(len(text), match.end() + 100)].split(".", 1)[0] + after_date = text[match.end() : min(len(text), match.end() + 100)] local_time = _TIME.search(after_date) + if local_time and "." in after_date[: local_time.start()]: + local_time = None event_at = None if local_time: hour = int(local_time.group(1)) % 12 + ( diff --git a/backend/tests/test_forecast_provenance.py b/backend/tests/test_forecast_provenance.py index cce6937..496bb3b 100644 --- a/backend/tests/test_forecast_provenance.py +++ b/backend/tests/test_forecast_provenance.py @@ -377,6 +377,45 @@ def _results(day: str) -> bytes: ).encode() +def test_sec_sept_abbreviation_is_accepted_for_schedule_and_actual(): + accepted = datetime(2026, 9, 1, 16, tzinfo=UTC) + schedule = parse_forward_schedule( + "ACME", 12345, "0000012345-26-000001", accepted, + accepted + timedelta(minutes=5), "ex99-1.htm", _announcement("Sept. 30, 2026"), + ) + assert schedule is not None and schedule.event_date == date(2026, 9, 30) + actual_accepted = datetime(2026, 10, 1, 16, tzinfo=UTC) + actual = parse_actual_results( + "ACME", 12345, "0000012345-26-000002", actual_accepted, + actual_accepted + timedelta(minutes=5), "ex99-1.htm", _results("Sept. 30, 2026"), + ) + assert actual is not None and actual.event_date == date(2026, 9, 30) + + +def test_sec_dotted_meridiem_preserves_announced_time(): + accepted = datetime(2026, 9, 28, 16, tzinfo=UTC) + for meridiem, hour in (("a.m.", 9), ("p.m.", 21)): + schedule = parse_forward_schedule( + "ACME", 12345, "0000012345-26-000001", accepted, + accepted + timedelta(minutes=5), "ex99-1.htm", + _announcement("November 5, 2026").replace(b"PM", meridiem.encode()), + ) + assert schedule is not None + assert schedule.event_at == datetime(2026, 11, 5, hour, 30, tzinfo=UTC) + + +def test_sec_dotted_meridiem_does_not_take_next_sentence_time(): + accepted = datetime(2026, 9, 28, 16, tzinfo=UTC) + for separator in (b" ", b""): + schedule = parse_forward_schedule( + "ACME", 12345, "0000012345-26-000001", accepted, + accepted + timedelta(minutes=5), "ex99-1.htm", + b"
Acme will report earnings on November 5, 2026 at 4 p.m." + + separator + b"Conference call at 8:00 AM ET.
", + ) + assert schedule is not None and schedule.event_at is None + + def test_sec_schedule_revisions_obey_retrieval_knowledge_time(tmp_path): accepted = datetime(2026, 9, 28, 16, tzinfo=UTC) first = parse_forward_schedule( diff --git a/backend/tests/test_physical_contest.py b/backend/tests/test_physical_contest.py index f5bc3fe..1f31582 100644 --- a/backend/tests/test_physical_contest.py +++ b/backend/tests/test_physical_contest.py @@ -20,11 +20,13 @@ from stocksweeper.forecast.physical_contest import ( PhysicalShadowForecaster, ShadowForecast, + STUDENT_VERSION, _fit_gjr, _fit_har, _fit_arch_skew, _fit_markov, _gjr_terminal, + _seed, _student_terminal, ) from stocksweeper.forecast.physical_evaluation import ContestRow, crps, evaluate_band @@ -161,6 +163,21 @@ def test_gjr_requires_long_split_safe_history_and_scenarios_are_seeded(): ) +def test_student_tail_scenarios_are_bounded_and_versioned(): + # Parameters from frozen HSDT history at the 2024-01-17 origin. + spot = 5400.0 + prices = _student_terminal( + spot, + 0.05511406276449384, + 25, + (2.5562815487166555, 0.6221084805818157), + _seed("HSDT", date(2024, 1, 17), "student_t_ewma", 25), + ) + assert prices[0] >= spot / 100 * (1 - 1e-12) + assert prices[-1] <= spot * 100 * (1 + 1e-12) + assert STUDENT_VERSION == "student-t-ewma60-shadow-v2" + + def test_singular_candidate_fit_reports_only_that_model_unavailable(monkeypatch): import arch.univariate import statsmodels.tsa.regime_switching.markov_regression as markov_regression diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts index c6ef331..d8f9265 100644 --- a/frontend/src/App.test.ts +++ b/frontend/src/App.test.ts @@ -86,6 +86,8 @@ describe("formatters", () => { expect(moneyCents(Number.NaN)).toBe("—") expect(moneyCents(0)).toBe("$0.00") expect(moneyCents(-15601)).toBe("-$156.01") + expect(moneyCents(Number.MAX_SAFE_INTEGER)).toBe("$90,071,992,547,409.91") + expect(moneyCents(-Number.MAX_SAFE_INTEGER)).toBe("-$90,071,992,547,409.91") expect(percentTenths(null)).toBe("—") expect(percentTenths(0)).toBe("0.0%") expect(percentTenths(Number.POSITIVE_INFINITY)).toBe("—") diff --git a/frontend/src/ItmChain.browser.test.tsx b/frontend/src/ItmChain.browser.test.tsx index b995835..0dd6913 100644 --- a/frontend/src/ItmChain.browser.test.tsx +++ b/frontend/src/ItmChain.browser.test.tsx @@ -8,7 +8,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { ApiError, fetchChain, fetchTickers } from "./api" import { formatContractValues } from "./columns" import { formatRowClipboard } from "./copyRow" +import { setDensity } from "./density" import ItmChain from "./ItmChain" +import { setThemePreference } from "./theme" import { COLUMN_HEADERS, COPY_HEADERS, largeChainPage, sampleContract, samplePage, samplePutPage } from "./testFixtures" import type { CoveredCallPage } from "./types" import { addWatch } from "./watchlist/api" @@ -1160,6 +1162,51 @@ describe("chain interactions", () => { expect(window.localStorage.getItem("theme")).toBe("light") }) + it("keeps theme and density usable when localStorage is denied", async () => { + const original = Object.getOwnPropertyDescriptor(window, "localStorage")! + Object.defineProperty(window, "localStorage", { + configurable: true, + get() { throw new DOMException("Storage blocked", "SecurityError") }, + }) + try { + render(