gloomberb 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/bin/gloomberb +9 -1
  2. package/package.json +1 -1
  3. package/src/api-client/central-bank-rates.ts +3 -0
  4. package/src/api-client/types.ts +1 -1
  5. package/src/app/pane-runtime/index.tsx +7 -5
  6. package/src/app/runtime/broker-import.test.tsx +89 -0
  7. package/src/app/runtime/broker-import.ts +29 -8
  8. package/src/brokers/sync-broker-instance.ts +67 -0
  9. package/src/cli/commands/overview.ts +6 -2
  10. package/src/cli/native-smoke.ts +2 -0
  11. package/src/components/chart/composite/composite-chart.tsx +21 -1
  12. package/src/components/chart/composite/format.ts +71 -8
  13. package/src/components/chart/composite/fx-precision.test.ts +21 -3
  14. package/src/components/chart/composite/scene.ts +13 -1
  15. package/src/components/chart/composite/types.ts +4 -0
  16. package/src/components/layout/market-summary.ts +4 -2
  17. package/src/components/ticker/badge/format.test.ts +3 -3
  18. package/src/components/ticker/badge/format.ts +11 -9
  19. package/src/components/ticker/badge/text.test.tsx +6 -6
  20. package/src/components/ui/index.ts +2 -1
  21. package/src/components/ui/multi-select/dialog.tsx +46 -4
  22. package/src/components/ui/number-prompt-dialog.tsx +71 -0
  23. package/src/core/state/app/state.ts +8 -2
  24. package/src/market-data/coordinator/events.ts +5 -0
  25. package/src/market-data/coordinator/index.ts +44 -10
  26. package/src/market-data/market/format.test.ts +54 -0
  27. package/src/market-data/market/format.ts +164 -5
  28. package/src/market-data/query-store.test.ts +87 -0
  29. package/src/market-data/query-store.ts +66 -9
  30. package/src/plugins/builtin/alerts/format.ts +4 -3
  31. package/src/plugins/builtin/analytics/display.ts +3 -3
  32. package/src/plugins/builtin/analytics/index.test.tsx +22 -22
  33. package/src/plugins/builtin/analytics/pane-model.test.ts +10 -10
  34. package/src/plugins/builtin/analytics/pane-model.ts +8 -8
  35. package/src/plugins/builtin/analytics/portfolio-identity.test.tsx +6 -6
  36. package/src/plugins/builtin/analytics/risk-client.ts +6 -8
  37. package/src/plugins/builtin/analytics/risk-model.test.ts +21 -2
  38. package/src/plugins/builtin/analytics/risk-model.ts +8 -0
  39. package/src/plugins/builtin/analytics/view.tsx +2 -2
  40. package/src/plugins/builtin/backtest/engine.test.ts +0 -1
  41. package/src/plugins/builtin/backtest/engine.ts +1 -4
  42. package/src/plugins/builtin/backtest/pane.tsx +1 -1
  43. package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
  44. package/src/plugins/builtin/central-bank-rates/client.ts +2 -1
  45. package/src/plugins/builtin/central-bank-rates/headless.ts +2 -2
  46. package/src/plugins/builtin/central-bank-rates/model.test.ts +16 -1
  47. package/src/plugins/builtin/central-bank-rates/model.ts +4 -4
  48. package/src/plugins/builtin/central-bank-rates/pane.tsx +2 -2
  49. package/src/plugins/builtin/chart-composer/pane.tsx +50 -3
  50. package/src/plugins/builtin/chart-composer/presets.test.ts +17 -0
  51. package/src/plugins/builtin/chart-composer/presets.ts +48 -0
  52. package/src/plugins/builtin/chart-composer/settings.ts +42 -11
  53. package/src/plugins/builtin/chat/chat.test.tsx +2 -2
  54. package/src/plugins/builtin/cloud/askg/pane.tsx +1 -1
  55. package/src/plugins/builtin/cloud/thesis/board-pane.test.tsx +25 -0
  56. package/src/plugins/builtin/cloud/thesis/board-pane.tsx +3 -2
  57. package/src/plugins/builtin/cloud/thesis/store.ts +2 -2
  58. package/src/plugins/builtin/congress-trades/detail.tsx +1 -1
  59. package/src/plugins/builtin/congress-trades/table.tsx +1 -1
  60. package/src/plugins/builtin/cot/client.ts +2 -1
  61. package/src/plugins/builtin/cot/model.test.ts +9 -0
  62. package/src/plugins/builtin/cot/model.ts +30 -1
  63. package/src/plugins/builtin/cot/pane.tsx +2 -4
  64. package/src/plugins/builtin/credit-conditions/client.ts +2 -1
  65. package/src/plugins/builtin/crypto-board/client.ts +2 -1
  66. package/src/plugins/builtin/crypto-board/model.test.ts +9 -0
  67. package/src/plugins/builtin/crypto-board/model.ts +11 -3
  68. package/src/plugins/builtin/custom-view/live-quotes.test.ts +6 -1
  69. package/src/plugins/builtin/custom-view/live-quotes.ts +26 -0
  70. package/src/plugins/builtin/custom-view/pane.tsx +18 -7
  71. package/src/plugins/builtin/custom-view/view-spec.test.ts +24 -6
  72. package/src/plugins/builtin/custom-view/view-spec.ts +53 -21
  73. package/src/plugins/builtin/debt-maturities/client.ts +16 -3
  74. package/src/plugins/builtin/debt-maturities/debt-maturities.test.ts +8 -0
  75. package/src/plugins/builtin/debt-maturities/headless.ts +3 -2
  76. package/src/plugins/builtin/debt-maturities/model.ts +2 -1
  77. package/src/plugins/builtin/debt-maturities/pane.tsx +0 -8
  78. package/src/plugins/builtin/earnings-calls/pane.test.tsx +14 -0
  79. package/src/plugins/builtin/earnings-calls/pane.tsx +3 -1
  80. package/src/plugins/builtin/econ/fred-client.test.ts +2 -0
  81. package/src/plugins/builtin/econ/fred-series-map.ts +0 -1
  82. package/src/plugins/builtin/econ/index.tsx +4 -2
  83. package/src/plugins/builtin/econ-statistics/pane.tsx +4 -2
  84. package/src/plugins/builtin/econ-statistics/stats.ts +29 -24
  85. package/src/plugins/builtin/econ-statistics/view.test.ts +16 -0
  86. package/src/plugins/builtin/equity-screener/headless.test.ts +22 -0
  87. package/src/plugins/builtin/equity-screener/headless.ts +18 -10
  88. package/src/plugins/builtin/estimate-revisions/client.ts +3 -1
  89. package/src/plugins/builtin/estimate-revisions/model.test.ts +7 -0
  90. package/src/plugins/builtin/estimate-revisions/model.ts +0 -5
  91. package/src/plugins/builtin/futures/table.tsx +4 -3
  92. package/src/plugins/builtin/futures-curve/client.ts +2 -1
  93. package/src/plugins/builtin/futures-curve/model.test.ts +6 -4
  94. package/src/plugins/builtin/futures-curve/model.ts +18 -14
  95. package/src/plugins/builtin/futures-curve/pane.tsx +2 -2
  96. package/src/plugins/builtin/fx-matrix/index.tsx +21 -4
  97. package/src/plugins/builtin/fx-matrix/live-legs.ts +7 -0
  98. package/src/plugins/builtin/fx-matrix/pairs.test.ts +5 -0
  99. package/src/plugins/builtin/fx-matrix/pairs.ts +9 -3
  100. package/src/plugins/builtin/insider/index.tsx +15 -12
  101. package/src/plugins/builtin/insider/pane.test.tsx +24 -0
  102. package/src/plugins/builtin/market-movers/headless.ts +2 -2
  103. package/src/plugins/builtin/market-movers/model.ts +23 -10
  104. package/src/plugins/builtin/market-movers/screener.test.ts +5 -1
  105. package/src/plugins/builtin/market-movers/screener.ts +3 -0
  106. package/src/plugins/builtin/market-movers/table.ts +2 -2
  107. package/src/plugins/builtin/market-valuation/client.test.ts +3 -0
  108. package/src/plugins/builtin/market-valuation/client.ts +6 -1
  109. package/src/plugins/builtin/money-markets/client.ts +2 -1
  110. package/src/plugins/builtin/money-markets/pane.tsx +0 -1
  111. package/src/plugins/builtin/options/expiry-selection.test.tsx +1 -1
  112. package/src/plugins/builtin/options/footer.test.ts +2 -2
  113. package/src/plugins/builtin/options/footer.ts +2 -2
  114. package/src/plugins/builtin/options/market-reference.test.ts +2 -2
  115. package/src/plugins/builtin/options/market-reference.ts +14 -2
  116. package/src/plugins/builtin/options/table.ts +5 -8
  117. package/src/plugins/builtin/options/view.test.tsx +2 -2
  118. package/src/plugins/builtin/options/view.tsx +1 -1
  119. package/src/plugins/builtin/options-calculator/pane.test.tsx +6 -6
  120. package/src/plugins/builtin/options-calculator/pane.tsx +23 -8
  121. package/src/plugins/builtin/options-calculator/surface.test.ts +16 -1
  122. package/src/plugins/builtin/options-calculator/surface.ts +13 -6
  123. package/src/plugins/builtin/portfolio-list/column-values.ts +17 -9
  124. package/src/plugins/builtin/portfolio-list/index.test.ts +5 -5
  125. package/src/plugins/builtin/portfolio-list/metrics.test.ts +8 -8
  126. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +12 -12
  127. package/src/plugins/builtin/portfolio-list/position-cost-integrity.test.ts +2 -2
  128. package/src/plugins/builtin/portfolio-list/summary/index.tsx +7 -6
  129. package/src/plugins/builtin/rate-path/client.ts +2 -1
  130. package/src/plugins/builtin/rate-path/headless.ts +1 -1
  131. package/src/plugins/builtin/rate-path/pane.tsx +0 -1
  132. package/src/plugins/builtin/realized-vol/client.test.ts +7 -0
  133. package/src/plugins/builtin/realized-vol/headless.ts +1 -1
  134. package/src/plugins/builtin/realized-vol/model.test.ts +16 -4
  135. package/src/plugins/builtin/realized-vol/model.ts +24 -9
  136. package/src/plugins/builtin/relative-rotation/client.ts +2 -1
  137. package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -10
  138. package/src/plugins/builtin/risk-factors/data.test.ts +7 -0
  139. package/src/plugins/builtin/risk-factors/data.ts +8 -1
  140. package/src/plugins/builtin/risk-factors/pane.test.tsx +3 -0
  141. package/src/plugins/builtin/shared/screener-live-quotes.test.ts +3 -3
  142. package/src/plugins/builtin/shared/screener-live-quotes.ts +16 -1
  143. package/src/plugins/builtin/short-volume/client.ts +2 -1
  144. package/src/plugins/builtin/short-volume/pane.tsx +0 -1
  145. package/src/plugins/builtin/thirteenf/headless.ts +3 -3
  146. package/src/plugins/builtin/thirteenf/model.ts +14 -4
  147. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +12 -5
  148. package/src/plugins/builtin/thirteenf/overlap.test.ts +13 -1
  149. package/src/plugins/builtin/thirteenf/overlap.ts +31 -3
  150. package/src/plugins/builtin/thirteenf/pane.test.tsx +2 -1
  151. package/src/plugins/builtin/thirteenf/pane.tsx +12 -9
  152. package/src/plugins/builtin/thirteenf/table.ts +9 -3
  153. package/src/plugins/builtin/ticker-detail/financials/model.ts +0 -12
  154. package/src/plugins/builtin/ticker-detail/headless-schema.ts +4 -1
  155. package/src/plugins/builtin/ticker-detail/index.test.tsx +1 -1
  156. package/src/plugins/builtin/ticker-detail/overview/components.tsx +9 -10
  157. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  158. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +6 -9
  159. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +3 -2
  160. package/src/plugins/builtin/time-sales/model.test.ts +17 -1
  161. package/src/plugins/builtin/time-sales/model.ts +14 -3
  162. package/src/plugins/builtin/time-sales/pane.tsx +8 -4
  163. package/src/plugins/builtin/treasury-auctions/pane.tsx +2 -2
  164. package/src/plugins/builtin/vol-surface/client.ts +4 -1
  165. package/src/plugins/builtin/vol-surface/model.test.ts +4 -2
  166. package/src/plugins/builtin/vol-surface/model.ts +14 -2
  167. package/src/plugins/builtin/vol-surface/pane.tsx +9 -2
  168. package/src/plugins/builtin/volatility/client.test.ts +13 -8
  169. package/src/plugins/builtin/volatility/client.ts +3 -2
  170. package/src/plugins/builtin/volatility/headless.test.ts +2 -2
  171. package/src/plugins/builtin/volatility/model.test.ts +4 -0
  172. package/src/plugins/builtin/volatility/model.ts +6 -7
  173. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -2
  174. package/src/renderers/electrobun/bun/desktop/workspace/index.test.ts +33 -1
  175. package/src/renderers/electrobun/bun/desktop/workspace/index.ts +34 -1
  176. package/src/renderers/electrobun/bun/index.ts +2 -1
  177. package/src/renderers/opentui/render-timing-smoke.ts +20 -0
  178. package/src/sources/provider-router/profile-enrichment.test.ts +0 -3
  179. package/src/sources/yahoo-finance.test.ts +5 -1
  180. package/src/sources/yahoo-finance.ts +2 -1
  181. package/src/time-series/forward-valuation.ts +6 -0
  182. package/src/time-series/resolve.ts +5 -1
  183. package/src/time-series/valuation-currency.test.ts +2 -0
  184. package/src/time-series/valuation-currency.ts +3 -1
  185. package/src/utils/exchange-rate-snapshot.test.ts +12 -1
  186. package/src/utils/exchange-rate-snapshot.ts +3 -2
  187. package/src/utils/format.ts +55 -20
  188. package/src/utils/fx-market-hours.test.ts +28 -0
  189. package/src/utils/fx-market-hours.ts +42 -0
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useState } from "react";
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  DataTableView,
4
4
  usePaneFooter,
