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
@@ -11,6 +11,7 @@ import { formatNumber } from "../../../utils/format";
11
11
  import { isPlainKey } from "../../../utils/keyboard";
12
12
  import { OPTIONS_CALCULATOR_PANE_ID, daysToExpiryFrom, describeDraftProblem, draftFromParams, reconcileOptionCalcDraft, solveImpliedVolatility, updateOptionCalcDraft, valueOption, type OptionCalcDraft, type OptionSide } from "./model";
13
13
  import { buildQuoteKey, resolveEntryData } from "../../../market-data/selectors";
14
+ import { liveQuoteFormatOptions } from "../../../market-data/market/format";
14
15
  import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming";
15
16
  import type { QuoteSubscriptionTarget } from "../../../types/data-provider";
16
17
  import { buildOptionQuoteKey, freshOptionQuote, OPTIONS_QUOTE_EXCHANGE } from "../options/live-quotes";
@@ -126,6 +127,13 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
126
127
  // The contract's remaining time runs with the clock while its price is live.
127
128
  daysToExpiry: liveInputs.price != null && reference ? daysToExpiryFrom(reference.expiration, Date.now()) : draft.daysToExpiry,
128
129
  marketReference: liveInputs.reference }, [draft, liveInputs, reference]);
130
+ // A spot that follows the underlying keeps the underlying's decimals, so a
131
+ // tick on a whole dime shows 910.10 rather than 910.1. A typed spot shows as typed.
132
+ const underlyingQuote = spotLinked ? resolveEntryData(liveEntries.get(buildQuoteKey({ symbol: draft.symbol, exchange: "" }))) : null;
133
+ const spotText = spotLinked
134
+ ? linkedDraft.spot.toFixed(liveQuoteFormatOptions(underlyingQuote, underlyingQuote?.currency ?? reference?.currency).fixedFractionDigits ?? 2)
135
+ : String(linkedDraft.spot);
136
+ const daysRunning = liveInputs?.price != null && !!reference;
129
137
  const effectiveDraft = useMemo(() => ({ ...linkedDraft, dividends: dividendInput.dividends,
130
138
  volatility: surfaceSource && surface?.volatility != null ? surface.volatility : linkedDraft.volatility }), [linkedDraft, dividendInput.dividends, surfaceSource, surface?.volatility]);
131
139
 
@@ -135,7 +143,7 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
135
143
  }, [seed, setDraft]);
136
144
 
137
145
  const fields = useMemo<GridField[]>(() => [
138
- { id: "spot", label: "Spot", value: linkedDraft.spot, valueText: String(linkedDraft.spot), onValue: (value) => updateDraft({ spot: value }) },
146
+ { id: "spot", label: "Spot", value: linkedDraft.spot, valueText: spotText, onValue: (value) => updateDraft({ spot: value }) },
139
147
  { id: "strike", label: "Strike", value: draft.strike, valueText: String(draft.strike), onValue: (value) => updateDraft({ strike: value }) },
140
148
  {
141
149
  id: "days",
@@ -143,11 +151,14 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
143
151
  value: linkedDraft.daysToExpiry,
144
152
  // Keep intraday expiry visible; rounding six hours to "0 d" makes a
145
153
  // live contract appear expired while its time value is still priced.
146
- valueText: Number.isInteger(linkedDraft.daysToExpiry)
147
- ? String(linkedDraft.daysToExpiry)
148
- : linkedDraft.daysToExpiry > 0 && linkedDraft.daysToExpiry < 0.0001
149
- ? "<0.0001"
150
- : String(Number(linkedDraft.daysToExpiry.toFixed(4))),
154
+ // Days that run with the clock keep all four decimals from tick to tick.
155
+ valueText: linkedDraft.daysToExpiry > 0 && linkedDraft.daysToExpiry < 0.0001
156
+ ? "<0.0001"
157
+ : daysRunning
158
+ ? linkedDraft.daysToExpiry.toFixed(4)
159
+ : Number.isInteger(linkedDraft.daysToExpiry)
160
+ ? String(linkedDraft.daysToExpiry)
161
+ : String(Number(linkedDraft.daysToExpiry.toFixed(4))),
151
162
  suffix: "d",
152
163
  onValue: (value) => updateDraft({ daysToExpiry: Math.max(0, value) }),
153
164
  },
@@ -158,14 +169,18 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
158
169
  id: "marketPrice",
159
170
  label: draft.marketPriceSource === "mid" ? "Mid" : draft.marketPriceSource === "last" ? "Last" : draft.marketPrice > 0 ? "Input" : "Market",
160
171
  value: linkedDraft.marketPrice,
161
- valueText: String(Number(linkedDraft.marketPrice.toPrecision(12))),
172
+ // A linked mid or last streams, so it keeps fixed decimals; a mid of two
173
+ // penny quotes can land on a half cent, so it keeps three.
174
+ valueText: draft.marketPriceSource === "mid" ? linkedDraft.marketPrice.toFixed(3)
175
+ : draft.marketPriceSource === "last" ? linkedDraft.marketPrice.toFixed(2)
176
+ : String(Number(linkedDraft.marketPrice.toPrecision(12))),
162
177
  // Clearing the field is how a standalone user says "no market price".
163
178
  onValue: (value) => updateDraft({ marketPrice: Math.max(0, value), marketPriceSource: undefined }),
164
179
  onClear: () => updateDraft({ marketPrice: 0, marketPriceSource: undefined }),
165
180
  },
166
181
  ...(american ? [{ id: "steps", label: "Steps", value: draft.steps ?? 400, valueText: String(draft.steps ?? 400),
167
182
  onValue: (value: number) => updateDraft({ steps: value }) }] : []),
168
- ], [draft, linkedDraft, updateDraft, american, surfaceSource, effectiveDraft.volatility, surface?.volatility]);
183
+ ], [draft, linkedDraft, spotText, daysRunning, updateDraft, american, surfaceSource, effectiveDraft.volatility, surface?.volatility]);
169
184
 
