>
@@ -123,9 +131,9 @@ function DesktopResults({
{active ? (sort.dir === "asc" ? : ) : null}
- {index === 0 ?
+ {index === 0 ?
onSort(PREDICTIVE_ODDS_SORT_ID)}>
- Expiry odds
+ Expiry odds · {physicalModelName(forecastModel)}
{sort.id === PREDICTIVE_ODDS_SORT_ID ? (sort.dir === "asc" ? : ) : null}
: null}
@@ -153,6 +161,7 @@ function DesktopResults({
{index === 0 ?
+
{!predictiveAvailable(row.predictive_odds) && row.predictive_odds?.status !== "pending" ? (
Why forecast unavailable? {row.predictive_odds?.reason || "No validated forecast is available for this contract."}
) : null}
@@ -203,6 +212,8 @@ function MobileResults({
ranges,
copiedKey,
sort,
+ forecastModel,
+ modelEvidence,
onSort,
onCopy,
watchStates,
@@ -218,7 +229,7 @@ function MobileResults({
Sort by
onSort(event.target.value)}>
- Stock forecast ITM odds
+ {physicalModelName(forecastModel)} ITM odds
{columns.map((column) => {column.label} )}
@@ -251,6 +262,7 @@ function MobileResults({
+
{!predictiveAvailable(predictive) && predictive?.status !== "pending" ? Forecast unavailable: {predictive?.reason || "No validated forecast is available for this contract."}
: null}
{!oddsAvailable(odds) && odds?.status !== "pending" ? Market odds unavailable: {oddsMessage(odds)}
: null}
@@ -302,6 +314,8 @@ export default function ExpiryTables({
copiedKey,
sort,
density,
+ forecastModel,
+ modelEvidence,
onToggleExpiration,
onSort,
onCopy,
@@ -349,6 +363,8 @@ export default function ExpiryTables({
ranges={ranges}
copiedKey={copiedKey}
sort={sort}
+ forecastModel={forecastModel}
+ modelEvidence={modelEvidence}
onSort={onSort}
onCopy={onCopy}
watchStates={watchStates}
@@ -364,6 +380,8 @@ export default function ExpiryTables({
copiedKey={copiedKey}
sort={sort}
density={density}
+ forecastModel={forecastModel}
+ modelEvidence={modelEvidence}
onSort={onSort}
onCopy={onCopy}
watchStates={watchStates}
diff --git a/frontend/src/ItmChain.browser.test.tsx b/frontend/src/ItmChain.browser.test.tsx
index 49a28fb..1a89ebc 100644
--- a/frontend/src/ItmChain.browser.test.tsx
+++ b/frontend/src/ItmChain.browser.test.tsx
@@ -139,7 +139,7 @@ describe("chain interactions", () => {
const headers = within(first!).getAllByRole("columnheader")
expect(headers.map((header) => header.textContent)).toEqual([
"Strike",
- "Expiry odds",
+ "Expiry odds · EWMA lognormal",
"Bid",
"Sprd %",
"OI",
@@ -232,7 +232,7 @@ describe("chain interactions", () => {
expect(rows[1].querySelector(".odds-cell")?.textContent).toContain("Stock close · Sep 18, 2026")
expect(rows[1].querySelector(".odds-cell")?.textContent).not.toContain("ATM")
expect(rows[1].querySelector(".odds-cell")?.textContent).not.toContain("Reliability not yet established")
- expect(screen.getByText(/Reliability is not yet established unless shown/)).toBeTruthy()
+ expect(screen.getByText(/Selected forecast: EWMA lognormal baseline/)).toBeTruthy()
expect(rows[1].querySelector(".odds-cell")?.textContent).toContain("Why market odds unavailable?")
expect(screen.getByRole("columnheader", { name: "Est P&L" })).toBeTruthy()
expect(screen.getByRole("columnheader", { name: "Loss odds" })).toBeTruthy()
@@ -259,13 +259,44 @@ describe("chain interactions", () => {
}))
fetchMock.mockResolvedValue(oddsPage)
render( )
- await screen.findByRole("button", { name: "Expiry odds" })
- fireEvent.click(screen.getByRole("button", { name: "Expiry odds" }))
+ await screen.findByRole("button", { name: "Expiry odds · EWMA lognormal" })
+ fireEvent.click(screen.getByRole("button", { name: "Expiry odds · EWMA lognormal" }))
const rows = screen.getAllByRole("row")
expect(rows[1].firstChild?.textContent).toBe("$40.50")
expect(rows[1].querySelector(".odds-physical")?.textContent).toContain("70.0% ITM")
expect(rows[1].querySelector(".odds-market")?.textContent).toContain("10.0% ITM")
- expect(screen.getByRole("columnheader", { name: "Expiry odds" }).getAttribute("aria-sort")).toBe("descending")
+ expect(screen.getByRole("columnheader", { name: "Expiry odds · EWMA lognormal" }).getAttribute("aria-sort")).toBe("descending")
+ })
+
+ it("sorts the selected experimental forecast and keeps mobile model comparison outside the row trigger", async () => {
+ setDesktopViewport(false)
+ const oddsPage = page()
+ oddsPage.expirations[0].contracts.forEach((row, index) => Object.assign(row, {
+ predictive_odds: {
+ method: "student_t_ewma", status: "available",
+ itm_pct_tenths: index === 0 ? 200 : 700, otm_pct_tenths: index === 0 ? 800 : 300,
+ atm_pct_tenths: 0, as_of_session: "2026-09-17", expiry_session: "2026-09-18",
+ },
+ physical_models: [
+ { method: "lognormal_ewma", status: "available", itm_pct_tenths: 500, otm_pct_tenths: 500, atm_pct_tenths: 0 },
+ { method: "student_t_ewma", status: "available", itm_pct_tenths: index === 0 ? 200 : 700, otm_pct_tenths: index === 0 ? 800 : 300, atm_pct_tenths: 0 },
+ ],
+ market_models: [{ method: "regimelib", status: "pending", reason: "quotes_pending" }],
+ }))
+ fetchMock.mockResolvedValue(oddsPage)
+ render( )
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("IREN", "call", "itm", "student_t_ewma"))
+ const sort = screen.getByRole("combobox", { name: "Sort by" })
+ fireEvent.change(sort, { target: { value: "predictive_itm_pct_tenths" } })
+ const first = document.querySelector(".mobile-option-row")!
+ expect(first.textContent).toContain("70.0% ITM")
+ expect(first.textContent).toContain("Student-t EWMA")
+ const trigger = first.querySelector(".mobile-row-summary")!
+ const compare = first.querySelector(".mobile-model-compare details")!
+ expect(trigger.contains(compare)).toBe(false)
+ fireEvent.click(compare.querySelector("summary")!)
+ expect((compare as HTMLDetailsElement).open).toBe(true)
+ expect(compare.textContent).toContain("quotes_pending")
})
it("includes odds in the mobile contract summary before expanding details", async () => {
@@ -315,6 +346,16 @@ describe("chain interactions", () => {
expect(disabled.hasAttribute("disabled")).toBe(true)
})
+ it("sends the selected model when creating a watch", async () => {
+ const watchPage = page()
+ Object.assign(watchPage.expirations[0].contracts[0], { watch_key: "opaque-contract-key", watchability_reason: null })
+ fetchMock.mockResolvedValue(watchPage)
+ addWatchMock.mockResolvedValue({ created: true, item: {} as never, job: null })
+ render( )
+ fireEvent.click(await screen.findByRole("button", { name: "Watch IREN 2026-09-18 $50.00 strike" }))
+ expect(addWatchMock).toHaveBeenCalledWith("opaque-contract-key", "gjr_garch_t")
+ })
+
it("shows a mobile Watch action and per-contract request errors", async () => {
setDesktopViewport(false)
const watchPage = page()
@@ -990,12 +1031,12 @@ describe("chain interactions", () => {
render( )
await waitFor(() => expect(screen.getByRole("columnheader", { name: "IV" })).toBeTruthy())
- expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual(["Strike", "Expiry odds", "IV", "Watch", "Copy"])
+ expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual(["Strike", "Expiry odds · EWMA lognormal", "IV", "Watch", "Copy"])
fireEvent.click(screen.getByRole("radio", { name: "Cash-secured puts" }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("IREN", "put", "otm"))
expect(screen.queryByRole("columnheader", { name: "IV" })).toBeNull()
expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([
- "Strike", "Expiry odds", "Bid", "Sprd %", "OI", "Premium", "Breakeven", "APR (net)", "Cushion (BE)", "Watch", "Copy",
+ "Strike", "Expiry odds · EWMA lognormal", "Bid", "Sprd %", "OI", "Premium", "Breakeven", "APR (net)", "Cushion (BE)", "Watch", "Copy",
])
expect(window.location.search).not.toContain("cols=")
})
@@ -1020,7 +1061,7 @@ describe("chain interactions", () => {
await screen.findByRole("columnheader", { name: "IV" })
expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([
- "Strike", "Expiry odds", "IV", "Watch", "Copy",
+ "Strike", "Expiry odds · EWMA lognormal", "IV", "Watch", "Copy",
])
expect(screen.getByRole("button", { name: "Columns2" })).toBeTruthy()
expect(window.location.search).toContain("cols=strike_cents%2Civ_pct_tenths")
diff --git a/frontend/src/ItmChain.tsx b/frontend/src/ItmChain.tsx
index 17b5617..b544d13 100644
--- a/frontend/src/ItmChain.tsx
+++ b/frontend/src/ItmChain.tsx
@@ -8,6 +8,7 @@ import CommandBar, { type SessionInfo } from "./CommandBar"
import { formatContractValues, visibleColumns } from "./columns"
import { contractCountIsSafe, contractSizeLabel, parseContractCount } from "./contracts"
import { plural } from "./format"
+import { DEFAULT_FORECAST_MODEL, PHYSICAL_MODEL_NAMES, type PhysicalModel } from "./forecastModels"
import { copyRowStateKey, formatRowClipboard } from "./copyRow"
import { useDensity } from "./density"
import ExpiryTables from "./ExpiryTable"
@@ -59,11 +60,11 @@ function firstDatedOdds(page: ChainPage | null): MarketOdds | null {
return fallback
}
-export default function ItmChain() {
+export default function ItmChain({ forecastModel = DEFAULT_FORECAST_MODEL }: { forecastModel?: PhysicalModel }) {
const { state, setState } = useUrlState()
const { ticker, side, moneyness, cols } = state
const identityKey = `${ticker}|${side}|${moneyness}`
- const { page, error, loading, beginTickerChange, beginRefresh } = useChainPage(ticker, side, moneyness)
+ const { page, error, loading, beginTickerChange, beginRefresh } = useChainPage(ticker, side, moneyness, forecastModel)
const [contractsText, setContractsText] = useState("")
const [copiedKey, setCopiedKey] = useState(null)
const [copiedNotice, setCopiedNotice] = useState("")
@@ -189,7 +190,9 @@ export default function ItmChain() {
const watchContract = async (watchKey: string) => {
setWatchStates((current) => ({ ...current, [watchKey]: { phase: "pending" } }))
try {
- const result = await addWatch(watchKey)
+ const result = forecastModel === DEFAULT_FORECAST_MODEL
+ ? await addWatch(watchKey)
+ : await addWatch(watchKey, forecastModel)
if (result.job) sessionStorage.setItem(WATCH_JOB_KEY, result.job.id)
setWatchStates((current) => ({ ...current, [watchKey]: { phase: result.created ? "added" : "existing" } }))
} catch (cause) {
@@ -236,7 +239,7 @@ export default function ItmChain() {
>
)}
- {page ? Stock forecasts use past closes. Reliability is not yet established unless shown. Market odds use option prices (risk-neutral). {oddsStamp ? `Market quote: ${oddsStamp}.` : ""}
: null}
+ {page ? Selected forecast: {PHYSICAL_MODEL_NAMES[forecastModel]}{forecastModel === DEFAULT_FORECAST_MODEL ? " baseline" : " · user-selected experimental"}. It drives expiry odds, odds sort, and hypothetical risk. Market odds use option prices (risk-neutral). {oddsStamp ? `Market quote: ${oddsStamp}.` : ""}
: null}
{page && columns.some((column) => column.greek) ? Greeks use the dated quote midpoint, Treasury rate, and time to the expiry-session close. European Black-Scholes estimates omit dividends and only approximate American equity options.
: null}
@@ -335,6 +338,8 @@ export default function ItmChain() {
expandedExpirations={expandedExpirations}
copiedKey={copiedKey}
sort={effectiveSort}
+ forecastModel={forecastModel}
+ modelEvidence={page?.model_evidence}
density={density}
onToggleExpiration={(expiration) => expansion.update((current) => {
const next = new Set(current)
diff --git a/frontend/src/ModelComparison.browser.test.tsx b/frontend/src/ModelComparison.browser.test.tsx
new file mode 100644
index 0000000..c00b73d
--- /dev/null
+++ b/frontend/src/ModelComparison.browser.test.tsx
@@ -0,0 +1,62 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react"
+import { afterEach, expect, it } from "vitest"
+import type { MarketOddsView, PredictiveOddsView } from "./generated/types.gen"
+import ModelComparison from "./ModelComparison"
+
+afterEach(cleanup)
+
+it("shows all physical and market methods, including pending and unavailable results", () => {
+ const physical: PredictiveOddsView[] = [
+ { method: "lognormal_ewma", status: "available", itm_pct_tenths: 600, otm_pct_tenths: 400, atm_pct_tenths: 0 },
+ { method: "empirical_scaled", status: "available", itm_pct_tenths: 630, otm_pct_tenths: 370, atm_pct_tenths: 0 },
+ { method: "student_t_ewma", status: "pending", reason: "candidate_not_prepared" },
+ { method: "gjr_garch_t", status: "unavailable", reason: "needs_500_sessions" },
+ { method: "intraday_shadow", status: "unavailable", reason: "stale_quote" },
+ ]
+ const market: MarketOddsView[] = [
+ { method: "regimelib", status: "available", itm_pct_tenths: 520, otm_pct_tenths: 480, bound_low_pct_tenths: 400, bound_high_pct_tenths: 600 },
+ { method: "constrained_call_curve", status: "unavailable", reason: "clean_strikes_do_not_bracket_contract" },
+ ]
+ const { container } = render( )
+ const disclosure = container.querySelector("details")!
+ expect(disclosure.open).toBe(false)
+ fireEvent.click(screen.getByText("Compare models"))
+ expect(disclosure.open).toBe(true)
+ expect(screen.getByRole("region", { name: "Physical forecast models" }).textContent).toContain("Student-t EWMA")
+ expect(screen.getByRole("region", { name: "Physical forecast models" }).textContent).toContain("Selected · experimental")
+ expect(screen.getByRole("region", { name: "Physical forecast models" }).textContent).toContain("needs_500_sessions")
+ expect(screen.getByRole("region", { name: "Risk-neutral market models" }).textContent).toContain("Regimelib")
+ expect(screen.getByRole("region", { name: "Risk-neutral market models" }).textContent).toContain("clean_strikes_do_not_bracket_contract")
+ expect(disclosure.textContent).toContain("Quote bounds")
+})
+
+it("keeps retrospective and as-issued evidence distinct and reports N=0 without confidence", () => {
+ const empty = {
+ generated_at: "2026-09-27T12:00:00Z", model_version: "student-v1", input_version: "ledger-v1",
+ tickers: 0, independent_date_blocks: 0, ticker_origin_horizon_units: 0,
+ contract_forecasts_available: 0, contract_cells_attempted: 0,
+ brier: { baseline: null, candidate: null, paired_delta: null, bootstrap_95: null },
+ log_loss: { baseline: null, candidate: null, paired_delta: null },
+ calibration_by_side: { call: [], put: [] }, latency_ms: {}, rejection_reasons: {},
+ }
+ const physical: PredictiveOddsView[] = [{
+ method: "student_t_ewma", status: "available", itm_pct_tenths: 610, otm_pct_tenths: 390, atm_pct_tenths: 0,
+ evidence_key: "student_t_ewma:2-5", model_evidence: null,
+ }]
+ const replay = { ...empty, independent_date_blocks: 20, ticker_origin_horizon_units: 500, tickers: 20, coverage_basis: "recorded_contract_cells_with_baseline_issuance", replay_scheduled_units: 25, replay_baseline_available_units: 20, replay_fit_coverage: 0.8, replay_rejection_reasons: { split_affected: 5 }, brier: { baseline: 0.22, candidate: 0.20, paired_delta: -0.02, bootstrap_95: [-0.03, -0.01] } }
+ render( )
+ fireEvent.click(screen.getByText("Compare models"))
+ const prospective = screen.getByRole("region", { name: "Prospective as-issued evidence" })
+ const replayPanel = screen.getByRole("region", { name: "Retrospective replay evidence" })
+ expect(prospective.textContent).toContain("N=0")
+ expect(prospective.textContent).toContain("Brier N/A")
+ expect(prospective.textContent).toContain("significance not estimable")
+ expect(replayPanel.textContent).toContain("Current-vintage screening")
+ expect(replayPanel.textContent).toContain("[-0.030, -0.010]")
+ expect(replayPanel.textContent).toContain("20/25 scheduled units (80.0%)")
+ expect(replayPanel.textContent).toContain("split_affected: 5")
+ expect(replayPanel.textContent).toContain("Call calibration: N=0")
+ expect(replayPanel.textContent).toContain("Put calibration: N=0")
+})
diff --git a/frontend/src/ModelComparison.tsx b/frontend/src/ModelComparison.tsx
new file mode 100644
index 0000000..0887406
--- /dev/null
+++ b/frontend/src/ModelComparison.tsx
@@ -0,0 +1,130 @@
+import { useState } from "react"
+import type { MarketOddsView, PredictiveOddsView } from "./generated/types.gen"
+import { DEFAULT_FORECAST_MODEL, marketModelName, physicalModelName, type PhysicalModel } from "./forecastModels"
+import { unsignedPercentTenths } from "./format"
+import { oddsAvailable, predictiveAvailable, quoteSupportLabel } from "./marketOdds"
+
+type Data = Record
+
+function data(value: unknown): Data | null {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Data : null
+}
+
+function number(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null
+}
+
+function count(value: unknown): string {
+ const n = number(value)
+ return n == null ? "0" : n.toLocaleString("en-US")
+}
+
+function score(value: unknown): string {
+ const n = number(value)
+ return n == null ? "N/A" : n.toFixed(3)
+}
+
+function milliseconds(value: unknown): string {
+ const n = number(value)
+ return n == null ? "N/A" : `${n.toFixed(0)} ms`
+}
+
+function percent(value: unknown): string {
+ const n = number(value)
+ return n == null ? "N/A" : `${(n * 100).toFixed(1)}%`
+}
+
+function reasonList(value: unknown): string {
+ const reasons = data(value)
+ return reasons && Object.keys(reasons).length
+ ? Object.entries(reasons).map(([name, amount]) => `${name}: ${count(amount)}`).join(" · ")
+ : "None recorded"
+}
+
+function evidencePanel(title: string, value: unknown) {
+ const report = data(value)
+ if (!report) return {title}: no report available. N=0 · Brier N/A · significance not estimable.
+ const brier = data(report.brier)
+ const logLoss = data(report.log_loss)
+ const blocks = number(report.independent_date_blocks) ?? 0
+ const units = number(report.ticker_origin_horizon_units) ?? 0
+ const attempted = number(report.contract_cells_attempted) ?? 0
+ const available = number(report.contract_forecasts_available) ?? 0
+ const interval = brier?.bootstrap_95
+ const intervalText = report.significance === "not_applicable" ? "not applicable for the baseline"
+ : blocks >= 20 && Array.isArray(interval) && interval.length === 2
+ ? `[${score(interval[0])}, ${score(interval[1])}]` : "significance not estimable"
+ const calibration = data(report.calibration_by_side)
+ const latency = data(report.latency_ms)
+ return
+ {title}
+ {title === "Retrospective replay" ? "Current-vintage screening; not an as-issued accuracy claim." : "Prospective as-issued observations; descriptive until promotion gates pass."}
+ Report {typeof report.generated_at === "string" ? report.generated_at.slice(0, 10) : "date unavailable"} · model {String(report.model_version ?? "unknown")} · input {String(report.input_version ?? "unknown")} · report hash {typeof report.report_hash === "string" ? report.report_hash.slice(0, 12) : "unrecorded"}
+ {typeof report.audit_session === "string" ? Frozen audit cohort: {report.audit_session}{typeof report.audit_frozen_at === "string" ? ` · captured ${report.audit_frozen_at.slice(0, 10)}` : ""}
: null}
+ {count(report.tickers)} tickers · {count(blocks)} independent date blocks · N={count(units)} ticker-origin-horizon units
+ Coverage {count(available)}/{count(attempted)} recorded contract cells{attempted > 0 ? ` (${percent(available / attempted)})` : " (N/A)"} · rejections: {reasonList(report.rejection_reasons)}
+ {report.coverage_basis === "recorded_contract_cells_with_baseline_issuance" ? This coverage is conditional on recorded cells with an EWMA issuance; it does not cover every scheduled origin.
: null}
+ {typeof report.coverage_basis === "string" && report.coverage_basis.includes("window") ? This coverage counts recorded timestamped windows only; missed windows are not in the ledger.
: null}
+ {report.replay_scheduled_units != null ? Replay fit coverage {count(report.replay_baseline_available_units)}/{count(report.replay_scheduled_units)} scheduled units ({percent(report.replay_fit_coverage)}) · skipped before strikes: {reasonList(report.replay_rejection_reasons)}
: null}
+ Brier {units ? score(brier?.candidate) : "N/A"} (EWMA {units ? score(brier?.baseline) : "N/A"}); paired change {units ? score(brier?.paired_delta) : "N/A"}; 95% calendar-block interval {intervalText}
+ Log loss {units ? score(logLoss?.candidate) : "N/A"} (EWMA {units ? score(logLoss?.baseline) : "N/A"}); paired change {units ? score(logLoss?.paired_delta) : "N/A"}
+ {data(report.quote_reanchored_comparator) ? Matched quote-reanchored comparator: Brier {score(data(report.quote_reanchored_comparator)?.brier)} · log loss {score(data(report.quote_reanchored_comparator)?.log_loss)}
: null}
+ {data(report.by_window) ? Matched intraday windows: {Object.entries(data(report.by_window)!).map(([window, value]) => `${window} N=${count(data(value)?.scored_ticker_date_window_expiry_units)}`).join(" · ")}
: null}
+ {(["call", "put"] as const).map((side) => {
+ const bins = Array.isArray(calibration?.[side]) ? calibration[side] as unknown[] : []
+ const nonempty = bins.map((item, index) => ({ item: data(item), index })).filter(({ item }) => (number(item?.count) ?? 0) > 0)
+ return {side === "call" ? "Call" : "Put"} calibration: {nonempty.length
+ ? nonempty.map(({ item, index }) => `${index * 10}–${(index + 1) * 10}%: N=${count(item?.count)}, forecast ${percent(item?.forecast_mean)}, observed ${percent(item?.observed_rate)}`).join(" · ")
+ : "N=0 · N/A"}
+ })}
+ {latency && "p50" in latency ? `Forecast p50/p95 ${milliseconds(latency.p50)}/${milliseconds(latency.p95)}` : `Preparation p50/p95 ${milliseconds(data(latency?.prepare)?.p50)}/${milliseconds(data(latency?.prepare)?.p95)} · lookup p50/p95 ${milliseconds(data(latency?.lookup)?.p50)}/${milliseconds(data(latency?.lookup)?.p95)}`}
+
+}
+
+function PhysicalResult({ model, selected, evidenceIndex }: { model: PredictiveOddsView; selected: PhysicalModel; evidenceIndex?: Data | null }) {
+ const valid = predictiveAvailable(model)
+ const evidence = data(model.model_evidence) ?? data(model.evidence_key ? evidenceIndex?.[model.evidence_key] : null)
+ return
+ {physicalModelName(model.method)} {model.method === selected ? Selected{selected !== DEFAULT_FORECAST_MODEL ? " · experimental" : " · baseline"} : null}
+ {valid ? `${unsignedPercentTenths(model.itm_pct_tenths)} ITM · ${unsignedPercentTenths(model.otm_pct_tenths)} OTM` : `${model.status === "pending" ? "Pending" : "Unavailable"}: ${model.reason ?? "No valid estimate"}`}
+ {model.price_basis === "validated_underlying_quote" ? "Validated stock quote" : model.price_basis === "completed_close" ? "Completed stock close" : "Input basis unavailable"} · input {model.price_as_of ?? model.as_of_session ?? "date unavailable"} · expiry {model.expiry_session ?? "unknown"} · version {model.model_version ?? "unknown"} · support {model.support ?? "N/A"}
+ {evidencePanel("Prospective as-issued", evidence?.prospective)}
+ {evidencePanel("Retrospective replay", evidence?.retrospective)}
+
+}
+
+function MarketResult({ model }: { model: MarketOddsView }) {
+ const evidence = data(model.model_evidence)
+ const valid = oddsAvailable(model)
+ const heldOut = number(evidence?.held_out_count) ?? 0
+ const inside = number(evidence?.held_out_inside) ?? 0
+ const check = (value: unknown) => value === true ? "yes" : value === false ? "no" : "N/A"
+ return
+ {marketModelName(model.method)} · risk-neutral {model.method === "regimelib" ? Benchmark : null}
+ {valid ? `${unsignedPercentTenths(model.itm_pct_tenths)} ITM · ${unsignedPercentTenths(model.otm_pct_tenths)} OTM` : `${model.status === "pending" ? "Pending" : "Unavailable"}: ${model.reason ?? "No valid estimate"}`}
+ {quoteSupportLabel(model) ?? "Quote bounds N/A"} · session {model.session_date ?? "unavailable"} · version {model.model_version ?? "unknown"}
+ Held-out quote coverage {heldOut ? `${inside}/${heldOut} (${percent(inside / heldOut)})` : "N=0 · N/A"} · paired quotes {count(evidence?.paired_held_out_count)} · bid/ask fit {check(evidence?.bid_ask_fit)} · one-tick stable {check(evidence?.one_tick_stable)}
+ {number(evidence?.paired_shadow_inside) != null && number(evidence?.paired_benchmark_inside) != null ? Matched held-out fit: curve {count(evidence?.paired_shadow_inside)} vs benchmark {count(evidence?.paired_benchmark_inside)} of {count(evidence?.paired_held_out_count)} quotes.
: null}
+ Fit {milliseconds(evidence?.fit_ms)} · refresh {milliseconds(evidence?.refresh_ms)} · rejections: {reasonList(evidence?.rejection_reasons)}
+
+}
+
+export default function ModelComparison({ physical, market, selected, evidenceIndex }: {
+ physical: PredictiveOddsView[] | null | undefined
+ market: MarketOddsView[] | null | undefined
+ selected: PhysicalModel
+ evidenceIndex?: Data | null
+}) {
+ const [open, setOpen] = useState(false)
+ return
+ setOpen((current) => !current)}>Compare models
+ {open ?
+
Stock-close forecasts Real-world expiry-close odds. User-selected models are not validated champions.
+ {physical?.length ? physical.map((model, index) => ) : No physical model results yet. }
+
+
Option-price estimates Risk-neutral odds from option quotes. Quote fit does not measure realized forecast accuracy.
+ {market?.length ? market.map((model, index) => ) : No market model results yet. }
+
+
: null}
+
+}
diff --git a/frontend/src/OddsValues.tsx b/frontend/src/OddsValues.tsx
index 8ddbc01..ee26b70 100644
--- a/frontend/src/OddsValues.tsx
+++ b/frontend/src/OddsValues.tsx
@@ -1,4 +1,5 @@
import { unsignedPercentTenths } from "./format"
+import { DEFAULT_FORECAST_MODEL, physicalModelName } from "./forecastModels"
import { oddsAvailable, oddsMessage, predictiveAvailable, predictiveBasisLabel, predictiveReliabilityLabel, quoteSupportLabel, type MarketOdds, type PredictiveOdds } from "./marketOdds"
export default function OddsValues({ odds, predictiveOdds, compact = false }: {
@@ -13,7 +14,7 @@ export default function OddsValues({ odds, predictiveOdds, compact = false }: {
return (
- Stock forecast
+ Stock forecast{predictiveOdds?.method ? ` · ${physicalModelName(predictiveOdds.method)}` : ""}{predictiveOdds?.method && predictiveOdds.method !== DEFAULT_FORECAST_MODEL ? " · user-selected" : ""}
{predictive ? (
<>
diff --git a/frontend/src/VersionBanner.browser.test.tsx b/frontend/src/VersionBanner.browser.test.tsx
new file mode 100644
index 0000000..f2e4819
--- /dev/null
+++ b/frontend/src/VersionBanner.browser.test.tsx
@@ -0,0 +1,56 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react"
+import { afterEach, expect, it, vi } from "vitest"
+import VersionBanner from "./VersionBanner"
+
+const SHA = "a".repeat(40)
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllEnvs()
+ vi.unstubAllGlobals()
+ vi.useRealTimers()
+})
+
+it.each([
+ ["update_available", true, "An update is available"],
+ ["offline", true, "Version unverified: origin/main could not be reached"],
+ ["current", false, "Frontend and backend versions differ"],
+ ["unverified_checkout", true, "not a verified clean main launch"],
+] as const)("shows %s or mismatch without restarting running code", async (status, frontendMatches, message) => {
+ vi.stubEnv("VITE_APP_SHA", SHA)
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({
+ running_sha: SHA, remote_sha: null, status, frontend_matches: frontendMatches,
+ })))
+ vi.stubGlobal("fetch", fetchMock)
+ render( )
+ expect((await screen.findByRole("status")).textContent).toContain(message)
+ expect(fetchMock).toHaveBeenCalledWith(`/api/version?frontend_sha=${SHA}`, expect.anything())
+})
+
+it("hides the banner for a verified current frontend and backend", async () => {
+ vi.stubEnv("VITE_APP_SHA", SHA)
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
+ running_sha: SHA, remote_sha: SHA, status: "current", frontend_matches: true,
+ }))))
+ render( )
+ await waitFor(() => expect(screen.queryByRole("status")).toBeNull())
+})
+
+it("polls for a newer revision without changing the running page", async () => {
+ vi.useFakeTimers()
+ vi.stubEnv("VITE_APP_SHA", SHA)
+ let status = "current"
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({
+ running_sha: SHA, remote_sha: status === "current" ? SHA : "b".repeat(40), status, frontend_matches: true,
+ })))
+ vi.stubGlobal("fetch", fetchMock)
+ render( )
+ await act(async () => { await Promise.resolve() })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ status = "update_available"
+ await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(screen.getByRole("status").textContent).toContain("Restart ./scripts/dev")
+})
diff --git a/frontend/src/VersionBanner.tsx b/frontend/src/VersionBanner.tsx
new file mode 100644
index 0000000..cee4d4a
--- /dev/null
+++ b/frontend/src/VersionBanner.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useState } from "react"
+
+type VersionStatus = {
+ running_sha: string | null
+ remote_sha: string | null
+ status: "current" | "update_available" | "offline" | "unverified_checkout"
+ frontend_matches: boolean | null
+}
+
+export default function VersionBanner() {
+ const frontendSha = import.meta.env.VITE_APP_SHA as string | undefined
+ const [status, setStatus] = useState(null)
+ const [failed, setFailed] = useState(false)
+
+ useEffect(() => {
+ if (!frontendSha) return
+ let active = true
+ const poll = async () => {
+ if (document.hidden) return
+ try {
+ const response = await fetch(`/api/version?frontend_sha=${encodeURIComponent(frontendSha)}`, {
+ signal: AbortSignal.timeout(12_000),
+ })
+ if (!response.ok) throw new Error("Version check failed")
+ const result = await response.json() as VersionStatus
+ if (!result || !["current", "update_available", "offline", "unverified_checkout"].includes(result.status)) throw new Error("Invalid version status")
+ if (active) { setStatus(result); setFailed(false) }
+ } catch {
+ if (active) setFailed(true)
+ }
+ }
+ void poll()
+ const timer = window.setInterval(() => { void poll() }, 60_000)
+ document.addEventListener("visibilitychange", poll)
+ return () => { active = false; window.clearInterval(timer); document.removeEventListener("visibilitychange", poll) }
+ }, [frontendSha])
+
+ let message: string | null = null
+ if (!frontendSha) message = "Version unverified. Start with ./scripts/dev."
+ else if (status?.frontend_matches === false) message = "Frontend and backend versions differ. Restart ./scripts/dev to load the same revision."
+ else if (status?.status === "update_available") message = "An update is available on origin/main. Restart ./scripts/dev to apply it."
+ else if (status?.status === "offline") message = "Version unverified: origin/main could not be reached. You can keep using this local revision."
+ else if (status?.status === "unverified_checkout") message = "Version unverified: this checkout is not a verified clean main launch."
+ else if (failed) message = "Version unverified: the local version check failed."
+
+ return message ? {message}
: null
+}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 5442d2f..1ab6f48 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1,4 +1,5 @@
import { zCashSecuredPutPage, zCoveredCallPage, zTickerSearchResponse } from "./generated/zod.gen"
+import { DEFAULT_FORECAST_MODEL, type PhysicalModel } from "./forecastModels"
import type { CashSecuredPutPage, CoveredCallPage, Moneyness, Side, Ticker, TickerSearchResponse } from "./types"
export class ApiError extends Error {
@@ -54,8 +55,9 @@ export async function fetchTickers(query: string, limit = 10): Promise {
- const body = await fetchJson(`/api/covered-calls/${ticker}?moneyness=${moneyness}`)
+export async function fetchCoveredCalls(ticker: Ticker, moneyness: Moneyness, model: PhysicalModel = "lognormal_ewma"): Promise {
+ const choice = model === DEFAULT_FORECAST_MODEL ? "" : `&forecast_model=${model}`
+ const body = await fetchJson(`/api/covered-calls/${ticker}?moneyness=${moneyness}${choice}`)
const parsed = zCoveredCallPage.safeParse(body)
if (!parsed.success) {
throw new Error("Local API returned an invalid response")
@@ -63,8 +65,9 @@ export async function fetchCoveredCalls(ticker: Ticker, moneyness: Moneyness): P
return parsed.data
}
-export async function fetchCashSecuredPuts(ticker: Ticker, moneyness: Moneyness): Promise {
- const body = await fetchJson(`/api/cash-secured-puts/${ticker}?moneyness=${moneyness}`)
+export async function fetchCashSecuredPuts(ticker: Ticker, moneyness: Moneyness, model: PhysicalModel = "lognormal_ewma"): Promise {
+ const choice = model === DEFAULT_FORECAST_MODEL ? "" : `&forecast_model=${model}`
+ const body = await fetchJson(`/api/cash-secured-puts/${ticker}?moneyness=${moneyness}${choice}`)
const parsed = zCashSecuredPutPage.safeParse(body)
if (!parsed.success) {
throw new Error("Local API returned an invalid response")
@@ -72,8 +75,8 @@ export async function fetchCashSecuredPuts(ticker: Ticker, moneyness: Moneyness)
return parsed.data
}
-export async function fetchChain(ticker: Ticker, side: Side, moneyness: Moneyness) {
+export async function fetchChain(ticker: Ticker, side: Side, moneyness: Moneyness, model: PhysicalModel = "lognormal_ewma") {
return side === "put"
- ? fetchCashSecuredPuts(ticker, moneyness)
- : fetchCoveredCalls(ticker, moneyness)
+ ? fetchCashSecuredPuts(ticker, moneyness, model)
+ : fetchCoveredCalls(ticker, moneyness, model)
}
diff --git a/frontend/src/forecastModels.ts b/frontend/src/forecastModels.ts
new file mode 100644
index 0000000..a2dc9e4
--- /dev/null
+++ b/frontend/src/forecastModels.ts
@@ -0,0 +1,33 @@
+export const PHYSICAL_MODELS = [
+ "lognormal_ewma",
+ "empirical_scaled",
+ "student_t_ewma",
+ "gjr_garch_t",
+ "intraday_shadow",
+] as const
+
+export type PhysicalModel = typeof PHYSICAL_MODELS[number]
+export const DEFAULT_FORECAST_MODEL: PhysicalModel = "lognormal_ewma"
+export const FORECAST_MODEL_KEY = "hyperoptions.forecastModel"
+
+export const PHYSICAL_MODEL_NAMES: Record = {
+ lognormal_ewma: "EWMA lognormal",
+ empirical_scaled: "Scaled empirical",
+ student_t_ewma: "Student-t EWMA",
+ gjr_garch_t: "GJR-GARCH Student-t",
+ intraday_shadow: "Intraday conditioned",
+}
+
+export function physicalModel(value: unknown): PhysicalModel | null {
+ return typeof value === "string" && PHYSICAL_MODELS.some((method) => method === value)
+ ? value as PhysicalModel : null
+}
+
+export function physicalModelName(value: string | null | undefined): string {
+ return physicalModel(value) ? PHYSICAL_MODEL_NAMES[value as PhysicalModel] : "Unknown model"
+}
+
+export function marketModelName(value: string | null | undefined): string {
+ return value === "regimelib" ? "Regimelib" : value === "constrained_call_curve"
+ ? "Constrained call curve" : "Unknown market model"
+}
diff --git a/frontend/src/generated/index.ts b/frontend/src/generated/index.ts
index 501dddd..5a2179a 100644
--- a/frontend/src/generated/index.ts
+++ b/frontend/src/generated/index.ts
@@ -1,3 +1,3 @@
// This file is auto-generated by @hey-api/openapi-ts
-export type { AddWatchApiWatchlistPostData, AddWatchApiWatchlistPostError, AddWatchApiWatchlistPostErrors, AddWatchApiWatchlistPostResponse, AddWatchApiWatchlistPostResponses, CashSecuredPutContract, CashSecuredPutExpiration, CashSecuredPutPage, ClientOptions, CoveredCallContract, CoveredCallExpiration, CoveredCallPage, DeleteWatchApiWatchlistWatchIdDeleteData, DeleteWatchApiWatchlistWatchIdDeleteError, DeleteWatchApiWatchlistWatchIdDeleteErrors, DeleteWatchApiWatchlistWatchIdDeleteResponse, DeleteWatchApiWatchlistWatchIdDeleteResponses, GetCashSecuredPutsApiCashSecuredPutsTickerGetData, GetCashSecuredPutsApiCashSecuredPutsTickerGetError, GetCashSecuredPutsApiCashSecuredPutsTickerGetErrors, GetCashSecuredPutsApiCashSecuredPutsTickerGetResponse, GetCashSecuredPutsApiCashSecuredPutsTickerGetResponses, GetCoveredCallsApiCoveredCallsTickerGetData, GetCoveredCallsApiCoveredCallsTickerGetError, GetCoveredCallsApiCoveredCallsTickerGetErrors, GetCoveredCallsApiCoveredCallsTickerGetResponse, GetCoveredCallsApiCoveredCallsTickerGetResponses, GetJobApiJobsJobIdGetData, GetJobApiJobsJobIdGetError, GetJobApiJobsJobIdGetErrors, GetJobApiJobsJobIdGetResponse, GetJobApiJobsJobIdGetResponses, GetWatchlistApiWatchlistGetData, GetWatchlistApiWatchlistGetResponse, GetWatchlistApiWatchlistGetResponses, HealthApiHealthGetData, HealthApiHealthGetResponse, HealthApiHealthGetResponses, HealthResponse, HttpValidationError, HypotheticalRiskView, Job, MarketOddsView, OutcomeView, PeriodLows, PredictiveOddsView, PredictiveValidationEvidence, RefreshWatchlistApiWatchlistRefreshPostData, RefreshWatchlistApiWatchlistRefreshPostResponse, RefreshWatchlistApiWatchlistRefreshPostResponses, SearchTickersApiTickersGetData, SearchTickersApiTickersGetError, SearchTickersApiTickersGetErrors, SearchTickersApiTickersGetResponse, SearchTickersApiTickersGetResponses, TickerListing, TickerSearchResponse, ValidationError, WatchCreate, WatchCreateResponse, WatchItem, WatchListResponse, WatchRefreshResponse } from './types.gen';
+export type { AddWatchApiWatchlistPostData, AddWatchApiWatchlistPostError, AddWatchApiWatchlistPostErrors, AddWatchApiWatchlistPostResponse, AddWatchApiWatchlistPostResponses, CashSecuredPutContract, CashSecuredPutExpiration, CashSecuredPutPage, ClientOptions, CoveredCallContract, CoveredCallExpiration, CoveredCallPage, DeleteWatchApiWatchlistWatchIdDeleteData, DeleteWatchApiWatchlistWatchIdDeleteError, DeleteWatchApiWatchlistWatchIdDeleteErrors, DeleteWatchApiWatchlistWatchIdDeleteResponse, DeleteWatchApiWatchlistWatchIdDeleteResponses, GetCashSecuredPutsApiCashSecuredPutsTickerGetData, GetCashSecuredPutsApiCashSecuredPutsTickerGetError, GetCashSecuredPutsApiCashSecuredPutsTickerGetErrors, GetCashSecuredPutsApiCashSecuredPutsTickerGetResponse, GetCashSecuredPutsApiCashSecuredPutsTickerGetResponses, GetCoveredCallsApiCoveredCallsTickerGetData, GetCoveredCallsApiCoveredCallsTickerGetError, GetCoveredCallsApiCoveredCallsTickerGetErrors, GetCoveredCallsApiCoveredCallsTickerGetResponse, GetCoveredCallsApiCoveredCallsTickerGetResponses, GetJobApiJobsJobIdGetData, GetJobApiJobsJobIdGetError, GetJobApiJobsJobIdGetErrors, GetJobApiJobsJobIdGetResponse, GetJobApiJobsJobIdGetResponses, GetWatchlistApiWatchlistGetData, GetWatchlistApiWatchlistGetError, GetWatchlistApiWatchlistGetErrors, GetWatchlistApiWatchlistGetResponse, GetWatchlistApiWatchlistGetResponses, HealthApiHealthGetData, HealthApiHealthGetResponse, HealthApiHealthGetResponses, HealthResponse, HttpValidationError, HypotheticalRiskView, Job, MarketOddsView, OutcomeView, PeriodLows, PredictiveOddsView, PredictiveValidationEvidence, RefreshWatchlistApiWatchlistRefreshPostData, RefreshWatchlistApiWatchlistRefreshPostResponse, RefreshWatchlistApiWatchlistRefreshPostResponses, SearchTickersApiTickersGetData, SearchTickersApiTickersGetError, SearchTickersApiTickersGetErrors, SearchTickersApiTickersGetResponse, SearchTickersApiTickersGetResponses, TickerListing, TickerSearchResponse, ValidationError, VersionApiVersionGetData, VersionApiVersionGetError, VersionApiVersionGetErrors, VersionApiVersionGetResponse, VersionApiVersionGetResponses, VersionStatus, WatchCreate, WatchCreateResponse, WatchItem, WatchListResponse, WatchRefreshResponse } from './types.gen';
diff --git a/frontend/src/generated/types.gen.ts b/frontend/src/generated/types.gen.ts
index 9d0b24e..3830d97 100644
--- a/frontend/src/generated/types.gen.ts
+++ b/frontend/src/generated/types.gen.ts
@@ -150,6 +150,14 @@ export type CashSecuredPutContract = {
greeks_rate_as_of_session?: string | null;
market_odds?: MarketOddsView;
predictive_odds?: PredictiveOddsView;
+ /**
+ * Physical Models
+ */
+ physical_models?: Array;
+ /**
+ * Market Models
+ */
+ market_models?: Array;
hypothetical_risk?: HypotheticalRiskView;
};
@@ -260,6 +268,12 @@ export type CashSecuredPutPage = {
*/
risk_free_rate_pct_tenths: number | null;
lows: PeriodLows;
+ /**
+ * Model Evidence
+ */
+ model_evidence?: {
+ [key: string]: unknown;
+ };
/**
* Expirations
*/
@@ -420,6 +434,14 @@ export type CoveredCallContract = {
greeks_rate_as_of_session?: string | null;
market_odds?: MarketOddsView;
predictive_odds?: PredictiveOddsView;
+ /**
+ * Physical Models
+ */
+ physical_models?: Array;
+ /**
+ * Market Models
+ */
+ market_models?: Array;
hypothetical_risk?: HypotheticalRiskView;
};
@@ -530,6 +552,12 @@ export type CoveredCallPage = {
*/
risk_free_rate_pct_tenths: number | null;
lows: PeriodLows;
+ /**
+ * Model Evidence
+ */
+ model_evidence?: {
+ [key: string]: unknown;
+ };
/**
* Expirations
*/
@@ -566,6 +594,10 @@ export type HypotheticalRiskView = {
* Status
*/
status?: 'available' | 'unavailable';
+ /**
+ * Forecast Method
+ */
+ forecast_method?: 'lognormal_ewma' | 'empirical_scaled' | 'student_t_ewma' | 'gjr_garch_t' | 'intraday_shadow' | null;
/**
* Reason
*/
@@ -646,6 +678,10 @@ export type Job = {
* MarketOddsView
*/
export type MarketOddsView = {
+ /**
+ * Method
+ */
+ method?: 'regimelib' | 'constrained_call_curve' | null;
/**
* Status
*/
@@ -690,6 +726,12 @@ export type MarketOddsView = {
* Quote Support Score
*/
quote_support_score?: number | null;
+ /**
+ * Model Evidence
+ */
+ model_evidence?: {
+ [key: string]: unknown;
+ } | null;
};
/**
@@ -769,7 +811,7 @@ export type PredictiveOddsView = {
/**
* Method
*/
- method?: string | null;
+ method?: 'lognormal_ewma' | 'empirical_scaled' | 'student_t_ewma' | 'gjr_garch_t' | 'intraday_shadow' | null;
/**
* Reason
*/
@@ -815,6 +857,16 @@ export type PredictiveOddsView = {
*/
price_as_of?: string | null;
validation_evidence?: PredictiveValidationEvidence | null;
+ /**
+ * Evidence Key
+ */
+ evidence_key?: string | null;
+ /**
+ * Model Evidence
+ */
+ model_evidence?: {
+ [key: string]: unknown;
+ } | null;
};
/**
@@ -827,6 +879,10 @@ export type PredictiveValidationEvidence = {
* Source
*/
source: 'prospective_as_issued';
+ /**
+ * Option Side
+ */
+ option_side: 'call' | 'put';
/**
* Model Version
*/
@@ -925,6 +981,36 @@ export type ValidationError = {
};
};
+/**
+ * VersionStatus
+ */
+export type VersionStatus = {
+ /**
+ * Running Sha
+ */
+ running_sha: string | null;
+ /**
+ * Branch
+ */
+ branch: string | null;
+ /**
+ * Remote Sha
+ */
+ remote_sha: string | null;
+ /**
+ * Status
+ */
+ status: 'current' | 'update_available' | 'offline' | 'unverified_checkout';
+ /**
+ * Checked At
+ */
+ checked_at: string;
+ /**
+ * Frontend Matches
+ */
+ frontend_matches: boolean | null;
+};
+
/**
* WatchCreate
*/
@@ -945,6 +1031,12 @@ export type WatchCreateResponse = {
*/
created: boolean;
job: Job | null;
+ /**
+ * Model Evidence
+ */
+ model_evidence?: {
+ [key: string]: unknown;
+ };
};
/**
@@ -986,6 +1078,14 @@ export type WatchItem = {
market_odds?: MarketOddsView;
last_available_market_odds?: MarketOddsView | null;
predictive_odds?: PredictiveOddsView;
+ /**
+ * Physical Models
+ */
+ physical_models?: Array;
+ /**
+ * Market Models
+ */
+ market_models?: Array;
hypothetical_risk?: HypotheticalRiskView;
outcome: OutcomeView;
};
@@ -999,6 +1099,12 @@ export type WatchListResponse = {
*/
items: Array;
active_job?: Job | null;
+ /**
+ * Model Evidence
+ */
+ model_evidence?: {
+ [key: string]: unknown;
+ };
};
/**
@@ -1071,6 +1177,10 @@ export type GetCoveredCallsApiCoveredCallsTickerGetData = {
* Moneyness
*/
moneyness?: 'itm' | 'otm' | 'all' | null;
+ /**
+ * Forecast Model
+ */
+ forecast_model?: 'lognormal_ewma' | 'empirical_scaled' | 'student_t_ewma' | 'gjr_garch_t' | 'intraday_shadow';
};
url: '/api/covered-calls/{ticker}';
};
@@ -1106,6 +1216,10 @@ export type GetCashSecuredPutsApiCashSecuredPutsTickerGetData = {
* Moneyness
*/
moneyness?: 'itm' | 'otm' | 'all' | null;
+ /**
+ * Forecast Model
+ */
+ forecast_model?: 'lognormal_ewma' | 'empirical_scaled' | 'student_t_ewma' | 'gjr_garch_t' | 'intraday_shadow';
};
url: '/api/cash-secured-puts/{ticker}';
};
@@ -1128,6 +1242,36 @@ export type GetCashSecuredPutsApiCashSecuredPutsTickerGetResponses = {
export type GetCashSecuredPutsApiCashSecuredPutsTickerGetResponse = GetCashSecuredPutsApiCashSecuredPutsTickerGetResponses[keyof GetCashSecuredPutsApiCashSecuredPutsTickerGetResponses];
+export type VersionApiVersionGetData = {
+ body?: never;
+ path?: never;
+ query?: {
+ /**
+ * Frontend Sha
+ */
+ frontend_sha?: string | null;
+ };
+ url: '/api/version';
+};
+
+export type VersionApiVersionGetErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type VersionApiVersionGetError = VersionApiVersionGetErrors[keyof VersionApiVersionGetErrors];
+
+export type VersionApiVersionGetResponses = {
+ /**
+ * Successful Response
+ */
+ 200: VersionStatus;
+};
+
+export type VersionApiVersionGetResponse = VersionApiVersionGetResponses[keyof VersionApiVersionGetResponses];
+
export type GetJobApiJobsJobIdGetData = {
body?: never;
path: {
@@ -1161,10 +1305,24 @@ export type GetJobApiJobsJobIdGetResponse = GetJobApiJobsJobIdGetResponses[keyof
export type GetWatchlistApiWatchlistGetData = {
body?: never;
path?: never;
- query?: never;
+ query?: {
+ /**
+ * Forecast Model
+ */
+ forecast_model?: 'lognormal_ewma' | 'empirical_scaled' | 'student_t_ewma' | 'gjr_garch_t' | 'intraday_shadow';
+ };
url: '/api/watchlist';
};
+export type GetWatchlistApiWatchlistGetErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type GetWatchlistApiWatchlistGetError = GetWatchlistApiWatchlistGetErrors[keyof GetWatchlistApiWatchlistGetErrors];
+
export type GetWatchlistApiWatchlistGetResponses = {
/**
* Successful Response
@@ -1177,7 +1335,12 @@ export type GetWatchlistApiWatchlistGetResponse = GetWatchlistApiWatchlistGetRes
export type AddWatchApiWatchlistPostData = {
body: WatchCreate;
path?: never;
- query?: never;
+ query?: {
+ /**
+ * Forecast Model
+ */
+ forecast_model?: 'lognormal_ewma' | 'empirical_scaled' | 'student_t_ewma' | 'gjr_garch_t' | 'intraday_shadow';
+ };
url: '/api/watchlist';
};
diff --git a/frontend/src/generated/zod.gen.ts b/frontend/src/generated/zod.gen.ts
index 0a85daa..0f80a22 100644
--- a/frontend/src/generated/zod.gen.ts
+++ b/frontend/src/generated/zod.gen.ts
@@ -16,6 +16,13 @@ export const zHealthResponse = z.object({
*/
export const zHypotheticalRiskView = z.object({
status: z.enum(['available', 'unavailable']).optional().default('unavailable'),
+ forecast_method: z.enum([
+ 'lognormal_ewma',
+ 'empirical_scaled',
+ 'student_t_ewma',
+ 'gjr_garch_t',
+ 'intraday_shadow'
+ ]).nullish(),
reason: z.string().nullish(),
assumed_spot_cents: z.int().nullish(),
assumed_bid_cents: z.int().nullish(),
@@ -50,6 +57,7 @@ export const zJob = z.object({
* MarketOddsView
*/
export const zMarketOddsView = z.object({
+ method: z.enum(['regimelib', 'constrained_call_curve']).nullish(),
status: z.enum([
'pending',
'available',
@@ -64,7 +72,8 @@ export const zMarketOddsView = z.object({
model_version: z.string().nullish(),
bound_low_pct_tenths: z.int().nullish(),
bound_high_pct_tenths: z.int().nullish(),
- quote_support_score: z.int().nullish()
+ quote_support_score: z.int().nullish(),
+ model_evidence: z.record(z.string(), z.unknown()).nullish()
});
/**
@@ -107,6 +116,7 @@ export const zPeriodLows = z.object({
*/
export const zPredictiveValidationEvidence = z.object({
source: z.literal('prospective_as_issued'),
+ option_side: z.enum(['call', 'put']),
model_version: z.string(),
horizon_band: z.string(),
moneyness_band: z.string(),
@@ -127,7 +137,13 @@ export const zPredictiveOddsView = z.object({
'available',
'unavailable'
]).optional().default('pending'),
- method: z.string().nullish(),
+ method: z.enum([
+ 'lognormal_ewma',
+ 'empirical_scaled',
+ 'student_t_ewma',
+ 'gjr_garch_t',
+ 'intraday_shadow'
+ ]).nullish(),
reason: z.string().nullish(),
itm_pct_tenths: z.int().nullish(),
otm_pct_tenths: z.int().nullish(),
@@ -139,7 +155,9 @@ export const zPredictiveOddsView = z.object({
data_hash: z.string().nullish(),
price_basis: z.enum(['completed_close', 'validated_underlying_quote']).nullish(),
price_as_of: z.iso.datetime().nullish(),
- validation_evidence: zPredictiveValidationEvidence.nullish()
+ validation_evidence: zPredictiveValidationEvidence.nullish(),
+ evidence_key: z.string().nullish(),
+ model_evidence: z.record(z.string(), z.unknown()).nullish()
});
/**
@@ -183,6 +201,8 @@ export const zCashSecuredPutContract = z.object({
greeks_rate_as_of_session: z.iso.date().nullish(),
market_odds: zMarketOddsView.optional(),
predictive_odds: zPredictiveOddsView.optional(),
+ physical_models: z.array(zPredictiveOddsView).optional(),
+ market_models: z.array(zMarketOddsView).optional(),
hypothetical_risk: zHypotheticalRiskView.optional()
});
@@ -229,6 +249,7 @@ export const zCashSecuredPutPage = z.object({
history_from_cache: z.boolean(),
risk_free_rate_pct_tenths: z.int().nullable(),
lows: zPeriodLows,
+ model_evidence: z.record(z.string(), z.unknown()).optional(),
expirations: z.array(zCashSecuredPutExpiration)
});
@@ -275,6 +296,8 @@ export const zCoveredCallContract = z.object({
greeks_rate_as_of_session: z.iso.date().nullish(),
market_odds: zMarketOddsView.optional(),
predictive_odds: zPredictiveOddsView.optional(),
+ physical_models: z.array(zPredictiveOddsView).optional(),
+ market_models: z.array(zMarketOddsView).optional(),
hypothetical_risk: zHypotheticalRiskView.optional()
});
@@ -321,6 +344,7 @@ export const zCoveredCallPage = z.object({
history_from_cache: z.boolean(),
risk_free_rate_pct_tenths: z.int().nullable(),
lows: zPeriodLows,
+ model_evidence: z.record(z.string(), z.unknown()).optional(),
expirations: z.array(zCoveredCallExpiration)
});
@@ -361,6 +385,23 @@ export const zHttpValidationError = z.object({
detail: z.array(zValidationError).optional()
});
+/**
+ * VersionStatus
+ */
+export const zVersionStatus = z.object({
+ running_sha: z.string().nullable(),
+ branch: z.string().nullable(),
+ remote_sha: z.string().nullable(),
+ status: z.enum([
+ 'current',
+ 'update_available',
+ 'offline',
+ 'unverified_checkout'
+ ]),
+ checked_at: z.iso.datetime(),
+ frontend_matches: z.boolean().nullable()
+});
+
/**
* WatchCreate
*/
@@ -383,6 +424,8 @@ export const zWatchItem = z.object({
market_odds: zMarketOddsView.optional(),
last_available_market_odds: zMarketOddsView.nullish(),
predictive_odds: zPredictiveOddsView.optional(),
+ physical_models: z.array(zPredictiveOddsView).optional(),
+ market_models: z.array(zMarketOddsView).optional(),
hypothetical_risk: zHypotheticalRiskView.optional(),
outcome: zOutcomeView
});
@@ -393,7 +436,8 @@ export const zWatchItem = z.object({
export const zWatchCreateResponse = z.object({
item: zWatchItem,
created: z.boolean(),
- job: zJob.nullable()
+ job: zJob.nullable(),
+ model_evidence: z.record(z.string(), z.unknown()).optional()
});
/**
@@ -401,7 +445,8 @@ export const zWatchCreateResponse = z.object({
*/
export const zWatchListResponse = z.object({
items: z.array(zWatchItem),
- active_job: zJob.nullish()
+ active_job: zJob.nullish(),
+ model_evidence: z.record(z.string(), z.unknown()).optional()
});
/**
@@ -431,6 +476,11 @@ export const zGetCoveredCallsApiCoveredCallsTickerGetResponse = zCoveredCallPage
*/
export const zGetCashSecuredPutsApiCashSecuredPutsTickerGetResponse = zCashSecuredPutPage;
+/**
+ * Successful Response
+ */
+export const zVersionApiVersionGetResponse = zVersionStatus;
+
/**
* Successful Response
*/
diff --git a/frontend/src/index.css b/frontend/src/index.css
index b80d368..d76bc36 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -175,16 +175,16 @@
padding: 12px clamp(12px, 1.6vw, 24px) 40px;
}
+ .workspace-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; margin-bottom: 12px; padding: 4px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); }
.workspace-nav {
display: flex;
flex-wrap: wrap;
gap: 4px;
- margin-bottom: 12px;
- padding: 4px;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--card);
}
+ .model-selector { display: flex; min-width: 0; align-items: center; gap: 8px; padding: 0 8px; color: var(--muted-foreground); font-size: .78rem; font-weight: 700; }
+ .model-selector select { max-width: min(100%, 18rem); min-height: 34px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--secondary); color: var(--foreground); font: inherit; }
+ .model-selector select:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
+ .version-banner { max-width: 100%; margin: 0; padding: 6px 9px; border: 1px solid color-mix(in oklch, var(--primary) 42%, var(--border)); border-radius: 7px; background: color-mix(in oklch, var(--primary) 8%, var(--card)); color: var(--foreground); font-size: .76rem; line-height: 1.35; }
.workspace-nav a {
display: inline-flex;
min-height: 36px;
@@ -199,7 +199,7 @@
.workspace-nav a[aria-current="page"] { background: var(--primary); color: var(--primary-foreground); }
.workspace-nav a:hover:not([aria-current="page"]) { background: var(--muted); }
- .watchlist-page { max-width: 72rem; margin: 0 auto; padding: 16px 0; }
+ .watchlist-page { max-width: 72rem; margin: 0 auto; padding: 8px 0; }
.watchlist-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 12px; }
.watchlist-header h1 { margin: 0 0 8px; font-size: clamp(1.8rem, 4vw, 2.5rem); font-weight: 750; line-height: 1.1; }
.watchlist-header p:not(.eyebrow) { max-width: 56rem; margin: 0 0 4px; color: var(--muted-foreground); font-size: .9rem; }
@@ -208,7 +208,7 @@
.watchlist-header > button { flex: none; }
.watch-card-list { display: grid; gap: 16px; }
.watch-card { min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); }
- .watch-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
+ .watch-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 10px 18px; border-bottom: 1px solid var(--border); }
.watch-card-head h2 { margin: 0 0 8px; font-size: 1.15rem; font-weight: 700; }
.watch-card-head .eyebrow { margin-bottom: 4px; }
.watch-card-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, .9fr) minmax(0, 1.15fr); }
@@ -243,6 +243,20 @@
.watch-prior-odds { margin-top: 12px; color: var(--muted-foreground); font-size: .78rem; line-height: 1.5; }
.watch-prior-odds summary { width: fit-content; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
.watch-prior-odds p { margin: 3px 0 0; }
+ .model-comparison { max-width: 100%; margin-top: 8px; color: var(--muted-foreground); font-size: .76rem; line-height: 1.4; }
+ .model-comparison > summary { width: fit-content; color: var(--foreground); font-weight: 700; cursor: pointer; text-decoration: underline; text-decoration-color: var(--primary); text-underline-offset: 3px; }
+ .model-comparison > summary:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
+ .model-comparison-body { display: grid; gap: 14px; width: min(34rem, 80vw); max-width: 100%; margin-top: 9px; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--card); text-align: left; white-space: normal; overflow-wrap: break-word; }
+ .model-comparison-body h4, .model-evidence h5 { margin: 0 0 4px; color: var(--foreground); font-size: .79rem; }
+ .model-comparison-body p { margin: 0 0 5px; }
+ .model-comparison-body ul { display: grid; gap: 9px; margin: 7px 0 0; padding: 0; list-style: none; }
+ .model-result { min-width: 0; padding: 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--secondary); }
+ .model-result-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 8px; color: var(--foreground); }
+ .model-result-heading strong { font-size: .8rem; }
+ .model-tag { color: var(--foreground); font-size: .7rem; font-weight: 700; }
+ .model-result > p { margin: 5px 0 0; }
+ .model-evidence { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
+ .model-evidence p { margin: 4px 0; }
.watch-facts { display: grid; gap: 1px; margin: 12px 0; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--border); }
.watch-facts div { display: flex; justify-content: space-between; gap: 12px; min-width: 0; padding: 7px 9px; background: var(--card); font-size: .78rem; }
.watch-facts dt { flex: 0 1 48%; color: var(--muted-foreground); }
@@ -986,6 +1000,7 @@
thead th:nth-last-child(2) { z-index: 3; background: var(--secondary); }
tbody td.watch-cell { background: var(--card); text-align: center; }
tbody td.odds-cell { min-width: 11rem; white-space: normal; text-align: left; }
+ tbody td.odds-cell .model-comparison-body { max-width: 28rem; }
.odds-values.compact,
.odds-values.compact .odds-physical,
.odds-values.compact .odds-market { display: grid; min-width: 0; gap: 2px; line-height: 1.3; }
@@ -1083,6 +1098,9 @@
}
@media (max-width: 39.999rem) {
+ .workspace-bar { align-items: stretch; flex-direction: column; }
+ .model-selector { align-items: stretch; flex-direction: column; gap: 3px; padding: 0 8px 5px; }
+ .model-selector select { min-width: 0; width: 100%; max-width: none; }
.app { padding-bottom: 24px; }
.mobile-market-quote .market-session-status { display: inline-flex; }
.chain-header { padding-bottom: 16px; }
@@ -1166,6 +1184,8 @@
.row-chevron { width: 16px; transition: transform 160ms ease; }
.mobile-row-summary[aria-expanded="true"] .row-chevron { transform: rotate(90deg); }
.mobile-row-details { padding: 0 10px 12px; }
+ .mobile-model-compare { padding: 0 10px 10px; }
+ .mobile-model-compare .model-comparison-body { width: 100%; }
.mobile-row-details dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0 0 10px; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--border); }
.mobile-row-details dl div { display: flex; min-width: 0; justify-content: space-between; gap: 8px; padding: 8px; background: var(--card); font-size: .72rem; }
.mobile-row-details dt { color: var(--muted-foreground); }
diff --git a/frontend/src/marketOdds.test.ts b/frontend/src/marketOdds.test.ts
index 63268b8..86d3bec 100644
--- a/frontend/src/marketOdds.test.ts
+++ b/frontend/src/marketOdds.test.ts
@@ -34,7 +34,7 @@ it("shows calibration only for supported matching prospective evidence", () => {
const forecast: PredictiveOddsView = {
status: "available", itm_pct_tenths: 600, otm_pct_tenths: 400, atm_pct_tenths: 0,
model_version: "ewma-v1", validation_evidence: {
- source: "prospective_as_issued", model_version: "ewma-v1", horizon_band: "6–25 sessions",
+ source: "prospective_as_issued", option_side: "call", model_version: "ewma-v1", horizon_band: "6–25 sessions",
moneyness_band: "near ATM", independent_units: 500,
predicted_itm_pct_tenths: 600, observed_itm_pct_tenths: 570,
through_session: "2026-09-25",
diff --git a/frontend/src/marketOdds.ts b/frontend/src/marketOdds.ts
index 0ee0181..d759b3f 100644
--- a/frontend/src/marketOdds.ts
+++ b/frontend/src/marketOdds.ts
@@ -1,4 +1,5 @@
import { unsignedPercentTenths } from "./format"
+import { physicalModelName } from "./forecastModels"
import type { MarketOddsView, PredictiveOddsView } from "./generated/types.gen"
export type MarketOdds = MarketOddsView
@@ -79,20 +80,17 @@ export function oddsMessage(odds: MarketOdds | null | undefined): string {
export function oddsLabel(market: MarketOdds | null | undefined, predictive?: PredictiveOdds | null): string {
const reliability = predictiveReliabilityLabel(predictive)
+ const model = predictive?.method ? `${physicalModelName(predictive.method)} stock forecast` : "Stock forecast"
const physical = predictiveAvailable(predictive)
- ? `Stock forecast ${unsignedPercentTenths(predictive!.itm_pct_tenths)} ITM, ${unsignedPercentTenths(predictive!.otm_pct_tenths)} OTM, ${predictiveBasisLabel(predictive)}${reliability === "Reliability not yet established" ? "" : `, ${reliability}`}`
- : `Stock forecast ${predictive?.status === "pending" ? "pending" : `unavailable: ${predictive?.reason || "no validated forecast"}`}`
+ ? `${model} ${unsignedPercentTenths(predictive!.itm_pct_tenths)} ITM, ${unsignedPercentTenths(predictive!.otm_pct_tenths)} OTM, ${predictiveBasisLabel(predictive)}${reliability === "Reliability not yet established" ? "" : `, ${reliability}`}`
+ : `${model} ${predictive?.status === "pending" ? "pending" : `unavailable: ${predictive?.reason || "no validated forecast"}`}`
if (oddsAvailable(market)) return `${physical}. Market-implied risk-neutral ${unsignedPercentTenths(market!.itm_pct_tenths)} ITM, ${unsignedPercentTenths(market!.otm_pct_tenths)} OTM${quoteSupportLabel(market) ? `, ${quoteSupportLabel(market)}` : ""}`
return `${physical}. ${oddsMessage(market)}`
}
export function predictiveProvenance(odds: PredictiveOdds | null | undefined): string | null {
if (!predictiveAvailable(odds)) return null
- const method = odds!.method === "empirical_scaled"
- ? "Volatility-scaled empirical model"
- : odds!.method === "lognormal_ewma"
- ? "EWMA lognormal model"
- : "Historical predictive model"
+ const method = `${physicalModelName(odds!.method)} model`
return `${method} · expiry session ${odds!.expiry_session ?? "unknown"}`
}
diff --git a/frontend/src/useChainPage.browser.test.tsx b/frontend/src/useChainPage.browser.test.tsx
index 8fda72c..ce35e49 100644
--- a/frontend/src/useChainPage.browser.test.tsx
+++ b/frontend/src/useChainPage.browser.test.tsx
@@ -118,6 +118,61 @@ it("checks a pending predictive forecast after hours and stops when it resolves"
expect(fetchMock).toHaveBeenCalledTimes(2)
})
+it.each(["physical_models", "market_models"])("refreshes a pending %s comparison after hours", async (field) => {
+ vi.setSystemTime(new Date("2026-09-17T21:00:00Z"))
+ const pending = samplePage()
+ Object.assign(pending.expirations[0].contracts[0], { [field]: [{ status: "pending" }] })
+ fetchMock.mockResolvedValueOnce(pending).mockResolvedValue(samplePage())
+ renderHook(() => useChainPage("IREN", "call", "itm"))
+ await act(async () => { await Promise.resolve() })
+
+ await act(async () => { await vi.advanceTimersByTimeAsync(15_000) })
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ await act(async () => { await vi.advanceTimersByTimeAsync(5 * 60_000) })
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+})
+
+it("refreshes missing shared evidence after hours and stops when it arrives", async () => {
+ vi.setSystemTime(new Date("2026-09-17T21:00:00Z"))
+ const missing = samplePage({ model_evidence: {} })
+ Object.assign(missing.expirations[0].contracts[0], {
+ physical_models: [{ status: "available", evidence_key: "lognormal_ewma:2-5" }],
+ })
+ const complete = samplePage({ model_evidence: { "lognormal_ewma:2-5": { prospective: {} } } })
+ Object.assign(complete.expirations[0].contracts[0], {
+ physical_models: [{ status: "available", evidence_key: "lognormal_ewma:2-5" }],
+ })
+ fetchMock.mockResolvedValueOnce(missing).mockResolvedValue(complete)
+ const hook = renderHook(() => useChainPage("IREN", "call", "itm"))
+ await act(async () => { await Promise.resolve() })
+
+ await act(async () => { await vi.advanceTimersByTimeAsync(14_999) })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ await act(async () => { await vi.advanceTimersByTimeAsync(1) })
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(hook.result.current.page?.model_evidence?.["lognormal_ewma:2-5"]).toBeTruthy()
+ await act(async () => { await vi.advanceTimersByTimeAsync(5 * 60_000) })
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+})
+
+it("stops retrying genuinely absent evidence after eight polls", async () => {
+ vi.setSystemTime(new Date("2026-09-17T21:00:00Z"))
+ const missing = samplePage({ model_evidence: {} })
+ Object.assign(missing.expirations[0].contracts[0], {
+ physical_models: [{ status: "available", evidence_key: "lognormal_ewma:2-5" }],
+ })
+ fetchMock.mockResolvedValue(missing)
+ renderHook(() => useChainPage("IREN", "call", "itm"))
+ await act(async () => { await Promise.resolve() })
+
+ for (let poll = 0; poll < 8; poll += 1) {
+ await act(async () => { await vi.advanceTimersByTimeAsync(15_000) })
+ }
+ expect(fetchMock).toHaveBeenCalledTimes(9)
+ await act(async () => { await vi.advanceTimersByTimeAsync(20 * 60_000) })
+ expect(fetchMock).toHaveBeenCalledTimes(9)
+})
+
it.each(["market_data_missing", "market_data_invalid"])("retries temporary %s forecast failure after hours only when visible", async (reason) => {
vi.setSystemTime(new Date("2026-09-17T21:00:00Z"))
const unavailable = samplePage()
@@ -137,7 +192,10 @@ it.each(["market_data_missing", "market_data_invalid"])("retries temporary %s fo
it("does not poll terminal predictive unsupported reasons after hours", async () => {
vi.setSystemTime(new Date("2026-09-17T21:00:00Z"))
const unsupported = samplePage()
- Object.assign(unsupported.expirations[0].contracts[0], { predictive_odds: { status: "unavailable", reason: "horizon_unsupported" } })
+ Object.assign(unsupported.expirations[0].contracts[0], {
+ predictive_odds: { status: "unavailable", reason: "horizon_unsupported" },
+ physical_models: [{ status: "unavailable", evidence_key: null }],
+ })
fetchMock.mockResolvedValue(unsupported)
renderHook(() => useChainPage("IREN", "call", "itm"))
await act(async () => { await Promise.resolve() })
diff --git a/frontend/src/useChainPage.ts b/frontend/src/useChainPage.ts
index cf7176c..959f270 100644
--- a/frontend/src/useChainPage.ts
+++ b/frontend/src/useChainPage.ts
@@ -1,19 +1,22 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { fetchChain } from "./api"
+import { DEFAULT_FORECAST_MODEL, type PhysicalModel } from "./forecastModels"
import { isRegularMarketHours } from "./marketHours"
import type { ChainPage, Moneyness, Side, Ticker } from "./types"
const REFRESH_MS = 5 * 60_000
const PENDING_MS = 15_000
+const MAX_EVIDENCE_POLLS = 8
-export function useChainPage(ticker: Ticker, side: Side, moneyness: Moneyness) {
+export function useChainPage(ticker: Ticker, side: Side, moneyness: Moneyness, forecastModel: PhysicalModel = "lognormal_ewma") {
const [page, setPage] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const requestId = useRef(0)
const lastRequestedAt = useRef(0)
- const requestKey = `${ticker}|${side}|${moneyness}`
+ const requestKey = `${ticker}|${side}|${moneyness}|${forecastModel}`
+ const [evidencePolls, setEvidencePolls] = useState({ key: requestKey, count: 0 })
const [activeKey, setActiveKey] = useState(requestKey)
if (activeKey !== requestKey) {
setActiveKey(requestKey)
@@ -27,12 +30,15 @@ export function useChainPage(ticker: Ticker, side: Side, moneyness: Moneyness) {
selected: Ticker,
nextSide: Side,
nextMoneyness: Moneyness,
+ nextModel: PhysicalModel,
preservePage = false,
) => {
const id = ++requestId.current
lastRequestedAt.current = Date.now()
try {
- const result = await fetchChain(selected, nextSide, nextMoneyness)
+ const result = nextModel === DEFAULT_FORECAST_MODEL
+ ? await fetchChain(selected, nextSide, nextMoneyness)
+ : await fetchChain(selected, nextSide, nextMoneyness, nextModel)
if (id !== requestId.current) return
setPage(result)
setError(null)
@@ -46,23 +52,34 @@ export function useChainPage(ticker: Ticker, side: Side, moneyness: Moneyness) {
}, [])
useEffect(() => {
- void load(ticker, side, moneyness)
- }, [load, ticker, side, moneyness])
+ void load(ticker, side, moneyness, forecastModel)
+ }, [load, ticker, side, moneyness, forecastModel])
const pending = page?.expirations.some((group) => group.contracts.some(
- (row) => row.market_odds?.status === "pending" || row.predictive_odds?.status === "pending",
+ (row) => row.market_odds?.status === "pending"
+ || row.predictive_odds?.status === "pending"
+ || row.physical_models?.some((model) => model.status === "pending")
+ || row.market_models?.some((model) => model.status === "pending"),
)) ?? false
const retryableForecast = page?.expirations.some((group) => group.contracts.some(
(row) => row.predictive_odds?.status === "unavailable"
&& (row.predictive_odds.reason === "market_data_missing" || row.predictive_odds.reason === "market_data_invalid"),
)) ?? false
+ const missingEvidence = page?.expirations.some((group) => group.contracts.some(
+ (row) => row.physical_models?.some((model) => model.evidence_key && !page.model_evidence?.[model.evidence_key]),
+ )) ?? false
+ const evidencePending = missingEvidence && (evidencePolls.key !== requestKey || evidencePolls.count < MAX_EVIDENCE_POLLS)
useEffect(() => {
- const interval = pending ? PENDING_MS : REFRESH_MS
+ const interval = pending || evidencePending ? PENDING_MS : REFRESH_MS
const refresh = () => {
- if (document.hidden || loading || (!pending && !retryableForecast && !isRegularMarketHours())) return
+ if (document.hidden || loading || (!pending && !evidencePending && !retryableForecast && !isRegularMarketHours())) return
if (Date.now() - lastRequestedAt.current < interval) return
- void load(ticker, side, moneyness, true)
+ if (evidencePending && !pending) setEvidencePolls((current) => ({
+ key: requestKey,
+ count: (current.key === requestKey ? current.count : 0) + 1,
+ }))
+ void load(ticker, side, moneyness, forecastModel, true)
}
const timer = window.setInterval(refresh, interval)
document.addEventListener("visibilitychange", refresh)
@@ -70,7 +87,7 @@ export function useChainPage(ticker: Ticker, side: Side, moneyness: Moneyness) {
window.clearInterval(timer)
document.removeEventListener("visibilitychange", refresh)
}
- }, [load, loading, moneyness, pending, retryableForecast, side, ticker])
+ }, [load, loading, moneyness, pending, evidencePending, retryableForecast, side, ticker, forecastModel, requestKey])
const beginTickerChange = () => {
setPage(null)
@@ -79,9 +96,10 @@ export function useChainPage(ticker: Ticker, side: Side, moneyness: Moneyness) {
}
const beginRefresh = () => {
+ setEvidencePolls({ key: requestKey, count: 0 })
setLoading(true)
setError(null)
- void load(ticker, side, moneyness, true)
+ void load(ticker, side, moneyness, forecastModel, true)
}
return { page, error, loading, beginTickerChange, beginRefresh }
diff --git a/frontend/src/watchlist/WatchCard.tsx b/frontend/src/watchlist/WatchCard.tsx
index 1e8d0c9..be9b524 100644
--- a/frontend/src/watchlist/WatchCard.tsx
+++ b/frontend/src/watchlist/WatchCard.tsx
@@ -1,10 +1,12 @@
import { Button } from "@/components/ui/button"
+import { DEFAULT_FORECAST_MODEL, physicalModelName, type PhysicalModel } from "../forecastModels"
import { dateTime, moneyCents, percentTenths, unsignedPercentTenths } from "../format"
import { oddsAvailable, oddsProvenance, predictiveProvenance, quoteSupportLabel } from "../marketOdds"
import OddsValues from "../OddsValues"
+import ModelComparison from "../ModelComparison"
import type { WatchItem, WatchOutcome } from "./types"
-function OddsSection({ item }: { item: WatchItem }) {
+function OddsSection({ item, forecastModel, modelEvidence }: { item: WatchItem; forecastModel: PhysicalModel; modelEvidence?: Record | null }) {
const predictiveSource = predictiveProvenance(item.predictive_odds)
const marketSource = oddsProvenance(item.market_odds)
const prior = item.last_available_market_odds
@@ -12,6 +14,7 @@ function OddsSection({ item }: { item: WatchItem }) {
Expiry-close odds
+
{predictiveSource ? Forecast method {predictiveSource}
: null}
{marketSource ? Market odds: {marketSource}
: null}
{!oddsAvailable(item.market_odds) && prior && oddsAvailable(prior) ? (
@@ -26,17 +29,17 @@ function OddsSection({ item }: { item: WatchItem }) {
)
}
-function HypotheticalRiskSection({ risk, side }: { risk: WatchItem["hypothetical_risk"]; side: WatchItem["side"] }) {
+function HypotheticalRiskSection({ risk, side, forecastModel }: { risk: WatchItem["hypothetical_risk"]; side: WatchItem["side"]; forecastModel: PhysicalModel }) {
const available = risk?.status === "available"
&& risk.assumed_spot_cents != null && risk.assumed_bid_cents != null
&& risk.expected_pnl_cents != null && risk.expected_return_pct_tenths != null
&& risk.loss_pct_tenths != null && risk.p05_pnl_cents != null && risk.quote_session != null
return (
- Hypothetical hold-to-expiry risk
+ Hypothetical hold-to-expiry risk · {physicalModelName(forecastModel)}
{available ? (
<>
- One 100-share contract · {risk.forecast_price_basis === "intraday_quote" ? "validated intraday quote" : "completed-close forecast"}. Hypothetical hold-to-expiry; excludes dividends, fees, assignment. No trade or position is recorded.
+ {forecastModel === DEFAULT_FORECAST_MODEL ? "Baseline" : "User-selected experimental model"} · one 100-share contract · {risk.forecast_price_basis === "intraday_quote" ? "validated intraday quote" : "completed-close forecast"}. Hypothetical hold-to-expiry; excludes dividends, fees, assignment. No trade or position is recorded.
{side === "call" ? "Assumed stock entry" : "Underlying quote used"} {moneyCents(risk.assumed_spot_cents)} per share
Assumed option bid {moneyCents(risk.assumed_bid_cents)} per share
@@ -83,8 +86,10 @@ function Outcome({ outcome, expiration }: { outcome: WatchOutcome; expiration: s
)
}
-export default function WatchCard({ item, deleting, deleteError, onDelete }: {
+export default function WatchCard({ item, forecastModel = DEFAULT_FORECAST_MODEL, modelEvidence, deleting, deleteError, onDelete }: {
item: WatchItem
+ forecastModel?: PhysicalModel
+ modelEvidence?: Record | null
deleting: boolean
deleteError: string | null
onDelete: (id: string) => void
@@ -103,9 +108,9 @@ export default function WatchCard({ item, deleting, deleteError, onDelete }: {
{deleteError ? {deleteError}
: null}
-
+
-
+
)
diff --git a/frontend/src/watchlist/Watchlist.browser.test.tsx b/frontend/src/watchlist/Watchlist.browser.test.tsx
index 85f3905..ab94ee1 100644
--- a/frontend/src/watchlist/Watchlist.browser.test.tsx
+++ b/frontend/src/watchlist/Watchlist.browser.test.tsx
@@ -42,6 +42,7 @@ afterEach(() => {
cleanup()
vi.unstubAllGlobals()
window.sessionStorage.clear()
+ window.localStorage.clear()
})
it("shows dated market-implied ITM and OTM odds beside the expiry result", async () => {
@@ -248,6 +249,34 @@ it("uses a dated historical forecast when market bounds conflict and keeps prior
expect(risk.textContent).toContain("No trade or position is recorded")
})
+it("keeps an unavailable selected model unavailable while showing an available alternative", async () => {
+ window.history.replaceState(null, "", "/watchlist")
+ localStorage.setItem("hyperoptions.forecastModel", "student_t_ewma")
+ const item = {
+ ...watched,
+ predictive_odds: { method: "student_t_ewma", status: "unavailable", reason: "candidate_not_prepared" },
+ physical_models: [
+ { method: "lognormal_ewma", status: "available", itm_pct_tenths: 620, otm_pct_tenths: 380, atm_pct_tenths: 0 },
+ { method: "student_t_ewma", status: "unavailable", reason: "candidate_not_prepared" },
+ ],
+ market_models: [{ method: "regimelib", status: "pending", reason: "refreshing_quotes" }],
+ hypothetical_risk: { status: "unavailable", forecast_method: "student_t_ewma", reason: "Selected forecast unavailable" },
+ }
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL) => new Response(JSON.stringify({ items: [item] })))
+ vi.stubGlobal("fetch", fetchMock)
+ render( )
+ const odds = await screen.findByRole("region", { name: "Odds estimates" })
+ expect(odds.querySelector(".watch-odds")?.textContent).toContain("Forecast unavailable")
+ expect(odds.querySelector(".watch-odds")?.textContent).not.toContain("62.0%")
+ fireEvent.click(screen.getByText("Compare models"))
+ expect(odds.textContent).toContain("62.0% ITM")
+ expect(odds.textContent).toContain("candidate_not_prepared")
+ const risk = screen.getByRole("region", { name: "Hypothetical expiry risk" })
+ expect(risk.textContent).toContain("Student-t EWMA")
+ expect(risk.textContent).toContain("Selected forecast unavailable")
+ await waitFor(() => expect(fetchMock.mock.calls.some(([url]) => String(url) === "/api/watchlist?forecast_model=student_t_ewma")).toBe(true))
+})
+
it("labels the underlying quote for a cash-secured put without implying a stock purchase", async () => {
window.history.replaceState(null, "", "/watchlist")
const item = {
diff --git a/frontend/src/watchlist/Watchlist.tsx b/frontend/src/watchlist/Watchlist.tsx
index d9b20b8..7777caf 100644
--- a/frontend/src/watchlist/Watchlist.tsx
+++ b/frontend/src/watchlist/Watchlist.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"
import { Link } from "react-router-dom"
import { Button } from "@/components/ui/button"
import type { Job } from "../generated/types.gen"
+import { DEFAULT_FORECAST_MODEL, type PhysicalModel } from "../forecastModels"
import { dateTime } from "../format"
import { deleteWatch, getJob, getWatchlist, refreshWatchlist, WATCH_JOB_KEY } from "./api"
import type { WatchItem, WatchlistResponse } from "./types"
@@ -11,8 +12,10 @@ function message(error: unknown): string {
return error instanceof Error && error.message ? error.message : "The request failed."
}
-export default function Watchlist({ chainUrl = "/" }: { chainUrl?: string }) {
+export default function Watchlist({ chainUrl = "/", forecastModel = DEFAULT_FORECAST_MODEL }: { chainUrl?: string; forecastModel?: PhysicalModel }) {
const [items, setItems] = useState([])
+ const [modelEvidence, setModelEvidence] = useState | null>(null)
+ const [loadedModel, setLoadedModel] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [staleError, setStaleError] = useState(null)
@@ -25,10 +28,13 @@ export default function Watchlist({ chainUrl = "/" }: { chainUrl?: string }) {
const [jobId, setJobId] = useState(() => sessionStorage.getItem(WATCH_JOB_KEY))
const [job, setJob] = useState(null)
const mutationVersion = useRef(0)
- const pendingLoad = useRef | null>(null)
+ const pendingLoad = useRef<{ model: PhysicalModel; promise: Promise } | null>(null)
+ const activeModel = useRef(forecastModel)
+ useEffect(() => { activeModel.current = forecastModel }, [forecastModel])
const applyResponse = useCallback((response: WatchlistResponse) => {
setItems(response.items)
+ setModelEvidence(response.model_evidence ?? null)
setError(null)
setStaleError(null)
setLastLoadedAt(new Date().toISOString())
@@ -41,14 +47,19 @@ export default function Watchlist({ chainUrl = "/" }: { chainUrl?: string }) {
}, [])
const load = useCallback(() => {
- if (pendingLoad.current) return pendingLoad.current
+ if (pendingLoad.current?.model === forecastModel) return pendingLoad.current.promise
const version = mutationVersion.current
- const pending = getWatchlist()
- .then((response) => { if (version === mutationVersion.current) applyResponse(response) })
- .finally(() => { pendingLoad.current = null })
- pendingLoad.current = pending
+ const pending = getWatchlist(forecastModel)
+ .then((response) => {
+ if (version === mutationVersion.current && activeModel.current === forecastModel) {
+ applyResponse(response)
+ setLoadedModel(forecastModel)
+ }
+ })
+ .finally(() => { if (pendingLoad.current?.promise === pending) pendingLoad.current = null })
+ pendingLoad.current = { model: forecastModel, promise: pending }
return pending
- }, [applyResponse])
+ }, [applyResponse, forecastModel])
useEffect(() => {
let active = true
@@ -181,15 +192,15 @@ export default function Watchlist({ chainUrl = "/" }: { chainUrl?: string }) {
) : null}
{jobBusy && !job ? Checking expiry results…
: null}
{notice ? {notice}
: null}
- {loading ? Loading watched contracts…
: null}
+ {loading || (!error && loadedModel !== forecastModel) ? Loading watched contracts for the selected model…
: null}
{error ? Could not load the watchlist. {error}
{ setLoading(true); void load().catch((cause) => setError(message(cause))).finally(() => setLoading(false)) }}>Retry : null}
{staleError && !error ? Could not refresh the watchlist. Showing the last loaded watchlist{lastLoadedAt ? ` from ${dateTime(lastLoadedAt)}` : ""}. {staleError}
{ void retryStale() }}>{retrying ? "Retrying…" : "Retry"} : null}
- {!loading && !error && items.length === 0 ? (
+ {!loading && !error && loadedModel === forecastModel && items.length === 0 ? (
No watched contracts yet Open the option chain and use Watch on a supported contract.
Browse option chain
) : null}
- {!loading && !error && items.length > 0 ? (
+ {!loading && !error && loadedModel === forecastModel && items.length > 0 ? (
- {items.map((item) => { void remove(id) }} />)}
+ {items.map((item) => { void remove(id) }} />)}
) : null}
diff --git a/frontend/src/watchlist/api.test.ts b/frontend/src/watchlist/api.test.ts
new file mode 100644
index 0000000..9e43d9e
--- /dev/null
+++ b/frontend/src/watchlist/api.test.ts
@@ -0,0 +1,14 @@
+import { afterEach, expect, it, vi } from "vitest"
+import { addWatch, getWatchlist } from "./api"
+
+afterEach(() => vi.unstubAllGlobals())
+
+it("uses the selected physical model on watch reads and creation", async () => {
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ items: [] })))
+ vi.stubGlobal("fetch", fetchMock)
+ await getWatchlist("gjr_garch_t")
+ await addWatch("opaque-key", "gjr_garch_t")
+ expect(fetchMock.mock.calls[0][0]).toBe("/api/watchlist?forecast_model=gjr_garch_t")
+ expect(fetchMock.mock.calls[1][0]).toBe("/api/watchlist?forecast_model=gjr_garch_t")
+ expect(fetchMock.mock.calls[1][1]).toEqual(expect.objectContaining({ method: "POST", body: JSON.stringify({ watch_key: "opaque-key" }) }))
+})
diff --git a/frontend/src/watchlist/api.ts b/frontend/src/watchlist/api.ts
index 8dbdd23..b920054 100644
--- a/frontend/src/watchlist/api.ts
+++ b/frontend/src/watchlist/api.ts
@@ -1,11 +1,13 @@
import { getJson, postJson, readError } from "./client"
+import { DEFAULT_FORECAST_MODEL, type PhysicalModel } from "../forecastModels"
import type { Job, WatchCreate } from "../generated/types.gen"
import type { AddWatchResponse, RefreshResponse, WatchlistResponse } from "./types"
export const WATCH_JOB_KEY = "hyperoptions.watchlist.job"
-export const getWatchlist = () => getJson("/api/watchlist")
-export const addWatch = (watchKey: string) => postJson("/api/watchlist", { watch_key: watchKey } satisfies WatchCreate)
+const modelQuery = (model: PhysicalModel) => model === DEFAULT_FORECAST_MODEL ? "" : `?forecast_model=${model}`
+export const getWatchlist = (model: PhysicalModel = DEFAULT_FORECAST_MODEL) => getJson(`/api/watchlist${modelQuery(model)}`)
+export const addWatch = (watchKey: string, model: PhysicalModel = DEFAULT_FORECAST_MODEL) => postJson(`/api/watchlist${modelQuery(model)}`, { watch_key: watchKey } satisfies WatchCreate)
export const refreshWatchlist = () => postJson("/api/watchlist/refresh", {})
export const getJob = (id: string) => getJson(`/api/jobs/${encodeURIComponent(id)}`)
diff --git a/scripts/dev b/scripts/dev
index 8138572..cbbdf47 100755
--- a/scripts/dev
+++ b/scripts/dev
@@ -103,10 +103,23 @@ need npm
need python3
need curl
need lsof
+need git
[ -d "$ROOT/backend" ] || die "backend/ not found"
[ -d "$ROOT/frontend" ] || die "frontend/ not found"
+printf 'checking origin/main...\n'
+VERSION_CHECK_STATUS=$(python3 "$ROOT/scripts/dev_update.py" "$ROOT") || exit 1
+case "$VERSION_CHECK_STATUS" in
+ updated) printf 'updated main to origin/main\n' ;;
+ current) printf 'main is current\n' ;;
+ offline) printf 'warning: remote version unverified; starting the clean local main offline\n' >&2 ;;
+ *) die "unexpected update status" ;;
+esac
+OPTIONS_APP_SHA=$(git -C "$ROOT" rev-parse HEAD)
+VITE_APP_SHA=$OPTIONS_APP_SHA
+export OPTIONS_APP_SHA VITE_APP_SHA
+
if port_in_use "$BACKEND_PORT"; then
die "port $BACKEND_PORT is already in use (backend)"
fi
@@ -115,10 +128,10 @@ if port_in_use "$FRONTEND_PORT"; then
fi
printf 'syncing backend deps...\n'
-(cd "$ROOT/backend" && uv sync --group dev --group research)
+(cd "$ROOT/backend" && uv sync --frozen --group dev --group research)
printf 'installing frontend deps...\n'
-(cd "$ROOT/frontend" && npm install)
+(cd "$ROOT/frontend" && npm ci)
printf 'starting backend on 127.0.0.1:%s\n' "$BACKEND_PORT"
(
diff --git a/scripts/dev_update.py b/scripts/dev_update.py
new file mode 100644
index 0000000..d7962e5
--- /dev/null
+++ b/scripts/dev_update.py
@@ -0,0 +1,87 @@
+"""Verify and fast-forward a clean local main before starting the dev app."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+CANONICAL_ORIGINS = {
+ "git@github.com:hypertrial/hyperoptions.git",
+ "ssh://git@github.com/hypertrial/hyperoptions.git",
+ "https://github.com/hypertrial/hyperoptions.git",
+ "https://github.com/hypertrial/hyperoptions",
+}
+SSH_COMMAND = "ssh -oBatchMode=yes -oConnectTimeout=4 -oConnectionAttempts=1"
+
+
+def git(root: Path, *args: str, timeout: int = 5) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ ["git", *args],
+ cwd=root,
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_SSH_COMMAND": SSH_COMMAND},
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ )
+
+
+def checked(root: Path, *args: str) -> str:
+ result = git(root, *args)
+ if result.returncode:
+ detail = result.stderr.strip() or "unknown error"
+ raise RuntimeError(f"git {' '.join(args)} failed: {detail}")
+ return result.stdout.strip()
+
+
+def require_fast_forward(root: Path, remote_sha: str) -> str:
+ local_sha = checked(root, "rev-parse", "HEAD")
+ if git(root, "merge-base", "--is-ancestor", local_sha, remote_sha).returncode:
+ raise RuntimeError("main has diverged from origin/main; resolve it before starting")
+ return local_sha
+
+
+def update(root: Path) -> str:
+ if Path(checked(root, "rev-parse", "--show-toplevel")).resolve() != root.resolve():
+ raise RuntimeError("run ./scripts/dev from this repository checkout")
+ branch = checked(root, "symbolic-ref", "--short", "HEAD")
+ if branch != "main":
+ raise RuntimeError(f"checkout main before starting (current branch: {branch})")
+ if checked(root, "status", "--porcelain", "--untracked-files=normal"):
+ raise RuntimeError("commit or stash local changes before starting on main")
+ origin = checked(root, "remote", "get-url", "origin")
+ if origin not in CANONICAL_ORIGINS:
+ raise RuntimeError("origin must be the canonical hypertrial/hyperoptions GitHub repository")
+
+ try:
+ fetched = git(
+ root,
+ "fetch",
+ "--no-tags",
+ "origin",
+ "refs/heads/main:refs/remotes/origin/main",
+ timeout=15,
+ )
+ except subprocess.TimeoutExpired:
+ fetched = None
+ if fetched is None or fetched.returncode:
+ cached = git(root, "rev-parse", "--verify", "refs/remotes/origin/main")
+ if cached.returncode == 0:
+ require_fast_forward(root, cached.stdout.strip())
+ return "offline"
+
+ remote_sha = checked(root, "rev-parse", "refs/remotes/origin/main")
+ local_sha = require_fast_forward(root, remote_sha)
+ if local_sha == remote_sha:
+ return "current"
+ checked(root, "merge", "--ff-only", remote_sha)
+ return "updated"
+
+
+if __name__ == "__main__":
+ try:
+ print(update(Path(sys.argv[1])))
+ except (IndexError, OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
+ sys.exit(f"error: {exc}")