@@ -22,7 +22,7 @@ import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming";
22
22
  import { useAutoRefresh, useUpdatedAgo } from "../shared/auto-refresh";
23
23
  import { useLiveStreamingSetting } from "../shared/live-streaming";
24
24
  import { isStreamCarryingQuote } from "../shared/use-quote-board";
25
- import { fxLegQuoteKey, fxLegTargets, fxLegs, liveFxLegEntry } from "./live-legs";
25
+ import { fxLegQuoteKey, fxLegReferenceRate, fxLegTargets, fxLegs, liveFxLegEntry } from "./live-legs";
26
26
  import { MAJOR_CURRENCIES, formatRate, resolveCurrencies, type MajorCurrency } from "./pairs";
27
27
  import { createFxExportMetadata } from "./export";
28
28
 
@@ -64,6 +64,22 @@ function FxMatrixPane({ focused, width, height }: PaneProps) {
64
64
  for (const [currency, entry] of liveEntries) merged.set(currency, entry.data!);
65
65
  return merged;
66
66
  }, [liveEntries, snapshotRates]);
67
+ // A cell's decimals are counted at a rate that holds still, the pair's
68
+ // previous close or else the first rate this pane drew, never at the tick.
69
+ // The snapshot rates are no reference: they follow the stream too.
70
+ const firstRates = useRef(new Map<string, number>());
71
+ const referenceRates = useMemo(() => {
72
+ const references = new Map<string, number>([["USD", 1]]);
73
+ for (const leg of legs) {
74
+ const rate = rates.get(leg.currency);
75
+ if (!firstRates.current.has(leg.currency) && rate != null && Number.isFinite(rate) && rate > 0) {
76
+ firstRates.current.set(leg.currency, rate);
77
+ }
78
+ const reference = fxLegReferenceRate(leg, legEntries.get(fxLegQuoteKey(leg))) ?? firstRates.current.get(leg.currency);
79
+ if (reference != null) references.set(leg.currency, reference);
80
+ }
81
+ return references;
82
+ }, [legEntries, legs, rates]);
67
83
  // Export the provenance of this render's rates, even if a provider response