170
185
  const calculation = useMemo(() => {
171
186
  const unavailable = seedError ?? (american ? dividendInput.error : null)
@@ -60,15 +60,30 @@ describe("calculator surface source", () => {
60
60
  expect(projected.volatility).toBeCloseTo(month.cells[0]!.volatility!, 10);
61
61
  });
62
62
 
63
+ test("brackets quoted in the same New York session do not warn about differing dates", async () => {
64
+ const quoted = (asOf: Record<number, string>) => dependencies({ loadOptions: async (query) => {
65
+ const expiration = query.expirationDate ?? expirations[0]!;
66
+ return ready({ ...chain(expiration), asOf: asOf[expiration] ?? "2026-09-22T13:45:00Z" });
67
+ } });
68
+ const same = await loadCalculatorSurfaceVol(request, quoted({ [expirations[0]!]: "2026-09-22T13:45:00.004Z", [expirations[1]!]: "2026-09-22T13:44:00.001Z" }));
69
+ expect(same.error).toBeNull();
70
+ expect(same.asOf).toBe("2026-09-22T13:44:00.001Z");
71
+ expect(same.warnings.some((warning) => warning.startsWith("Surface quote dates differ"))).toBe(false);
72
+ const mixed = await loadCalculatorSurfaceVol(request, quoted({ [expirations[1]!]: "2026-09-21T19:59:59Z" }));
73
+ expect(mixed.warnings).toContain("Surface quote dates differ: 2026-09-21, 2026-09-22");
74
+ });
75
+
63
76
  test("exact listed tenor needs one usable slice and does not require a neighboring smile", async () => {
64
77
  const result = await loadCalculatorSurfaceVol({ ...request, daysToExpiry: days(expirations[1]!) }, dependencies({
65
78
  loadOptions: async (query) => {
66
- if (query.expirationDate === expirations[2]) throw new Error("Unused neighboring expiry offline");
79
+ if (query.expirationDate != null && query.expirationDate !== expirations[1]) throw new Error("Unused neighboring expiry offline");
67
80
  return ready(chain(query.expirationDate ?? expirations[0]!));
68
81
  },
69
82
  }));
70
83
  expect(result.error).toBeNull();
71
84
  expect(result.volatility).toBeCloseTo(.4, 4);
85
+ // The representative front expiry is loaded too, but its failure does not concern this price.
86
+ expect(result.warnings).toEqual([]);
72
87
  });
73
88
 
74
89
  test("pins actual adjacent listings with bounded requests and reuses the selected OMON cache", async () => {
@@ -6,7 +6,7 @@ import type { OptionsChain, Quote } from "../../../types/financials";
6
6
  import type { DataProvider } from "../../../types/data-provider";
7
7
  import { canonicalExchange, parsePublicTickerKey } from "../../../utils/exchanges";
8
8
  import { loadVolatilitySurface, type SurfaceLoaderDependencies } from "../vol-surface/client";
9
- import { evaluateSurfaceSmile, type SurfaceSnapshot } from "../vol-surface/model";
9
+ import { evaluateSurfaceSmile, surfaceCalendarWarnings, type SurfaceExpiry, type SurfaceSnapshot } from "../vol-surface/model";
10
10
  import { interpolateTotalVariance, logForwardMoneyness } from "../shared/volatility";
11
11
  import { daysToExpiryFrom } from "./model";
12
12
  import type { YieldPoint } from "../yield-curve/treasury-data";
@@ -56,6 +56,8 @@ const positive = (value: unknown): value is number => typeof value === "number"
56
56
  const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
57
57
  const message = (error: unknown): string => error instanceof Error ? error.message : String(error);
58
58
  const expiryLabel = (expiration: number): string => new Date(expiration * 1000).toISOString().slice(0, 10);
59
+ /** Brackets are fetched separately, so their quote instants never match; the New York session is what must agree. */
60
+ const quoteSession = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit" });
59
61
  const empty = (error: string, warnings: string[] = []): CalculatorSurfaceVol => ({
60
62
  volatility: null, rate: null, dividendYield: null, asOf: null, sourceSpot: null, spotAsOf: null, rateAsOf: [],
61
63
  source: "OVDV midpoint", warnings, error,
@@ -86,15 +88,19 @@ export function projectCalculatorSurfaceVol(
86
88
  snapshot: SurfaceSnapshot,
87
89
  request: Pick<CalculatorSurfaceRequest, "symbol" | "strike" | "daysToExpiry">,
88
90
  ): CalculatorSurfaceVol {
89
- const warnings = [...new Set([...snapshot.warnings, ...snapshot.failures.map((failure) =>
90
- `${failure.expiration == null ? "Surface" : expiryLabel(failure.expiration)}: ${failure.message}`)])];
91
+ // Only the brackets price the option: other loaded expiries' caveats do not apply to it.
92
+ const failures = (expirations: readonly number[]) => snapshot.failures
93
+ .filter((failure) => failure.expiration == null || expirations.includes(failure.expiration))
94
+ .map((failure) => `${failure.expiration == null ? "Surface" : expiryLabel(failure.expiration)}: ${failure.message}`);
91
95
  if (snapshot.symbol !== parsePublicTickerKey(request.symbol).symbol || !positive(snapshot.spot)) {
92
- return empty("Surface snapshot does not match the requested underlying mark", warnings);
96
+ return empty("Surface snapshot does not match the requested underlying mark", failures([]));
93
97
  }
94
98
  const years = request.daysToExpiry / 365;
95
99
  const brackets = bracketingExpiries(snapshot.catalogue, years, snapshot.fetchedAt);
96
- if (!brackets.length) return empty("Requested tenor is outside the listed surface range; extrapolation is unavailable", warnings);
100
+ if (!brackets.length) return empty("Requested tenor is outside the listed surface range; extrapolation is unavailable", failures([]));
97
101
  const selected = brackets.map((expiration) => snapshot.expiries.find((expiry) => expiry.expiration === expiration));
102
+ const slices = selected.filter((expiry): expiry is SurfaceExpiry => expiry != null);
103
+ const warnings = [...new Set([...slices.flatMap((expiry) => expiry.warnings), ...surfaceCalendarWarnings(slices), ...failures(brackets)])];
98
104
  for (let index = 0; index < selected.length; index += 1) {
99
105
  const expiry = selected[index];
100
106
  if (!expiry || expiry.stale || expiry.error || expiry.state !== "ready" || !expiry.fit
@@ -120,7 +126,8 @@ export function projectCalculatorSurfaceVol(
120
126
  const dates = [...new Set(selected.flatMap((expiry) => expiry?.asOf && Number.isFinite(Date.parse(expiry.asOf)) ? [expiry.asOf] : []))];
121
127
  const asOf = dates.length ? dates.toSorted((a, b) => Date.parse(a) - Date.parse(b))[0]! : null;
122
128
  if (selected.some((expiry) => !expiry?.asOf || !Number.isFinite(Date.parse(expiry.asOf)))) warnings.push("Surface quote observation date unavailable");
123
- if (dates.length > 1) warnings.push(`Surface quote dates differ: ${dates.join(", ")}`);
129
+ const sessions = [...new Set(dates.map((date) => quoteSession.format(Date.parse(date))))].sort();
130
+ if (sessions.length > 1) warnings.push(`Surface quote dates differ: ${sessions.join(", ")}`);
124
131
  const rateDates = [...new Set(selected.flatMap((expiry) => expiry!.rateAsOf))];
125
132
  if (!rateDates.length) warnings.push("Treasury observation date unavailable");
126
133
  const fits = selected.map((expiry) => `${expiryLabel(expiry!.expiration)} ${expiry!.fit!.method} (${expiry!.source ?? "source unavailable"})`);
@@ -6,7 +6,7 @@ import type { EarningsEvent } from "../../../types/data-provider";
6
6
  import type { TickerRecord } from "../../../types/ticker";
7
7
  import { priceColor } from "../../../theme/colors";
8
8
  import { formatQuoteAgeWithSource, resolveQuoteAgeTimestamp } from "../../../market-data/quotes/time";
9
- import { convertCurrency, formatCompact, formatNumber, formatPercentRaw } from "../../../utils/format";
9
+ import { convertCurrency, formatCompact, formatCompactAmount, formatNumber, formatPercentRaw } from "../../../utils/format";
10
10
  import {
11
11
  formatMarketCost,
12
12
  quoteFormatOptions,
@@ -14,6 +14,7 @@ import {
14
14
  formatMarketPriceWithCurrency,
15
15
  formatMarketQuantity,
16
16
  formatSignedMarketPrice,
17
+ withStablePriceDigits,
17
18
  type MarketFormatOptions,
18
19
  } from "../../../market-data/market/format";
19
20
  import {
@@ -239,7 +240,14 @@ export function getColumnValue(
239
240
  multiplier: multiplierHint,
240
241
  priceBasis: positionMetrics.priceBasis,
241
242
  };
242
- const currentQuoteOptions = { ...formatOptions, ...quoteFormatOptions(quote, ticker.metadata.assetCategory, financials?.quoteMetadata?.instrumentType) };
243
+ // Streamed prices keep one decimal count per instrument, so a tick landing on
244
+ // a whole dime does not print 150.1 and jump the column's digits.
245
+ const currentQuoteOptions = withStablePriceDigits(
246
+ { ...formatOptions, ...quoteFormatOptions(quote, ticker.metadata.assetCategory, financials?.quoteMetadata?.instrumentType) },
247
+ quoteCurrency,
248
+ quote,
249
+ );
250
+ const markOptions = withStablePriceDigits(formatOptions, positionCurrency || quoteCurrency, { price: brokerMarkPrice });
243
251
 
244
252
  switch (col.id) {
245
253
  case "ticker": {
@@ -272,7 +280,7 @@ export function getColumnValue(
272
280
  case "tags":
273
281
  return { text: ticker.metadata.tags.length > 0 ? ticker.metadata.tags.join(",") : "—" };
274
282
  case "price":
275
- return resolvePortfolioPriceValue(displayQuote, brokerMarkPrice, displayQuote ? currentQuoteOptions : formatOptions, col.width, quote?.marketState);
283
+ return resolvePortfolioPriceValue(displayQuote, brokerMarkPrice, displayQuote ? currentQuoteOptions : markOptions, col.width, quote?.marketState);
276
284
  case "change":
277
285
  if (!displayQuote) return { text: "—" };
278
286
  return {
@@ -304,10 +312,10 @@ export function getColumnValue(
304
312
  ? { text: formatPercentRaw(displayQuote.changePercent), color: marketChangeColor(displayQuote.changePercent, quote?.marketState) }
305
313
  : { text: quote ? formatPercentRaw(quote.changePercent) : "—", color: quote ? marketChangeColor(quote.changePercent, quote.marketState) : undefined };
306
314
  case "volume":
307
- return { text: finiteNumber(quote?.volume) ? formatCompact(quote.volume) : "—" };
315
+ return { text: finiteNumber(quote?.volume) ? formatCompact(quote.volume, { fixedDecimals: true }) : "—" };
308
316
  case "dollar_volume": {
309
317
  if (!displayQuote || !finiteNumber(quote?.volume)) return { text: "—" };
310
- return { text: formatCompact(toBaseQuote(displayQuote.price * quote.volume)) };
318
+ return { text: formatCompact(toBaseQuote(displayQuote.price * quote.volume), { fixedDecimals: true }) };
311
319
  }
312
320
  case "range_52w": {
313
321
  const position = fiftyTwoWeekPosition(displayQuote, quote);
@@ -316,7 +324,7 @@ export function getColumnValue(
316
324
  case "market_cap": {
317
325
  const cap = liveMarketCapitalization(quote, fundamentals);
318
326
  const value = cap ? convertMarketCapitalization(cap.value, cap.currency, ctx.baseCurrency, ctx.exchangeRates) : null;
319
- return { text: value == null ? "—" : formatCompact(value) };
327
+ return { text: value == null ? "—" : formatCompact(value, { fixedDecimals: true }) };
320
328
  }
321
329
  case "pe":
322
330
  return { text: formatPriceEarnings(liveTrailingPE(quote, fundamentals)) };
@@ -349,7 +357,7 @@ export function getColumnValue(
349
357
  if (baseMetrics.positionCount === 0 || !Number.isFinite(baseMetrics.totalCost)) return { text: "—" };
350
358
  return { text: formatCompact(baseMetrics.totalCost) };
351
359
  case "mkt_value":
352
- return { text: formatCompact(resolvePortfolioMarketValue(baseMetrics, activeQuote ? toBaseQuote(activeQuote.price) : null)?.gross ?? Number.NaN) };
360
+ return { text: formatCompactAmount(resolvePortfolioMarketValue(baseMetrics, activeQuote ? toBaseQuote(activeQuote.price) : null)?.gross ?? Number.NaN) };
353
361
  case "weight": {
354
362
  const marketValue = getActiveMarketValue(activeQuote, baseMetrics, toBaseQuote);
355
363
  if (marketValue == null || !ctx.portfolioTotalMarketValue) return { text: "—" };
@@ -358,13 +366,13 @@ export function getColumnValue(
358
366
  case "day_pnl":
359
367
  if (activeQuote && finiteNumber(activeQuote.change) && Number.isFinite(positionMetrics.grossPriceUnits) && positionMetrics.grossPriceUnits !== 0) {
360
368
  const dayPnl = toBaseQuote(totalPriceUnits * activeQuote.change);
361
- return { text: `${dayPnl >= 0 ? "+" : ""}${formatCompact(dayPnl)}`, color: priceColor(dayPnl) };
369
+ return { text: formatCompactAmount(dayPnl, { signed: true }), color: priceColor(dayPnl) };
362
370
  }
363
371
  return { text: "—" };
364
372
  case "pnl": {
365
373
  const pnl = positionPnl.value;
366
374
  return pnl === null ? { text: "—", pnlBasis: positionPnl.basis }
367
- : { text: `${pnl >= 0 ? "+" : ""}${formatCompact(pnl)}`, color: priceColor(pnl), pnlBasis: positionPnl.basis };
375
+ : { text: formatCompactAmount(pnl, { signed: true }), color: priceColor(pnl), pnlBasis: positionPnl.basis };
368
376
  }
369
377
  case "pnl_pct": {
370
378
  const percent = portfolioPnlPercent(positionPnl.value, baseMetrics.totalCost);
@@ -59,7 +59,7 @@ describe("buildPortfolioSummarySegments", () => {
59
59
  const segments = fitSummarySegments(buildPortfolioSummarySegments({
60
60
  totals,
61
61
  accountState: { account, sourceLabel: "Live" },
62
- }), 24);
62
+ }), 26);
63
63
 
64
64
  expect(segments.map((segment) => segment.id)).toEqual(["netliq", "val"]);
65
65
  });
@@ -85,14 +85,14 @@ describe("buildPortfolioSummarySegments", () => {
85
85
  accountState: { account, sourceLabel: "Live" },
86
86
  });
87
87
 
88
- expect(segments.find((segment) => segment.id === "val")?.parts[1]?.text).toBe("175k");
88
+ expect(segments.find((segment) => segment.id === "val")?.parts[1]?.text).toBe("175.0k");
89
89
  });
90
90
 
91
91
  test("drops low-priority broker segments before required ones", () => {
92
92
  const segments = fitSummarySegments(buildPortfolioSummarySegments({
93
93
  totals,
94
94
  accountState: { account, sourceLabel: "Live" },
95
- }), 100);
95
+ }), 112);
96
96
 
97
97
  expect(segments.map((segment) => segment.id)).toEqual([
98
98
  "netliq",
@@ -107,12 +107,12 @@ describe("buildPortfolioSummarySegments", () => {
107
107
 
108
108
  test("the open cash drawer continues where the header row ran out of room", () => {
109
109
  const segments = buildPortfolioSummarySegments({ totals, accountState: { account, sourceLabel: "Live" } });
110
- const layout = layoutPortfolioSummaryHeader(segments, 50, { cashDrawer: true, hideHeader: false });
110
+ const layout = layoutPortfolioSummaryHeader(segments, 56, { cashDrawer: true, hideHeader: false });
111
111
  const ids = (list: typeof segments) => list.map((segment) => segment.id);
112
112
 
113
113
  expect(ids(layout.row)).toEqual(["netliq", "val", "cash"]);
114
114
  expect(ids(layout.detail)).toEqual(["day", "pnl", "settled"]);
115
- expect(ids(layoutPortfolioSummaryHeader(segments, 50, { cashDrawer: true, hideHeader: true }).detail))
115
+ expect(ids(layoutPortfolioSummaryHeader(segments, 56, { cashDrawer: true, hideHeader: true }).detail))
116
116
  .toEqual(["netliq", "val", "cash"]);
117
117
  });
118
118
 
@@ -143,11 +143,11 @@ describe("portfolio-metrics", () => {
143
143
  const mutedPositive = blendHex(colors.positive, colors.textDim, 0.55);
144
144
 
145
145
  expect(getColumnValue(priceColumn, ticker, financials, defaultColumnContext)).toEqual({
146
- text: "120",
146
+ text: "120.00",
147
147
  color: colors.textDim,
148
148
  });
149
149
  expect(getColumnValue(changeColumn, ticker, financials, defaultColumnContext)).toEqual({
150
- text: "+5",
150
+ text: "+5.00",
151
151
  color: mutedPositive,
152
152
  });
153
153
  expect(getColumnValue(changePctColumn, ticker, financials, defaultColumnContext)).toEqual({
@@ -337,13 +337,13 @@ describe("portfolio-metrics", () => {
337
337
  const latencyColumn: ColumnConfig = { id: "latency", label: "AGE", width: 6, align: "right" };
338
338
 
339
339
  expect(getColumnValue(dayPnlColumn, ticker, financials, defaultColumnContext)).toEqual({
340
- text: "+50",
340
+ text: "+50.00",
341
341
  color: expect.any(String),
342
342
  });
343
343
  expect(getSortValue(dayPnlColumn, ticker, financials, defaultColumnContext)).toBe(50);
344
344
  expect(getColumnValue(pnlColumn, ticker, financials, defaultColumnContext)).toEqual({
345
345
  pnlBasis: "quote-and-cost",
346
- text: "+200",
346
+ text: "+200.00",
347
347
  color: expect.any(String),
348
348
  });
349
349
  expect(getSortValue(pnlColumn, ticker, financials, defaultColumnContext)).toBe(200);
@@ -366,7 +366,7 @@ describe("portfolio-metrics", () => {
366
366
  expect(getSortValue(pnlColumn, ticker, financials, defaultColumnContext)).toBe(200);
367
367
  expect(getColumnValue(pnlColumn, ticker, financials, defaultColumnContext)).toEqual({
368
368
  pnlBasis: "quote-and-cost",
369
- text: "+200",
369
+ text: "+200.00",
370
370
  color: expect.any(String),
371
371
  });
372
372
  expect(calculatePortfolioSummaryTotals(
@@ -425,7 +425,7 @@ describe("portfolio-metrics", () => {
425
425
  expect(getColumnValue({ id: "weight", label: "WEIGHT", width: 8, align: "right" }, ticker, financials, context).text).toBe("+50.00%");
426
426
  expect(getSortValue({ id: "weight", label: "WEIGHT", width: 8, align: "right" }, ticker, financials, context)).toBe(50);
427
427
  expect(getColumnValue({ id: "range_52w", label: "52W%", width: 7, align: "right" }, ticker, financials, context).text).toBe("+50.00%");
428
- expect(getColumnValue({ id: "dollar_volume", label: "$VOL", width: 9, align: "right" }, ticker, financials, context).text).toBe("1.5B");
428
+ expect(getColumnValue({ id: "dollar_volume", label: "$VOL", width: 9, align: "right" }, ticker, financials, context).text).toBe("1.50B");
429
429
  expect(getColumnValue({ id: "spread_pct", label: "SPR%", width: 7, align: "right" }, ticker, financials, context).text).toBe("+0.83%");
430
430
  expect(getColumnValue({ id: "bid_ask_size", label: "B/A SZ", width: 9, align: "right" }, ticker, financials, context).text).toBe("100/150");
431
431
  expect(getColumnValue({ id: "mark_delta", label: "MARK%", width: 8, align: "right" }, ticker, financials, context).text).toBe("+1.00%");
@@ -492,8 +492,8 @@ describe("position aggregation across sides, currencies and broker coverage", ()
492
492
  expect(getSortValue(column("pnl"), ticker, financials, defaultColumnContext)).toBe(1000);
493
493
  expect(getSortValue(column("pnl_pct"), ticker, financials, defaultColumnContext)).toBeCloseTo(1000 / 21000 * 100);
494
494
  expect(getColumnValue(column("shares"), ticker, financials, defaultColumnContext).text).toBe("0");
495
- expect(getColumnValue(column("pnl"), ticker, financials, defaultColumnContext).text).toBe("+1k");
496
- expect(buildPortfolioSummarySegments({ totals, accountState: null }).map(segment => segment.parts.map(part => part.text).join(" ")).join(" ")).toContain("Gross 24k Net 0");
495
+ expect(getColumnValue(column("pnl"), ticker, financials, defaultColumnContext).text).toBe("+1.0k");
496
+ expect(buildPortfolioSummarySegments({ totals, accountState: null }).map(segment => segment.parts.map(part => part.text).join(" ")).join(" ")).toContain("Gross 24.0k Net 0.00");
497
497
  });
498
498
 
499
499
  test("converts each cost basis before summing and withholds averages across native currencies", () => {
@@ -524,7 +524,7 @@ describe("PortfolioListPane cash and margin UI", () => {
524
524
  });
525
525
  for (let index = 0; index < 6; index++) await flushFrame();
526
526
  const frame = testSetup!.captureCharFrame();
527
- expect(frame).toContain(source === "unknown currency" ? "Net Liq —" : "Net Liq 15k");
527
+ expect(frame).toContain(source === "unknown currency" ? "Net Liq —" : "Net Liq 15.0k");
528
528
  expect(frame).toContain(source === "CAD" ? "Cash 13.5k" : "Cash —");
529
529
  expect(frame).not.toContain("Cash 0");
530
530
  if (source !== "unknown currency") expect(requested).toContain("CAD");
@@ -815,7 +815,7 @@ describe("PortfolioListPane cash and margin UI", () => {
815
815
  await flushFrame();
816
816
 
817
817
  const frame = testSetup.captureCharFrame();
818
- expect(frame).toMatch(/AAPL\s+125\s+\+4\.17%/);
818
+ expect(frame).toMatch(/AAPL\s+125\.00\s+\+4\.17%/);
819
819
  expect(frame).toContain("100");
820
820
  expect(frame).toContain("1.4k");
821
821
  expect(frame).toContain("+275");
@@ -838,7 +838,7 @@ describe("PortfolioListPane cash and margin UI", () => {
838
838
  await flushFrame();
839
839
  const before = testSetup.captureCharFrame();
840
840
  expect(before).toContain("⚠");
841
- expect(before).toMatch(/AAPL\s+—\s+1\.2k\s+\+200\s+—/);
841
+ expect(before).toMatch(/AAPL\s+—\s+1\.2k\s+\+200\.00\s+—/);
842
842
  expect(before).not.toContain("NaN");
843
843
  const corrected = { ...imported, metadata: { ...imported.metadata,
844
844
  positions: [{ ...imported.metadata.positions[0]!, avgCost: 100 }],
@@ -847,7 +847,7 @@ describe("PortfolioListPane cash and margin UI", () => {
847
847
  await flushFrame();
848
848
  const after = testSetup.captureCharFrame();
849
849
  expect(after).not.toContain("⚠");
850
- expect(after).toMatch(/AAPL\s+100\s+1\.2k\s+\+200\s+\+20\.00%/);
850
+ expect(after).toMatch(/AAPL\s+100\s+1\.2k\s+\+200\.00\s+\+20\.00%/);
851
851
  });
852
852
  }
853
853
 
@@ -1055,8 +1055,8 @@ describe("PortfolioListPane cash and margin UI", () => {
1055
1055
  expect(frame).toContain("4.25");
1056
1056
  expect(frame).toContain("5");
1057
1057
  expect(frame).toContain("850");
1058
- expect(frame).toContain("1k");
1059
- expect(frame).toContain("+150");
1058
+ expect(frame).toContain("1.0k");
1059
+ expect(frame).toContain("+150.00");
1060
1060
  expect(frame).not.toContain("$4.25");
1061
1061
  });
1062
1062
 
@@ -1093,8 +1093,8 @@ describe("PortfolioListPane cash and margin UI", () => {
1093
1093
 
1094
1094
  const frame = testSetup.captureCharFrame();
1095
1095
  expect(frame).toContain("Cash & Margin");
1096
- expect(frame.match(/Cash -50k/g)).toHaveLength(1);
1097
- expect(frame.match(/Net Liq 125k/g)).toHaveLength(1);
1096
+ expect(frame.match(/Cash -50.0k/g)).toHaveLength(1);
1097
+ expect(frame.match(/Net Liq 125.0k/g)).toHaveLength(1);
1098
1098
  expect(frame).toContain("Flex Mar 27");
1099
1099
  expect(frame).toContain("-351,957.025");
1100
1100
  expect(frame).not.toContain("Avail");
@@ -1142,7 +1142,7 @@ describe("PortfolioListPane cash and margin UI", () => {
1142
1142
  { width: 60, height: 8 },
1143
1143
  );
1144
1144
  await flushFrame();
1145
- expect(testSetup.captureCharFrame()).toMatch(/AAPL\s+125\s+2s/);
1145
+ expect(testSetup.captureCharFrame()).toMatch(/AAPL\s+125\.00\s+2s/);
1146
1146
 
1147
1147
  // A quote received since the clock last ticked reads its true age on the next tick.
1148
1148
  clock += 5_000;
@@ -1150,7 +1150,7 @@ describe("PortfolioListPane cash and margin UI", () => {
1150
1150
  await Bun.sleep(AGE_CLOCK_MS + 50);
1151
1151
  });
1152
1152
  await flushFrame();
1153
- expect(testSetup.captureCharFrame()).toMatch(/AAPL\s+125\s+7s/);
1153
+ expect(testSetup.captureCharFrame()).toMatch(/AAPL\s+125\.00\s+7s/);
1154
1154
  });
1155
1155
 
1156
1156
  test("warms full financials for visible rows when only quote data is loaded", async () => {
@@ -1252,7 +1252,7 @@ describe("PortfolioListPane cash and margin UI", () => {
1252
1252
 
1253
1253
  const frame = testSetup.captureCharFrame();
1254
1254
  expect(calls).toBeGreaterThan(0);
1255
- expect(frame).toContain("2B");
1255
+ expect(frame).toContain("2.00B");
1256
1256
  expect(frame).toContain("25.0");
1257
1257
  expect(frame).toContain("22.0");
1258
1258
  });
@@ -1511,7 +1511,7 @@ describe("PortfolioListPane cash and margin UI", () => {
1511
1511
 
1512
1512
  const frame = testSetup.captureCharFrame();
1513
1513
  expect(liveCalls).toBe(0);
1514
- expect(frame).toContain("2B");
1514
+ expect(frame).toContain("2.00B");
1515
1515
  expect(frame).toContain("25.0");
1516
1516
  expect(frame).toContain("22.0");
1517
1517
 
@@ -54,7 +54,7 @@ test("a current mark cannot refresh broker profit without cost, and manual corre
54
54
  const record = ticker(lot({ unrealizedPnl: 200, marketValue: 1150, markPrice: 115 }));
55
55
  expect(totals(record)).toMatchObject({ totalMktValue: 1200, totalCostBasis: Number.NaN, unrealizedPnl: 200, unrealizedPnlPct: Number.NaN,
56
56
  unrealizedPnlBasis: "broker-snapshot", unavailableCostSymbols: ["AAPL"], brokerPnlSymbols: ["AAPL"] });
57
- expect(getColumnValue(column("pnl"), record, financials, context)).toMatchObject({ text: "+200", pnlBasis: "broker-snapshot" });
57
+ expect(getColumnValue(column("pnl"), record, financials, context)).toMatchObject({ text: "+200.00", pnlBasis: "broker-snapshot" });
58
58
  expect(getSortValue(column("pnl"), record, financials, context)).toBe(200);
59
59
  const summary = buildPortfolioSummarySegments({ totals: totals(record), accountState: null });
60
60
  expect(summary.flatMap(segment => segment.parts.map(part => part.text)).join(" ")).toContain("Broker P&L");
@@ -71,7 +71,7 @@ test("mixed lots select each independently, preserve offsetting exposure, and wi
71
71
  const record = ticker(lot({ avgCost: 100 }), lot({ shares: -10, unrealizedPnl: 50 }));
72
72
  expect(totals(record)).toMatchObject({ netMktValue: 0, totalMktValue: 2400, unrealizedPnl: 250,
73
73
  unrealizedPnlBasis: "mixed", totalCostBasis: Number.NaN, unrealizedPnlPct: Number.NaN });
74
- expect(getColumnValue(column("pnl"), record, financials, context)).toMatchObject({ text: "+250", pnlBasis: "mixed" });
74
+ expect(getColumnValue(column("pnl"), record, financials, context)).toMatchObject({ text: "+250.00", pnlBasis: "mixed" });
75
75
  expect(getSortValue(column("pnl"), record, financials, context)).toBe(250);
76
76
  record.metadata.positions.push(lot({ shares: 1 }));
77
77
  expect(totals(record).unrealizedPnl).toBeNaN();
@@ -7,7 +7,7 @@ import type { BrokerConnectionStatus } from "../../../../types/broker";
7
7
  import type { TickerFinancials } from "../../../../types/financials";
8
8
  import type { Portfolio, TickerRecord } from "../../../../types/ticker";
9
9
  import type { BrokerAccount, BrokerCashBalance } from "../../../../types/trading";
10
- import { displayWidth, formatCompact, formatPercentRaw } from "../../../../utils/format";
10
+ import { displayWidth, formatCompactAmount, formatPercentRaw } from "../../../../utils/format";
11
11
  import { getBrokerInstance } from "../../../../utils/broker-instances";
12
12
  import {
13
13
  resolvePortfolioAccountMetrics,
@@ -59,8 +59,9 @@ function createSummarySegment(
59
59
  };
60
60
  }
61
61
 
62
+ // Totals move with every streamed price; fixed decimals keep the segments after them still.
62
63
  function formatSignedCompact(value: number): string {
63
- return `${value >= 0 ? "+" : ""}${formatCompact(value)}`;
64
+ return formatCompactAmount(value, { signed: true });
64
65
  }
65
66
 
66
67
  function formatMonthDay(date: Date): string {
@@ -240,26 +241,26 @@ export function buildPortfolioSummarySegments({
240
241
  const accountValue = (id: string, label: string, value: number | undefined) => value != null
241
242
  ? createSummarySegment(id, [
242
243
  { text: label, tone: "label" },
243
- { text: formatCompact(convertAccountValue(value)), tone: "value", bold: true },
244
+ { text: formatCompactAmount(convertAccountValue(value)), tone: "value", bold: true },
244
245
  ])
245
246
  : null;
246
247
 
247
248
  if (netLiquidation != null) {
248
249
  candidates.push(createSummarySegment("netliq", [
249
250
  { text: "Net Liq", tone: "label" },
250
- { text: formatCompact(netLiquidation), tone: "value", bold: true },
251
+ { text: formatCompactAmount(netLiquidation), tone: "value", bold: true },
251
252
  ]));
252
253
  }
253
254
 
254
255
  candidates.push(createSummarySegment("val", [
255
256
  { text: totals.hasShorts ? "Gross" : "Val", tone: "label" },
256
- { text: formatCompact(totalMarketValue), tone: "value", bold: true },
257
+ { text: formatCompactAmount(totalMarketValue), tone: "value", bold: true },
257
258
  ]));
258
259
 
259
260
  if (totals.hasShorts && totals.netMktValue != null) {
260
261
  candidates.push(createSummarySegment("net-value", [
261
262
  { text: "Net", tone: "label" },
262
- { text: formatCompact(totals.netMktValue), tone: "value", bold: true },
263
+ { text: formatCompactAmount(totals.netMktValue), tone: "value", bold: true },
263
264
  ]));
264
265
  }
265
266
 
@@ -47,7 +47,8 @@ export async function fetchRatePath(client: Pick<typeof apiClient, "getCloudRate
47
47
 
48
48
  export function getCachedRatePath(): RatePathPayload | null {
49
49
  const cached = ratePathCache.get("usd", { allowExpired: true });
50
- return cached ? { ...cached.data, stale: cached.data.stale || cached.stale } : null;
50
+ // The pane revalidates this copy on mount; only the source or a failed refresh makes it stale.
51
+ return cached?.data ?? null;
51
52
  }
52
53
 
53
54
  export async function loadRatePath(force = false): Promise<RatePathPayload> {
@@ -29,7 +29,7 @@ const contractColumns: HeadlessPaneColumn[] = [
29
29
 
30
30
  export const ratePathHeadless: HeadlessPaneDefinition<"bundle"> = {
31
31
  discovery: { aliases: ["WIRP", "FFIP"], dataRequirements: ["Gloom Cloud rate-path endpoint"],
32
- limitations: ["Conditional two-outcome probabilities", "Sparse SOFR history", "Maintained FOMC schedule and SEP snapshot"] },
32
+ limitations: ["Conditional two-outcome probabilities", "SR3 history from hourly trades", "Maintained FOMC schedule and SEP snapshot"] },
33
33
  shape: "bundle", argument: { kind: "none" }, options: [], describe: "US rate path",
34
34
  async load(_args, ctx) {
35
35
  const data = await fetchRatePath(ctx.apiClient);
@@ -107,7 +107,6 @@ export function RatePathPane({ width, height, focused }: PaneProps) {
107
107
  info: data ? [
108
108
  { id: "as-of", parts: [{ text: `as of ${timestamp(data.asOf)} UTC`, tone: "muted" }] },
109
109
  ...(data.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "warning" as const }] }] : []),
110
- ...(data.status !== "available" ? [{ id: "partial", parts: [{ text: data.status, tone: "warning" as const }] }] : []),
111
110
  ] : [],
112
111
  });
113
112
  const selection = { kind: "id" as const, selectedId: selected, getId: (row: RateMeeting) => row.date, onChange: setSelected };
@@ -173,6 +173,13 @@ describe("independent current-IV loading", () => {
173
173
  expect(calls).toBe(1);
174
174
  });
175
175
 
176
+ test("an underlying with no option chain has no IV line and no error", async () => {
177
+ const empty: OptionsChain = { underlyingSymbol: "7203.T", expirationDates: [], calls: [], puts: [] };
178
+ const result = await loadCurrentAtmIv({ instrument: { symbol: "7203.T" }, spot: 100 }, { now: () => now, loadYieldCurve: async () => [],
179
+ loadOptions: async () => ({ ...ready(empty), error: { reasonCode: "NO_DATA", message: "No data available" } }) });
180
+ expect(result).toEqual({ reference: null, error: null, warnings: [], noOptionChain: true });
181
+ });
182
+
176
183
  test("surface cancellation remains cancellation rather than a missing-IV result", async () => {
177
184
  const gate = deferred<QueryEntry<OptionsChain>>();
178
185
  const controller = new AbortController();
@@ -63,7 +63,7 @@ export function realizedVolHeadless(initialView: "graph" | "cone"): HeadlessPane
63
63
  ] },
64
64
  ],
65
65
  complete: model.cone.every((row) => row.current != null) && model.warnings.length === 0 && !history.stale && errors.length === 0
66
- && (!args.options.showIv || iv?.reference != null),
66
+ && (!args.options.showIv || iv?.reference != null || iv?.noOptionChain === true),
67
67
  unavailableSymbols: model.history.length ? [] : [instrument.symbol], errors,
68
68
  metadata: { unit: "decimal annualized volatility", annualization: 252, model, currentIv: iv,
69
69
  source: history.source, stale: history.stale, fetchedAt: history.fetchedAt,
@@ -111,10 +111,12 @@ describe("realized-volatility projection", () => {
111
111
  });
112
112
 
113
113
  describe("current ATM IV reference", () => {
114
- test("selects the listed tenor nearest 30 days and preserves source date and independent failures", () => {
115
- const selected = expiry(31, { atmIV: 0.27 });
116
- const result = projectCurrentAtmIv(surface([expiry(7), selected, expiry(60)], {
114
+ test("selects the listed tenor nearest 30 days and keeps only its own and surface-wide caveats", () => {
115
+ const selected = expiry(31, { atmIV: 0.27, warnings: ["9 butterfly arbitrage warnings"] });
116
+ const leaps = expiry(800, { warnings: ["SVI did not converge"] });
117
+ const result = projectCurrentAtmIv(surface([expiry(7), selected, expiry(60), leaps], {
117
118
  phase: "partial", failures: [{ expiration: 100, message: "unrelated expiry offline" }],
119
+ warnings: ["SVI did not converge", "9 butterfly arbitrage warnings", "2 calendar arbitrage warnings"],
118
120
  }));
119
121
  expect(result.reference!.value).toBe(0.27);
120
122
  expect(result.reference!.expiration).toBe(selected.expiration);
@@ -122,7 +124,17 @@ describe("current ATM IV reference", () => {
122
124
  expect(result.reference!.date.toISOString()).toBe(new Date(selected.asOf!).toISOString());
123
125
  expect(result.reference!.source).toBe("test");
124
126
  expect(result.reference!.ivSource).toBe("recomputed");
125
- expect(result.error).toBe("unrelated expiry offline");
127
+ expect(result.error).toBeNull();
128
+ expect(result.warnings).toEqual(["9 butterfly arbitrage warnings"]);
129
+ const offline = projectCurrentAtmIv(surface([selected], { failures: [{ expiration: null, message: "Treasury: offline" }] }));
130
+ expect(offline.error).toBe("Treasury: offline");
131
+ });
132
+
133
+ test("says when nearer expiries without usable quotes pushed the reference to a farther tenor", () => {
134
+ const result = projectCurrentAtmIv(surface([expiry(1), expiry(29, { atmIV: null, state: "empty" }), expiry(60, { atmIV: null, state: "error" })]));
135
+ expect(result.reference!.daysToExpiry).toBeCloseTo(1, 12);
136
+ expect(result.warnings).toContain("Expiries nearer 30 days have no usable ATM quotes; reference uses the 1d expiry");
137
+ expect(projectCurrentAtmIv(surface([expiry(29), expiry(60, { atmIV: null, state: "empty" })])).warnings).toEqual([]);
126
138
  });
127
139
 
128
140
  test("withholds stale, failed and undated observations and chooses the next eligible expiry", () => {