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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion backend/src/stocksweeper/forecast/physical_contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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):
Expand Down
5 changes: 4 additions & 1 deletion backend/src/stocksweeper/forecast/sec_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 + (
Expand Down
39 changes: 39 additions & 0 deletions backend/tests/test_forecast_provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<p>Acme will report earnings on November 5, 2026 at 4 p.m."
+ separator + b"Conference call at 8:00 AM ET.</p>",
)
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(
Expand Down
17 changes: 17 additions & 0 deletions backend/tests/test_physical_contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("—")
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/ItmChain.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(<ItmChain />)
await waitFor(() => expect(screen.getByRole("heading", { name: /2026-09-18/ })).toBeTruthy())
fireEvent.click(screen.getByRole("button", { name: "Theme: system" }))
expect(screen.getByRole("button", { name: "Theme: dark" })).toBeTruthy()
fireEvent.click(screen.getByRole("button", { name: "Density: comfortable" }))
expect(document.querySelector("table")?.getAttribute("data-density")).toBe("compact")
} finally {
cleanup()
Object.defineProperty(window, "localStorage", original)
setThemePreference("system")
setDensity("comfortable")
}
})

it("keeps preference changes when localStorage writes fail", async () => {
const original = Object.getOwnPropertyDescriptor(window, "localStorage")!
Object.defineProperty(window, "localStorage", {
configurable: true,
value: {
getItem(key: string) { return key === "theme" ? "system" : null },
setItem() { throw new DOMException("Storage full", "QuotaExceededError") },
},
})
try {
render(<ItmChain />)
await waitFor(() => expect(screen.getByRole("heading", { name: /2026-09-18/ })).toBeTruthy())
fireEvent.click(screen.getByRole("button", { name: "Theme: system" }))
expect(screen.getByRole("button", { name: "Theme: dark" })).toBeTruthy()
fireEvent.click(screen.getByRole("button", { name: "Density: comfortable" }))
expect(document.querySelector("table")?.getAttribute("data-density")).toBe("compact")
} finally {
cleanup()
Object.defineProperty(window, "localStorage", original)
setThemePreference("system")
setDensity("comfortable")
}
})

it("selects a ticker from the keyboard", async () => {
render(<ItmChain />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
Expand Down
21 changes: 18 additions & 3 deletions frontend/src/density.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@ export type Density = "compact" | "comfortable"

const STORAGE_KEY = "density"
const listeners = new Set<() => void>()
let sessionDensity: Density | null = null
let storageWriteFailed = false

function emit() {
for (const listener of listeners) listener()
}

function readDensity(): Density {
if (typeof window === "undefined") return "comfortable"
return window.localStorage.getItem(STORAGE_KEY) === "compact" ? "compact" : "comfortable"
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
if (storageWriteFailed && sessionDensity !== null) return sessionDensity
sessionDensity = null
return stored === "compact" ? "compact" : "comfortable"
} catch { return sessionDensity ?? "comfortable" }
}

export function getDensity(): Density {
Expand All @@ -20,15 +27,23 @@ export function getDensity(): Density {

export function setDensity(density: Density) {
if (typeof window === "undefined") return
window.localStorage.setItem(STORAGE_KEY, density)
sessionDensity = density
try {
window.localStorage.setItem(STORAGE_KEY, density)
storageWriteFailed = false
} catch { storageWriteFailed = true }
emit()
}

function subscribe(listener: () => void) {
listeners.add(listener)
if (typeof window === "undefined") return () => listeners.delete(listener)
const onStorage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY) emit()
if (event.key === STORAGE_KEY) {
sessionDensity = null
storageWriteFailed = false
emit()
}
}
window.addEventListener("storage", onStorage)
return () => {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/format.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const dollars = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
})

export function moneyCents(value: number | null | undefined): string {
Expand All @@ -9,7 +10,7 @@ export function moneyCents(value: number | null | undefined): string {
const abs = Math.abs(value)
const whole = Math.trunc(abs / 100)
const frac = abs % 100
const formatted = dollars.format(whole + frac / 100)
const formatted = `${dollars.format(whole)}.${String(frac).padStart(2, "0")}`
if (!sign) return formatted
return formatted.startsWith("-") ? formatted : `-${formatted}`
}
Expand Down
18 changes: 15 additions & 3 deletions frontend/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ export type ThemePreference = "light" | "dark" | "system"

const STORAGE_KEY = "theme"
const listeners = new Set<() => void>()
let sessionPreference: ThemePreference | null = null
let storageWriteFailed = false

function emit() {
for (const listener of listeners) listener()
}

function readPreference(): ThemePreference {
if (typeof window === "undefined") return "system"
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored === "light" || stored === "dark" || stored === "system") return stored
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
if (storageWriteFailed && sessionPreference !== null) return sessionPreference
sessionPreference = null
if (stored === "light" || stored === "dark" || stored === "system") return stored
} catch { return sessionPreference ?? "system" }
return "system"
}

Expand All @@ -36,7 +42,11 @@ export function getThemePreference(): ThemePreference {

export function setThemePreference(preference: ThemePreference) {
if (typeof window === "undefined") return
window.localStorage.setItem(STORAGE_KEY, preference)
sessionPreference = preference
try {
window.localStorage.setItem(STORAGE_KEY, preference)
storageWriteFailed = false
} catch { storageWriteFailed = true }
applyClass(preference)
emit()
}
Expand All @@ -57,6 +67,8 @@ function subscribe(listener: () => void) {
}
const onStorage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY) {
sessionPreference = null
storageWriteFailed = false
applyClass(readPreference())
emit()
}
Expand Down
Loading