68
84
  // arrives before React commits the next render and the user exports now.
69
85
  const rateEntries = new Map(currencies.map((currency) => {
@@ -137,8 +153,9 @@ function FxMatrixPane({ focused, width, height }: PaneProps) {
137
153
  const pending = status.loading > 0;
138
154
  return { text: pending ? "…" : "—", color: dimmed };
139
155
  }
140
- return { text: formatRate(base / quote, quoteCurrency), color: selectedColor ?? colors.text };
141
- }, [rates, status.loading]);
156
+ const reference = (referenceRates.get(row) ?? base) / (referenceRates.get(quoteCurrency) ?? quote);
157
+ return { text: formatRate(base / quote, quoteCurrency, reference), color: selectedColor ?? colors.text };
158
+ }, [rates, referenceRates, status.loading]);
142
159
 
143
160
  const handleKeyDown = useCallback((event: DataTableKeyEvent) => {
144
161
  if (!isPlainKey(event, "r")) return false;
@@ -68,3 +68,10 @@ export function liveFxLegEntry(
68
68
  attempts: [],
69
69
  };
70
70
  }
71
+
72
+ /** USD per unit at the pair's previous close: a rate that holds still for the session. */
73
+ export function fxLegReferenceRate(leg: FxLeg, entry: QueryEntry<Quote> | undefined): number | null {
74
+ const close = resolveEntryData(entry)?.previousClose;
75
+ if (close == null || !Number.isFinite(close) || close <= 0) return null;
76
+ return leg.inverted ? 1 / close : close;
77
+ }
@@ -9,3 +9,8 @@ test("inverse yen rates retain meaningful precision instead of rounding a materi
9
9
  expect(formatRate(Number.POSITIVE_INFINITY, "USD")).toBe("—");
10
10
  expect(formatRate(0, "USD")).toBe("—");
11
11
  });
12
+
13
+ test("a cross ticking across a power of ten keeps the decimals of its reference rate", () => {
14
+ expect(formatRate(0.00999, "AUD", 0.01001)).toBe("0.009990");
15
+ expect(formatRate(0.01001, "AUD", 0.00999)).toBe("0.0100100");
16
+ });
@@ -2,10 +2,16 @@
2
2
  export const MAJOR_CURRENCIES = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"] as const;
3
3
  export type MajorCurrency = typeof MAJOR_CURRENCIES[number];
4
4
 
5
- /** Preserve conventional quote precision and meaningful digits on inverse JPY crosses. */
6
- export function formatRate(rate: number, toCurrency: string): string {
5
+ /**
6
+ * Preserve conventional quote precision and meaningful digits on inverse JPY
7
+ * crosses. The digits are counted at `referenceRate` (the cross at a rate that
8
+ * holds still) when there is one, so a live rate ticking across a power of ten,
9
+ * such as JPY/AUD near 0.0100, keeps its decimals.
10
+ */
11
+ export function formatRate(rate: number, toCurrency: string, referenceRate = rate): string {
7
12
  if (!Number.isFinite(rate) || rate <= 0) return "—";
8
- const decimals = rate < 0.1 ? Math.min(8, 4 - Math.floor(Math.log10(rate))) : toCurrency === "JPY" ? 2 : 4;
13
+ const basis = Number.isFinite(referenceRate) && referenceRate > 0 ? referenceRate : rate;
14
+ const decimals = basis < 0.1 ? Math.min(8, 4 - Math.floor(Math.log10(basis))) : toCurrency === "JPY" ? 2 : 4;
9
15
  return rate.toFixed(decimals);
10
16
  }
11
17
 
@@ -42,6 +42,9 @@ import { usePaneTickerIdentity } from "../../../state/hooks/pane-ticker";
42
42
  export { insiderHeadless } from "./headless";
43
43
 
44
44
  const FORM4_PAGE_SIZE = 20;
45
+ // The first page grows to every filing inside the 90-day window, up to this
46
+ // many, so the totals do not depend on how far the list is scrolled.
47
+ const INSIDER_WINDOW_CAP = 120;
45
48
  // Recent EDGAR dumps cap at 1,000 mixed forms. Older archives are fetched
46
49
  // until this many filings or company history ends.
47
50
  const SEC_FILING_SCAN_LIMIT = 20_000;
@@ -142,16 +145,22 @@ function InsiderView({ width, height, focused }: { width: number; height: number
142
145
  () => allFilings.filter((f) => isInsiderForm(f.form)),
143
146
  [allFilings],
144
147
  );
148
+ const windowCount = useMemo(() => {
149
+ const cutoff = insiderSummaryCutoff();
150
+ return form4Filings.findLastIndex((filing) => new Date(filing.filingDate).getTime() >= cutoff) + 1;
151
+ }, [form4Filings]);
152
+ const windowShown = Math.min(windowCount, INSIDER_WINDOW_CAP);
145
153
  const [visibleCount, setVisibleCount] = useState(FORM4_PAGE_SIZE);
154
+ const shownCount = Math.max(visibleCount, windowShown);
146
155
  const visibleForm4Filings = useMemo(
147
- () => form4Filings.slice(0, visibleCount),
148
- [form4Filings, visibleCount],
156
+ () => form4Filings.slice(0, shownCount),
157
+ [form4Filings, shownCount],
149
158
  );
150
159
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
151
160
  const loadMore = useTableLoadMore(
152
161
  scrollRef,
153
- visibleCount < form4Filings.length,
154
- () => setVisibleCount((current) => Math.min(form4Filings.length, current + FORM4_PAGE_SIZE)),
162
+ shownCount < form4Filings.length,
163
+ () => setVisibleCount((current) => Math.min(form4Filings.length, Math.max(current, windowShown) + FORM4_PAGE_SIZE)),
155
164
  );
156
165
  useEffect(() => {
157
166
  setVisibleCount(FORM4_PAGE_SIZE);
@@ -202,14 +211,8 @@ function InsiderView({ width, height, focused }: { width: number; height: number
202
211
  if (summary) lastSummaryRef.current = { scope: summaryScope, figures: summary };
203
212
  const shownSummary = summary ?? (lastSummaryRef.current?.scope === summaryScope ? lastSummaryRef.current.figures : null);
204
213
  const statItems = useMemo(() => shownSummary ? summaryItems(shownSummary) : [], [shownSummary]);
205
- // The totals only cover pages already loaded; they are whole once the
206
- // oldest loaded filing predates the window or every filing is loaded.
207
- const oldestLoaded = visibleForm4Filings.reduce<number | null>((oldest, filing) => {
208
- const time = new Date(filing.filingDate).getTime();
209
- return Number.isFinite(time) && (oldest == null || time < oldest) ? time : oldest;
210
- }, null);
211
- const windowPartial = visibleCount < form4Filings.length
212
- && oldestLoaded != null && oldestLoaded >= insiderSummaryCutoff();
214
+ // The totals miss window filings only when the window passes the cap.
215
+ const windowPartial = shownCount < windowCount;
213
216
  const insiderOptions = useMemo(() => {
214
217
  const names = new Set<string>();
215
218
  for (const entry of allParsed) {
@@ -44,6 +44,16 @@ async function mount(width: number, explanationOnly = false) {
44
44
  await act(async () => { setup = await testRender(<Harness width={width} />, { width, height: 30 }); });
45
45
  await settle();
46
46
  }
47
+ async function mountWindow(inWindow: number) {
48
+ const day = 24 * 60 * 60 * 1000;
49
+ const isoDate = (time: number) => new Date(time).toISOString().slice(0, 10);
50
+ const filings = Array.from({ length: inWindow + 1 }, (_, index) => ({ accessionNumber: `f${index}`, form: "4",
51
+ filingDate: new Date(Date.now() - (index < inWindow ? 1 + index % 60 : 120) * day), cik: "999", filingUrl: `https://www.sec.gov/f${index}` }));
52
+ setSharedMarketDataCoordinator(new MarketDataCoordinator(createTestDataProvider({ getSecFilings: async () => filings,
53
+ getSecFilingContent: async (filing) => `<ownershipDocument><documentType>4</documentType><reportingOwner><reportingOwnerId><rptOwnerName>A. OFFICER</rptOwnerName><rptOwnerCik>111</rptOwnerCik></reportingOwnerId></reportingOwner><nonDerivativeTransaction><securityTitle><value>Class A</value></securityTitle><transactionDate><value>${isoDate(new Date(filing.filingDate).getTime())}</value></transactionDate><transactionCoding><transactionCode>P</transactionCode></transactionCoding><transactionAmounts><transactionShares><value>100</value></transactionShares><transactionPricePerShare><value>10</value></transactionPricePerShare></transactionAmounts></nonDerivativeTransaction></ownershipDocument>` })));
54
+ await act(async () => { setup = await testRender(<Harness width={80} />, { width: 80, height: 30 }); });
55
+ for (let i = 0; i < 40 && (i < 8 || setup!.captureCharFrame().includes("loading")); i++) await settle();
56
+ }
47
57
  afterEach(async () => {
48
58
  if (setup) await act(async () => { setup!.renderer.destroy(); });
49
59
  setup = undefined;
@@ -86,3 +96,17 @@ test("owner filtering keeps explanation-only amendments and clears amendment sta
86
96
  expect(setup!.captureCharFrame()).not.toContain("A. OFFICER");
87
97
  expect(setup!.captureCharFrame()).not.toContain("⚠");
88
98
  });
99
+
100
+ test("the 90-day totals load every filing in the window, past the first page", async () => {
101
+ await mountWindow(25);
102
+ const frame = setup!.captureCharFrame();
103
+ expect(frame).toContain("2.5k shares");
104
+ expect(frame).not.toContain("⚠");
105
+ });
106
+
107
+ test("a window larger than the cap still says the totals are partial", async () => {
108
+ await mountWindow(121);
109
+ const frame = setup!.captureCharFrame();
110
+ expect(frame).toContain("12k shares");
111
+ expect(frame).toContain("⚠");
112
+ });
@@ -5,13 +5,13 @@ import type {
5
5
  } from "../../../types/plugin";
6
6
  import { formatCompact, formatNumber, formatPercentRaw } from "../../../utils/format";
7
7
  import { loadMarketMoverTab, type MarketMoverTabResult } from "./client";
8
- import { createRows, fiftyTwoWeekPositionPercent, formatMoverPrice, type TabId } from "./model";
8
+ import { createRows, fiftyTwoWeekPositionPercent, formatMoverPrice, moverReferencePrice, type MarketMoverRow, type TabId } from "./model";
9
9
 
10
10
  const COLUMNS = [
11
11
  { key: "rank", header: "Rank", align: "right" as const },
12
12
  { key: "symbol", header: "Symbol" },
13
13
  { key: "name", header: "Name" },
14
- { key: "price", header: "Last", align: "right" as const, format: (value: unknown, row: Record<string, unknown>) => formatMoverPrice(typeof value === "number" ? value : null, typeof row.currency === "string" ? row.currency : "") },
14
+ { key: "price", header: "Last", align: "right" as const, format: (value: unknown, row: Record<string, unknown>) => formatMoverPrice(typeof value === "number" ? value : null, typeof row.currency === "string" ? row.currency : "", moverReferencePrice(row as unknown as MarketMoverRow)) },
15
15
  { key: "changePercent", header: "Change %", align: "right" as const, format: (value: unknown) => value == null ? "—" : formatPercentRaw(Number(value)) },
16
16
  { key: "volume", header: "Volume", align: "right" as const, format: (value: unknown) => value == null ? "—" : formatCompact(Number(value), { fixedDecimals: true }) },
17
17
  { key: "volumeRatio", header: "Vol / Avg", align: "right" as const, format: (value: unknown) => value == null ? "—" : formatNumber(Number(value), 1) },
@@ -3,7 +3,7 @@ import { overlayScreenerQuoteEntries } from "../shared/screener-live-quotes";
3
3
  import type { Quote } from "../../../types/financials";
4
4
  import type { QueryEntry } from "../../../market-data/result-types";
5
5
  import { formatNumber } from "../../../utils/format";
6
- import { formatMarketPriceWithCurrency } from "../../../market-data/market/format";
6
+ import { formatMarketPriceWithCurrency, stablePriceFractionDigits } from "../../../market-data/market/format";
7
7
  import type { DataTableColumn } from "../../../components";
8
8
  import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
9
9
  import { MARKET_SUMMARY_SYMBOLS, convertScreenerPriceUnit, screenerNumber, screenerVolume, screenerVolumeRatio, type MarketSummaryQuote, type ScreenerCategory, type ScreenerQuote } from "./screener";
@@ -156,7 +156,7 @@ export function summaryQuoteFromQuote(
156
156
  };
157
157
  }
158
158
 
159
- export function screenerQuoteFromQuote(symbol: string, quote: { name?: string; price?: number; change?: number; changePercent?: number; volume?: number; currency?: string; exchangeName?: string; listingExchangeName?: string; lastUpdated?: number }): ScreenerQuote {
159
+ export function screenerQuoteFromQuote(symbol: string, quote: { name?: string; price?: number; change?: number; changePercent?: number; volume?: number; currency?: string; exchangeName?: string; listingExchangeName?: string; lastUpdated?: number; previousClose?: number }): ScreenerQuote {
160
160
  return {
161
161
  symbol,
162
162
  name: quote.name ?? symbol,
@@ -174,27 +174,39 @@ export function screenerQuoteFromQuote(symbol: string, quote: { name?: string; p
174
174
  dayLow: undefined,
175
175
  exchange: quote.listingExchangeName ?? quote.exchangeName ?? "",
176
176
  lastUpdated: quote.lastUpdated,
177
+ previousClose: screenerNumber(quote.previousClose) ?? undefined,
177
178
  };
178
179
  }
179
180
 
180
- /** A missing listing currency cannot be represented by a USD symbol. */
181
- function currencyMinorDigits(currency: string): number {
182
- try { return new Intl.NumberFormat("en-US", { style: "currency", currency }).resolvedOptions().maximumFractionDigits ?? 2; }
183
- catch { return 2; }
181
+ /** The session-fixed price a mover's decimals are read from: its previous
182
+ * close, else price less change (the same close), and only without either the
183
+ * price itself. */
184
+ export function moverReferencePrice(row: Pick<ScreenerQuote, "price" | "change" | "previousClose">): number | undefined {
185
+ if (row.previousClose != null && Number.isFinite(row.previousClose) && row.previousClose > 0) return row.previousClose;
186
+ if (row.price == null) return undefined;
187
+ // Rounded so float residue on a close of exactly $1 cannot flip the decimals between ticks.
188
+ const close = row.change == null ? Number.NaN : Number((row.price - row.change).toPrecision(12));
189
+ return Number.isFinite(close) && close > 0 ? close : row.price;
184
190
  }
185
191
 
186
- /** Listing currency at its minor unit (¥ none, $ two), keeping a sub-cent coin's digits. */
187
- export function formatMoverPrice(price: number | null, currency: string): string {
192
+ /** Listing currency at its minor unit (¥ none, $ two), four decimals under one
193
+ * unit and a sub-cent coin's digits. A missing listing currency cannot be
194
+ * represented by a USD symbol. The decimals come from `referencePrice`, so a
195
+ * streamed tick landing on $0.50 or crossing $1 keeps the column's digits. */
196
+ export function formatMoverPrice(price: number | null, currency: string, referencePrice: number | null | undefined = price): string {
188
197
  if (!currency) return formatNumber(price ?? undefined);
189
198
  const unit = resolveCurrencyUnit(currency);
199
+ const reference = referencePrice == null ? undefined : referencePrice / unit.divisor;
200
+ // Movers are priced as stocks: the currency's minor unit, not a fixed two, sets the digits above one unit.
201
+ const fixedFractionDigits = stablePriceFractionDigits({ assetCategory: "EQUITY", currency: unit.currency, referencePrice: reference });
190
202
  return formatMarketPriceWithCurrency(price == null ? undefined : price / unit.divisor, unit.currency,
191
- { minimumFractionDigits: currencyMinorDigits(unit.currency) });
203
+ { fixedFractionDigits, referencePrice: reference });
192
204
  }
193
205
 
194
206
  /** Keyed by the overlaid row, which the shared overlay keeps while its quote holds. */
195
207
  const convertedOverlays = new WeakMap<ScreenerQuote, ScreenerQuote>();
196
208
 
197
- /** Range endpoints belong to the original screener price denomination. */
209
+ /** Range endpoints and the previous close belong to the original screener price denomination. */
198
210
  export function overlayMarketMoverQuotes(
199
211
  rows: readonly ScreenerQuote[],
200
212
  entries: ReadonlyMap<string, QueryEntry<Quote>>,
@@ -211,6 +223,7 @@ export function overlayMarketMoverQuotes(
211
223
  fiftyTwoWeekHigh: convert(original.fiftyTwoWeekHigh),
212
224
  dayLow: convert(original.dayLow),
213
225
  dayHigh: convert(original.dayHigh),
226
+ previousClose: row.previousClose ?? convert(original.previousClose),
214
227
  };
215
228
  convertedOverlays.set(row, converted);
216
229
  return converted;
@@ -321,9 +321,13 @@ describe("fetchScreener", () => {
321
321
  });
322
322
 
323
323
  test("mover prices keep the currency's minor unit and a sub-cent coin's digits", async () => {
324
- const { formatMoverPrice } = await import("./model");
324
+ const { formatMoverPrice, moverReferencePrice } = await import("./model");
325
325
  expect(formatMoverPrice(0.00001234, "USD")).toBe("$0.00001234");
326
326
  expect(formatMoverPrice(33.4, "USD")).toBe("$33.40");
327
327
  expect(formatMoverPrice(45678, "JPY")).toBe("¥45,678");
328
328
  expect(formatMoverPrice(1234.5, "GBp")).toBe("£12.35");
329
+ // Decimals come from the session's close, never the tick: a sub-dollar stock
330
+ // landing on $0.50 or crossing $1 keeps its four.
331
+ expect(formatMoverPrice(0.5, "USD", moverReferencePrice({ price: 0.5, change: -0.0123 }))).toBe("$0.5000");
332
+ expect(formatMoverPrice(1.2, "USD", moverReferencePrice({ price: 1.2, change: 0.4 }))).toBe("$1.2000");
329
333
  });
@@ -177,6 +177,8 @@ export interface ScreenerQuote {
177
177
  dayLow: number | undefined;
178
178
  exchange: string;
179
179
  lastUpdated?: number;
180
+ /** Session-fixed, so the price column reads its decimals from it rather than from each tick. */
181
+ previousClose?: number;
180
182
  }
181
183
 
182
184
  export type MarketMoversDataSource = "cloud" | "yahoo";
@@ -260,6 +262,7 @@ export function parseScreenerResponse(data: any): ScreenerQuote[] {
260
262
  fiftyTwoWeekLow: screenerNumber(q.fiftyTwoWeekLow) ?? undefined,
261
263
  dayHigh: screenerNumber(q.regularMarketDayHigh) ?? undefined,
262
264
  dayLow: screenerNumber(q.regularMarketDayLow) ?? undefined,
265
+ previousClose: screenerNumber(q.regularMarketPreviousClose) ?? undefined,
263
266
  exchange: typeof (q.fullExchangeName ?? q.exchange) === "string" ? q.fullExchangeName ?? q.exchange : "",
264
267
  lastUpdated: typeof q.regularMarketTime === "number" && Number.isFinite(q.regularMarketTime) && q.regularMarketTime > 0
265
268
  ? q.regularMarketTime * 1000 : undefined,
@@ -3,7 +3,7 @@ import { TextAttributes } from "../../../ui";
3
3
  import { colors, priceColor } from "../../../theme/colors";
4
4
  import { formatCompact, formatPercentRaw } from "../../../utils/format";
5
5
  import type { MarketMoverColumn, MarketMoverRow } from "./model";
6
- import { fiftyTwoWeekPositionPercent, formatMoverPrice } from "./model";
6
+ import { fiftyTwoWeekPositionPercent, formatMoverPrice, moverReferencePrice } from "./model";
7
7
 
8
8
  function formatVolRatio(ratio: number | null): string {
9
9
  if (ratio == null || !Number.isFinite(ratio) || ratio < 0) return "—";
@@ -68,7 +68,7 @@ export function renderMarketMoverCell(
68
68
  case "name":
69
69
  return { text: row.name, color: selectedColor };
70
70
  case "price":
71
- return { text: formatMoverPrice(row.price, row.currency), color: selectedColor };
71
+ return { text: formatMoverPrice(row.price, row.currency, moverReferencePrice(row)), color: selectedColor };
72
72
  case "changePercent":
73
73
  return {
74
74
  text: formatPercentRaw(row.changePercent ?? undefined),
@@ -232,12 +232,15 @@ test("refresh bypasses fresh caches, shares pending Shiller work and retains fai
232
232
  expect(calls).toBe(3);
233
233
  expect(cached.errors).toEqual(failed.errors);
234
234
  expect(getCachedValuationBundle(indicators)!.errors).toEqual(failed.errors);
235
+ expect(getCachedValuationBundle(indicators)!.builds[0]!.sourceStale).toBe(true);
235
236
  now += 1000; failing = false; cape = 50;
236
237
  const recovered = await load(true);
237
238
  expect(calls).toBe(4);
238
239
  expect(recovered.errors).toEqual([]);
239
240
  expect(recovered.sources!.SHILLER_CAPE).toMatchObject({ stale: false, source: "network", fetchedAt: now });
240
241
  now += 6 * 60 * 60 * 1000 + 1; cape = 60;
242
+ // A cache past its refresh time is revalidated, not late at the source.
243
+ expect(getCachedValuationBundle(indicators)!.builds[0]!.sourceStale).toBe(false);
241
244
  const expired = await load();
242
245
  expect(calls).toBe(5);
243
246
  expect(expired.builds[0]!.series.points.at(-1)!.ratio).toBe(60);
@@ -87,7 +87,12 @@ function buildIndicators(
87
87
  builds.push({
88
88
  indicator,
89
89
  series,
90
- sourceStale: indicatorSeries(indicator).some((def) => legs.get(def.key)?.stale === true || legs.get(def.key)?.provider?.stale === true),
90
+ // A cached leg past its refresh time is not late at the source; a cached
91
+ // first paint is revalidated at once, and a failed refresh keeps its error.
92
+ sourceStale: indicatorSeries(indicator).some((def) => {
93
+ const leg = legs.get(def.key);
94
+ return leg?.provider?.stale === true || !!leg?.refreshError;
95
+ }),
91
96
  trend: fitIndicatorTrend(indicator, series.points),
92
97
  });
93
98
  } catch (error) {
@@ -56,7 +56,8 @@ export async function fetchMoneyMarkets(client: Pick<typeof apiClient, "getCloud
56
56
  export interface MoneyMarketsResource { payload: MoneyMarketsPayload; stale: boolean; refreshError: string | null }
57
57
  export function getCachedMoneyMarkets(): MoneyMarketsResource | null {
58
58
  const cached = moneyMarketsCache.get("usd", { allowExpired: true });
59
- return cached ? { payload: cached.data, stale: cached.stale, refreshError: null } : null;
59
+ // The pane revalidates this copy on mount; only a failed refresh makes it stale.
60
+ return cached ? { payload: cached.data, stale: false, refreshError: null } : null;
60
61
  }
61
62
  export async function loadMoneyMarkets(force = false): Promise<MoneyMarketsResource> {
62
63
  const result = await moneyMarketsCache.load("usd", () => fetchMoneyMarkets(), { force });
@@ -86,7 +86,6 @@ export function MoneyMarketsPane({ width, height, focused }: PaneProps) {
86
86
  info: data ? [
87
87
  ...(updatedAgo ? [{ id: "updated", parts: [{ text: updatedAgo, tone: "muted" as const }] }] : []),
88
88
  ...(resource.data?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "warning" as const }] }] : []),
89
- ...(data.status !== "available" ? [{ id: "partial", parts: [{ text: data.status, tone: "warning" as const }] }] : []),
90
89
  ] : [],
91
90
  });
92
91
  return <Box width={width} height={height} flexDirection="column">
@@ -203,7 +203,7 @@ test.each([48, 80, 120])("retains a chosen expiry across unchanged, reordered an
203
203
  const result = await f.capture(label);
204
204
  expect(result.launch?.marketReference?.expiration).toBe(EXPIRIES[1]);
205
205
  expect(result.launch?.marketPrice).toBe(20);
206
- expect(result.csv).toContain("19,21,10.0%,20");
206
+ expect(result.csv).toContain("19.00,21.00,10.0%,20.00");
207
207
  expect(result.frame).not.toContain("AAPL261120");
208
208
  }
209
209
  });
@@ -38,8 +38,8 @@ describe("selected contract status", () => {
38
38
  optionContractFooterSegments(...args).map((segment) => segment.parts.map((part) => part.text).join(" "));
39
39
 
40
40
  test("yields the spread to a visible SPRD column, but never the reason one is missing", () => {
41
- expect(texts(reference(10.05, 10.15), false, NOW)).toContain("spread 0.1 (1.0% of mid)");
42
- expect(texts(reference(10.05, 10.15), true, NOW)).not.toContain("spread 0.1 (1.0% of mid)");
41
+ expect(texts(reference(10.05, 10.15), false, NOW)).toContain("spread 0.10 (1.0% of mid)");
42
+ expect(texts(reference(10.05, 10.15), true, NOW)).not.toContain("spread 0.10 (1.0% of mid)");
43
43
  // The column renders every degenerate quote as one dash, so the status bar
44
44
  // keeps naming them whether or not it is shown.
45
45
  for (const visible of [false, true]) {
@@ -7,7 +7,7 @@ import { useCloudAccessFooter } from "../shared/cloud-upgrade";
7
7
  import { usePaneStatusFooter } from "../shared/pane-footer";
8
8
  import { CLOUD_QUOTE_DELAY_MINUTES } from "../shared/plan-access";
9
9
  import type { OptionQuoteCoverage, OptionQuoteCoverageStatus } from "./live-quotes";
10
- import { optionSpread, type OptionMarketReference } from "./market-reference";
10
+ import { formatOptionPrice, optionSpread, type OptionMarketReference } from "./market-reference";
11
11
 
12
12
  export interface OptionsCoverageState {
13
13
  text: string;
@@ -57,7 +57,7 @@ export function optionContractFooterSegments(
57
57
  id: "options-spread",
58
58
  parts: [
59
59
  { text: "spread", tone: "label" },
60
- { text: `${spread.spread} (${spread.percentOfMid.toFixed(1)}% of mid)`, tone: "value" },
60
+ { text: `${formatOptionPrice(spread.spread)} (${spread.percentOfMid.toFixed(1)}% of mid)`, tone: "value" },
61
61
  ],
62
62
  };
63
63
  return [
@@ -9,7 +9,7 @@ const recorded = {
9
9
  test("keeps historical trades distinct and never substitutes retrieval time for a quote clock", () => {
10
10
  const reference = optionMarketReference({ ...recorded, asOf: "2026-09-11T14:51:16Z" })!;
11
11
  const lines = optionMarketReferenceLines(reference).join("\n");
12
- expect(lines).toContain("spread 5.75 (4.89% of mid)");
12
+ expect(lines).toContain("Bid 114.80 · Ask 120.55 · spread 5.75 (4.89% of mid)");
13
13
  expect(lines).toContain("Quote time unavailable");
14
14
  expect(lines).toContain("Last 72.55 · trade 2026-08-13 14:19:55 UTC");
15
15
  expect(lines).not.toContain("14:51:16");
@@ -25,7 +25,7 @@ test("does not fabricate a midpoint for one-sided, crossed, missing or malformed
25
25
  expect(lines).not.toContain("% of mid");
26
26
  expect(lines).toContain("Last — · trade time unavailable");
27
27
  }
28
- expect(optionMarketReferenceLines({ ...recorded, bid: 1, ask: 1 }).join("\n")).toContain("spread 0 (0.00% of mid)");
28
+ expect(optionMarketReferenceLines({ ...recorded, bid: 1, ask: 1 }).join("\n")).toContain("spread 0.00 (0.00% of mid)");
29
29
  });
30
30
 
31
31
  test("round-trips only valid optional reference metadata across saved pane params", () => {
@@ -1,4 +1,5 @@
1
1
  import type { OptionContract } from "../../../types/financials";
2
+ import { formatMarketPrice, stablePriceFractionDigits } from "../../../market-data/market/format";
2
3
  import { formatExpDate } from "../../../utils/options";
3
4
 
4
5
  /** A saved contract observation, never a claim that its prices are executable. */
@@ -33,7 +34,18 @@ function timestamp(milliseconds: number | undefined): string | null {
33
34
  return Number.isFinite(date.getTime()) ? date.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " UTC") : null;
34
35
  }
35
36
 
36
- const price = (value: number) => Number.isFinite(value) && value > 0 ? String(Number(value.toPrecision(12))) : "—";
37
+ const OPTION_PRICE_DIGITS = stablePriceFractionDigits({ assetCategory: "OPT" });
38
+
39
+ /**
40
+ * A premium at the contract's fixed decimals whatever the quote, so a bid on a
41
+ * whole dime prints 1.20 rather than 1.2 and streamed prices hold their width.
42
+ * A cell too narrow drops grouping first, then decimals.
43
+ */
44
+ export function formatOptionPrice(value: number, maxWidth?: number): string {
45
+ return formatMarketPrice(value, { assetCategory: "OPT", fixedFractionDigits: OPTION_PRICE_DIGITS, maxWidth });
46
+ }
47
+
48
+ const price = (value: number) => Number.isFinite(value) && value > 0 ? formatOptionPrice(value) : "—";
37
49
 
38
50
  /**
39
51
  * The spread is the one quote fact the chain table cannot show: its columns
@@ -67,7 +79,7 @@ export function optionMarketReferenceLines(reference: OptionMarketReference): st
67
79
  const { bid, ask } = reference;
68
80
  const spread = optionSpread(reference);
69
81
  const market = spread.kind === "two-sided"
70
- ? `spread ${spread.spread} (${spread.percentOfMid.toFixed(2)}% of mid)`
82
+ ? `spread ${formatOptionPrice(spread.spread)} (${spread.percentOfMid.toFixed(2)}% of mid)`
71
83
  : spread.kind === "crossed" ? "crossed quote; no midpoint"
72
84
  : spread.kind === "one-sided" ? "one-sided quote; no midpoint" : "bid/ask unavailable";
73
85
  const quoteTime = timestamp(reference.lastUpdated);
@@ -4,8 +4,7 @@ import type { OptionContract, OptionsChain } from "../../../types/financials";
4
4
  import { blendHex, colors } from "../../../theme/colors";
5
5
  import { blendForContrast, blendForSeparation, contrastRatio } from "../../../theme/color-utils";
6
6
  import { formatCompact } from "../../../utils/format";
7
- import { formatMarketPrice } from "../../../market-data/market/format";
8
- import { optionQuoteSide, optionSpread } from "./market-reference";
7
+ import { formatOptionPrice, optionQuoteSide, optionSpread } from "./market-reference";
9
8
  import type {
10
9
  OptionColumn,
11
10
  OptionFieldId,
@@ -250,20 +249,18 @@ function formatOptionContractCell(
250
249
  const greeks = optionGreeksForColumn(row, column);
251
250
  switch (column.field) {
252
251
  case "last":
253
- return contract.lastPrice > 0
254
- ? formatMarketPrice(contract.lastPrice, { assetCategory: "OPT", maxWidth: column.width })
255
- : "—";
252
+ return contract.lastPrice > 0 ? formatOptionPrice(contract.lastPrice, column.width) : "—";
256
253
  case "bid":
257
254
  case "ask": {
258
255
  const quote = optionQuoteSide(contract, column.field);
259
- return quote == null ? "—" : formatMarketPrice(quote, { assetCategory: "OPT", maxWidth: column.width });
256
+ return quote == null ? "—" : formatOptionPrice(quote, column.width);
260
257
  }
261
258
  case "spread":
262
259
  return formatSpreadPercent(contract);
263
260
  case "volume":
264
- return formatCompact(contract.volume);
261
+ return formatCompact(contract.volume, { fixedDecimals: true });
265
262
  case "openInterest":
266
- return formatCompact(contract.openInterest);
263
+ return formatCompact(contract.openInterest, { fixedDecimals: true });
267
264
  case "iv":
268
265
  return formatIv(row.impliedVolatility);
269
266
  case "delta":
@@ -254,7 +254,7 @@ test("shows volatility statistics and mirrored default Greeks", async () => {
254
254
  const frame = testSetup!.captureCharFrame();
255
255
  expect(frame).toMatch(/ATM IV\s+90\.1%/);
256
256
  expect(frame).toMatch(/HV30\s+--/);
257
- expect(frame).toMatch(/Volume\s+4k/);
257
+ expect(frame).toMatch(/Volume\s+4\.0k/);
258
258
  expect(frame).toContain("C Δ");
259
259
  expect(frame).toContain("C Γ");
260
260
  expect(frame).toContain("P Γ");
@@ -540,7 +540,7 @@ test("starts at a held contract's expiry and preserves a researcher-selected rol
540
540
  expect(testSetup!.captureCharFrame()).toContain("Loading options chain");
541
541
  await act(async () => { selectTicker(ticker); });
542
542
  await renderSettled();
543
- expect(testSetup!.captureCharFrame()).toMatch(/34\.05\s+0\.3%\s+34\s+.*340/);
543
+ expect(testSetup!.captureCharFrame()).toMatch(/34\.05\s+0\.3%\s+34\.00\s+.*340/);
544
544
  });
545
545
 
546
546
  test("keeps the selected chain visible when its refresh fails", async () => {
@@ -117,7 +117,7 @@ function optionsSummaryItems({ summary, enrichment, currency, ivRank }: {
117
117
  { id: "sigma", label: "1σ fit", ...move(enrichment?.expectedMove.sigma, enrichment?.expectedMove.sigmaPercent) },
118
118
  { id: "skew", label: "Skew", value: skew == null ? "--" : `${skew} pp`, detail: "25d P-C" },
119
119
  { id: "slope", label: "Slope", value: slope == null ? "--" : `${slope} ${slopeAnnualized ? "pp/y" : "pts"}`, detail: slopeTo },
120
- { id: "volume", label: "Volume", value: summary?.expirationVolume == null ? "--" : formatCompact(summary.expirationVolume) },
120
+ { id: "volume", label: "Volume", value: summary?.expirationVolume == null ? "--" : formatCompact(summary.expirationVolume, { fixedDecimals: true }) },
121
121
  { id: "pc-volume", label: "P/C vol", value: formatRatio(summary?.putCallVolumeRatio) },
122
122
  { id: "pc-oi", label: "P/C OI", value: formatRatio(summary?.putCallOpenInterestRatio) },
123
123
  ];
@@ -122,17 +122,17 @@ test("a chain-seeded calculator follows the contract and underlying until the us
122
122
  await act(async () => { emit!(underlying, quote("COST", { price: 910 })); });
123
123
  await settle();
124
124
  let frame = testSetup!.captureCharFrame();
125
- expect(frame).toMatch(/Spot\s+900/);
125
+ expect(frame).toMatch(/Spot\s+900\.00/);
126
126
  expect(frame).toContain("Snapshot:");
127
127
  expect(frame).not.toContain("real-time market");
128
128
  await act(async () => { emit!(contract, quote(contract.symbol, { price: 24, bid: 23.9, ask: 24.1 })); });
129
129
  await settle();
130
130
  frame = testSetup!.captureCharFrame();
131
- expect(frame).toMatch(/Mid\s+24(?!\.)/);
132
- expect(frame).toMatch(/Spot\s+910/);
131
+ expect(frame).toMatch(/Mid\s+24\.000/);
132
+ expect(frame).toMatch(/Spot\s+910\.00/);
133
133
  // The contract context is the live quote now, not a saved snapshot.
134
134
  expect(frame).not.toContain("Snapshot:");
135
- expect(frame).toContain("Bid 23.9 · Ask 24.1");
135
+ expect(frame).toContain("Bid 23.90 · Ask 24.10");
136
136
  const liveIv = frame.match(/Implied IV\s+([\d.]+)%/)![1];
137
137
  const expected = valueOption({ ...draftFromParams({}), side: "call", spot: 910, strike: 905, rate: 0.04,
138
138
  daysToExpiry: daysToExpiryFrom(expiration, Date.now()), volatility: Number(liveIv) / 100, dividendYield: 0 }).price;
@@ -146,8 +146,8 @@ test("a chain-seeded calculator follows the contract and underlying until the us
146
146
  await act(async () => { emit!(underlying, quote("COST", { price: 915 })); });
147
147
  await settle();
148
148
  frame = testSetup!.captureCharFrame();
149
- expect(frame).toMatch(/Spot\s+905/);
150
- expect(frame).toMatch(/Mid\s+24(?!\.)/);
149
+ expect(frame).toMatch(/Spot\s+905(?!\.)/);
150
+ expect(frame).toMatch(/Mid\s+24\.000/);
151
151
  });
152
152
 
153
153
  test("narrow results keep contract context reachable while scrolling Greeks and editing fixed inputs", async () => {