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
@@ -6,6 +6,7 @@ import { TestDialogProvider, testRender } from "../../../../renderers/opentui/te
6
6
  import { appReducer, createInitialState } from "../../../../state/app/context";
7
7
  import { createTestPaneConfig, createTestTicker, TestPaneProvider } from "../../../../test-support/pane";
8
8
  import { createTestPluginRuntime } from "../../../../test-support/plugin-runtime";
9
+ import type { PluginPersistence } from "../../../../types/plugin";
9
10
  import { Box, Text } from "../../../../ui";
10
11
  import { ThesisBoardPane } from "./board-pane";
11
12
  import { thesisStore } from "./store";
@@ -193,6 +194,30 @@ describe("ThesisBoardPane", () => {
193
194
  expect(frame).toContain("Weights");
194
195
  });
195
196
 
197
+ test("the on-device copy is only called offline once the refresh fails", async () => {
198
+ let failList: (error: Error) => void = () => {};
199
+ setCloudApiFetchTransport(async (url, init) => {
200
+ const parsed = new URL(url);
201
+ if (parsed.pathname === "/theses") return new Promise<Response>((_, reject) => { failList = reject; });
202
+ const { status, body } = respond(parsed.pathname, init?.method ?? "GET");
203
+ return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
204
+ });
205
+ const saved = new Map<string, unknown>([["thesis-cache", [nvda, asml]]]);
206
+ thesisStore.attach({ getState: (key: string) => saved.get(key) ?? null, setState: (key: string, value: unknown) => { saved.set(key, value); } } as unknown as PluginPersistence);
207
+ thesisStore.start();
208
+ await act(async () => {
209
+ setup = await testRender(<Harness />, { width: 110, height: 30 });
210
+ });
211
+ await flush(null);
212
+ const loading = setup!.captureCharFrame();
213
+ expect(loading).toContain("ASML");
214
+ expect(loading).toContain("footer: loading");
215
+ expect(loading).not.toContain("offline copy");
216
+ await act(async () => { failList(new Error("network down")); });
217
+ await flush(null);
218
+ expect(setup!.captureCharFrame()).toContain("offline copy");
219
+ });
220
+
196
221
  test("enter opens the thesis: pillars, kill conditions, catalysts, and the open signal with its source", async () => {
197
222
  thesisStore.start();
198
223
  await act(async () => {
@@ -17,7 +17,7 @@ import { colors } from "../../../../theme/colors";
17
17
  import type { PaneProps } from "../../../../types/plugin";
18
18
  import { Box, TextAttributes, type InputRenderable } from "../../../../ui";
19
19
  import { useDialog } from "../../../../ui/dialog";
20
- import { formatCompactCurrency } from "../../../../utils/format";
20
+ import { formatCompactAmount } from "../../../../utils/format";
21
21
  import { isPlainKey } from "../../../../utils/keyboard";
22
22
  import { stopSearchFocusNavigation } from "../../../../utils/search-focus-navigation";
23
23
  import { usePluginAppActions } from "../../../runtime";
@@ -344,7 +344,8 @@ export function ThesisBoardPane({ focused, width, height }: PaneProps) {
344
344
  case "weight":
345
345
  return { text: `${pct(row.weight)}${row.hasOptions ? "*" : ""}`, color: selectedColor ?? colors.text };
346
346
  case "value":
347
- return { text: formatCompactCurrency(row.value, exposure.baseCurrency), color: selectedColor ?? colors.textDim };
347
+ // A thesis can be worth under 1000, where formatCompactCurrency would trim the cents as prices move.
348
+ return { text: `${formatCompactAmount(row.value)} ${exposure.baseCurrency}`, color: selectedColor ?? colors.textDim };
348
349
  case "gap": {
349
350
  const text = row.gap > 0 ? "under-sized" : row.gap < 0 ? "grew big" : "in line";
350
351
  const color = row.gap > 0 ? colors.warning : row.gap < 0 ? colors.negative : colors.textDim;
@@ -8,7 +8,7 @@ interface ThesisStoreSnapshot {
8
8
  loaded: boolean;
9
9
  loading: boolean;
10
10
  error: string | null;
11
- /** Set while the list is served from the on-device copy. */
11
+ /** Set when the last refresh failed and the list shown is the on-device copy. */
12
12
  offline: boolean;
13
13
  }
14
14
 
@@ -89,7 +89,7 @@ class ThesisStore {
89
89
  this.persistence = persistence;
90
90
  const cached = persistence.getState<CloudThesis[]>(CACHE_STATE_KEY);
91
91
  if (Array.isArray(cached) && cached.length > 0) {
92
- this.update({ theses: cached, offline: true });
92
+ this.update({ theses: cached });
93
93
  }
94
94
  }
95
95
 
@@ -80,7 +80,7 @@ export function TradeDetail({
80
80
  {/* The detail title already carries the member and ticker. */}
81
81
  <DetailLine label="chamber" value={`${trade.chamber === "senate" ? "Senate" : "House"}${trade.stateDistrict ? ` · ${trade.stateDistrict}` : ""}`} tone="muted" />
82
82
  <DetailLine label="party" value={trade.party ?? "--"} />
83
- <DetailLine label="side" value={trade.transactionType} tone={trade.side === "BUY" ? "positive" : trade.side === "SELL" ? "negative" : "warning"} />
83
+ <DetailLine label="side" value={trade.transactionType} tone={trade.side === "BUY" ? "positive" : trade.side === "SELL" ? "negative" : trade.side === "OTHER" ? "muted" : undefined} />
84
84
  <DetailLine label="asset" value={truncate(trade.assetName, Math.max(10, lineWidth - 16))} />
85
85
  <DetailLine label="amount" value={trade.amount} tone="value" />
86
86
  <DetailLine label="owner" value={trade.owner} />
@@ -21,7 +21,7 @@ export function sideColor(side: CloudCongressTradePayload["side"], selected: boo
21
21
  if (selected) return colors.selectedText;
22
22
  if (side === "BUY") return colors.positive;
23
23
  if (side === "SELL") return colors.negative;
24
- if (side === "EXCHANGE") return colors.warning;
24
+ if (side === "EXCHANGE") return colors.text;
25
25
  return colors.textDim;
26
26
  }
27
27
 
@@ -84,7 +84,8 @@ export interface CotDetailData { payload: CotContractPayload; price: PricePoint[
84
84
  export async function loadCotDetail(contractCode: string, family: CotFamily, client: Pick<typeof apiClient, "getCloudCotContract" | "getCloudHistory"> = apiClient): Promise<CotDetailData> {
85
85
  const payload = await fetchCotContract(contractCode, family, client);
86
86
  const mapping = cotPriceMapping(contractCode);
87
- if (!mapping?.priceSymbol) return { payload, price: [], priceSymbol: null, priceAsOf: null, priceWarning: "No verified front-price mapping for this CFTC market. Positioning remains available." };
87
+ // An unmapped market simply has no price panel; that is not a data failure.
88
+ if (!mapping?.priceSymbol) return { payload, price: [], priceSymbol: null, priceAsOf: null, priceWarning: null };
88
89
  try {
89
90
  const result = await client.getCloudHistory(mapping.priceSymbol, mapping.exchange, { interval: "1day", rangeKey: "5Y", outputsize: 1500 });
90
91
  if (!Array.isArray(result.data)) throw new Error("Front-price history unavailable");
@@ -56,6 +56,15 @@ test("price-source failure leaves report history readable and never substitutes
56
56
  const resultVx = await loadCotDetail("1170E1", "legacy", { getCloudCotContract: async () => vx, getCloudHistory: async () => { priceRequested = true; throw new Error("must not substitute cash VIX"); } });
57
57
  expect(priceRequested).toBe(false);
58
58
  expect(resultVx.priceSymbol).toBeNull();
59
+ expect(resultVx.priceWarning).toBeNull();
60
+ const corn = payload(); corn.contract = { ...corn.contract!, contractCode: "002602", marketName: "CORN" };
61
+ const requested: string[] = [];
62
+ const resultCorn = await loadCotDetail("002602", "legacy", { getCloudCotContract: async () => corn,
63
+ getCloudHistory: async (symbol, exchange) => { requested.push(`${symbol} ${exchange}`); return { status: "success", data: [] } as never; } });
64
+ expect(requested).toEqual(["ZC=F CBT"]);
65
+ expect(resultCorn.priceSymbol).toBe("ZC=F");
66
+ // A market code is never an equity alias: Soybean Meal's ZM stays Zoom's.
67
+ expect(cotContractCode("ZM")).toBeNull();
59
68
  });
60
69
 
61
70
  test("missing migration gives unavailable state; access failures remain access failures", async () => {
@@ -19,6 +19,35 @@ const ROOTS: Record<string, { code: string; exchange: string; priceSymbol: strin
19
19
  SR3: { code: "134741", exchange: "CME", priceSymbol: null },
20
20
  ES: { code: "13874A", exchange: "CME", priceSymbol: "ES=F" },
21
21
  };
22
+ /**
23
+ * Front-month price overlays for major markets without a root alias, each
24
+ * checked to return five years of daily history (2026-09-24). Keyed by CFTC
25
+ * code, never added to ROOTS: an alias would also claim the equity ticker under
26
+ * the cursor (ZM, ZS, PL). No cash index stands in, so the dollar index has none.
27
+ */
28
+ const PRICE_OVERLAYS: Record<string, { exchange: string; priceSymbol: string }> = {
29
+ "209742": { exchange: "CME", priceSymbol: "NQ=F" }, "239742": { exchange: "CME", priceSymbol: "RTY=F" },
30
+ "124603": { exchange: "CBT", priceSymbol: "YM=F" },
31
+ "042601": { exchange: "CBT", priceSymbol: "ZT=F" }, "044601": { exchange: "CBT", priceSymbol: "ZF=F" },
32
+ "043607": { exchange: "CBT", priceSymbol: "TN=F" }, "020601": { exchange: "CBT", priceSymbol: "ZB=F" },
33
+ "020604": { exchange: "CBT", priceSymbol: "UB=F" },
34
+ "099741": { exchange: "CME", priceSymbol: "6E=F" }, "097741": { exchange: "CME", priceSymbol: "6J=F" },
35
+ "096742": { exchange: "CME", priceSymbol: "6B=F" }, "090741": { exchange: "CME", priceSymbol: "6C=F" },
36
+ "232741": { exchange: "CME", priceSymbol: "6A=F" }, "092741": { exchange: "CME", priceSymbol: "6S=F" },
37
+ "095741": { exchange: "CME", priceSymbol: "6M=F" },
38
+ "133741": { exchange: "CME", priceSymbol: "BTC=F" }, "146021": { exchange: "CME", priceSymbol: "ETH=F" },
39
+ "023651": { exchange: "NYM", priceSymbol: "NG=F" }, "111659": { exchange: "NYM", priceSymbol: "RB=F" },
40
+ "022651": { exchange: "NYM", priceSymbol: "HO=F" }, "06765T": { exchange: "NYM", priceSymbol: "BZ=F" },
41
+ "085692": { exchange: "CMX", priceSymbol: "HG=F" }, "076651": { exchange: "NYM", priceSymbol: "PL=F" },
42
+ "075651": { exchange: "NYM", priceSymbol: "PA=F" },
43
+ "002602": { exchange: "CBT", priceSymbol: "ZC=F" }, "005602": { exchange: "CBT", priceSymbol: "ZS=F" },
44
+ "001602": { exchange: "CBT", priceSymbol: "ZW=F" }, "001612": { exchange: "CBT", priceSymbol: "KE=F" },
45
+ "007601": { exchange: "CBT", priceSymbol: "ZL=F" }, "026603": { exchange: "CBT", priceSymbol: "ZM=F" },
46
+ "083731": { exchange: "NYB", priceSymbol: "KC=F" }, "080732": { exchange: "NYB", priceSymbol: "SB=F" },
47
+ "033661": { exchange: "NYB", priceSymbol: "CT=F" }, "073732": { exchange: "NYB", priceSymbol: "CC=F" },
48
+ "057642": { exchange: "CME", priceSymbol: "LE=F" }, "054642": { exchange: "CME", priceSymbol: "HE=F" },
49
+ "061641": { exchange: "CME", priceSymbol: "GF=F" },
50
+ };
22
51
  /**
23
52
  * The markets most readers mean by "positioning", verified against the live
24
53
  * CFTC boards on 2026-09-22. A code missing from a report family simply does
@@ -55,7 +84,7 @@ export function cotContractCode(value: unknown): string | null {
55
84
  export function cotRoot(code: string): string | null {
56
85
  return Object.entries(ROOTS).find(([, row]) => row.code === code)?.[0] ?? null;
57
86
  }
58
- export function cotPriceMapping(code: string) { return Object.values(ROOTS).find((row) => row.code === code) ?? null; }
87
+ export function cotPriceMapping(code: string) { return Object.values(ROOTS).find((row) => row.code === code) ?? PRICE_OVERLAYS[code] ?? null; }
59
88
  export function cotClass(family: CotFamily, value: unknown): CotClass {
60
89
  return COT_CLASSES[family].find((row) => row.value === value)?.value ?? COT_CLASSES[family][0]!.value;
61
90
  }
@@ -97,8 +97,7 @@ function CotBoard({ width, height, focused, family, initialCode }: PaneProps & {
97
97
  ] : [] }), [boardShown]);
98
98
  usePaneNoticeFooter({ registrationId: "cot:board-notices", focused, enabled: !open, notices: data?.gaps ?? [] });
99
99
  usePaneStatusFooter({ registrationId: "cot:board", enabled: !open, loading: resource.loading, error: resource.error,
100
- info: data ? [{ id: "as-of", parts: [{ text: `CFTC futures only · ${data.asOf ?? "--"} · contracts`, tone: "muted" }] },
101
- ...(data.status !== "available" ? [{ id: "partial", parts: [{ text: data.status, tone: "warning" as const }] }] : [])] : [] });
100
+ info: data ? [{ id: "as-of", parts: [{ text: `CFTC futures only · ${data.asOf ?? "--"} · contracts`, tone: "muted" }] }] : [] });
102
101
  if (!data && !open) return <PaneStatusBody loading={resource.loading} error={resource.error} empty={!resource.loading && !resource.error} subject="COT history" />;
103
102
  return <DataTableStackView<CotBoardRow> columns={BOARD_COLUMNS} items={rows} focused={focused && !searching}
104
103
  rootWidth={width} rootHeight={height} selection={{ kind: "id", selectedId: selected, getId: (row) => row.contractCode, onChange: setSelected }}
@@ -150,8 +149,7 @@ function CotDetail({ width, height, focused, code, family, traderClass, onClassC
150
149
  });
151
150
  usePaneNoticeFooter({ registrationId: "cot:detail-notices", focused, notices: [...(payload?.gaps ?? []), ...(data?.priceWarning ? [data.priceWarning] : []), ...partialRanks] });
152
151
  usePaneStatusFooter({ registrationId: "cot:detail", loading: resource.loading, error: resource.error,
153
- info: payload ? [{ id: "as-of", parts: [{ text: `CFTC ${payload.asOf ?? "--"} · contracts${data?.priceAsOf ? ` · ${data.priceSymbol} ${data.priceAsOf}` : ""}`, tone: "muted" }] },
154
- ...(payload.status !== "available" ? [{ id: "partial", parts: [{ text: payload.status, tone: "warning" as const }] }] : [])] : [] });
152
+ info: payload ? [{ id: "as-of", parts: [{ text: `CFTC ${payload.asOf ?? "--"} · contracts${data?.priceAsOf ? ` · ${data.priceSymbol} ${data.priceAsOf}` : ""}`, tone: "muted" }] }] : [] });
155
153
  return <PaneStatusBody loading={resource.loading && !data} error={!data ? resource.error : null} empty={!!payload && !payload.contract} subject="COT contract">
156
154
  {payload && data ? <Box width={width} height={height} flexDirection="column">
157
155
  <CompositeChart series={series} panels={data.price.length ? [{ id: "price", height: 2 }, { id: "net", height: 1 }] : [{ id: "net" }]}
@@ -45,7 +45,8 @@ export function getCachedCreditConditions(): CreditConditionsLoadResult | null {
45
45
  const cached = getCachedFredSeries(requestFor(definition.seriesId), { allowExpired: true });
46
46
  if (!cached) continue;
47
47
  try {
48
- rows.push(normalizeCreditSeries(definition, cached.data, cached.stale));
48
+ // The pane revalidates this copy on mount; only the source or a failed refresh makes it stale.
49
+ rows.push(normalizeCreditSeries(definition, cached.data, cached.data.stale === true));
49
50
  } catch (error) {
50
51
  errors.push(error instanceof Error ? error.message : String(error));
51
52
  }
@@ -105,7 +105,8 @@ export function cachedCryptoMarkets(): CryptoMarketsResource | null {
105
105
  const cached = cryptoMarketsCache.get("usd", { allowExpired: true });
106
106
  if (!cached) return null;
107
107
  try {
108
- return { payload: validateCryptoMarkets(cached.data), stale: cached.stale, refreshError: null };
108
+ // The pane revalidates this copy on mount; only a failed refresh makes it stale.
109
+ return { payload: validateCryptoMarkets(cached.data), stale: false, refreshError: null };
109
110
  } catch {
110
111
  return null;
111
112
  }
@@ -52,6 +52,15 @@ test("a live quote without a change percent is restated from the previous close"
52
52
  expect(row!.changePercent).toBeCloseTo(10, 6);
53
53
  });
54
54
 
55
+ test("a live price crossing a power of ten keeps the decimals of its previous close", () => {
56
+ const coin = { ...cryptoFixture().assets[0]!, price: 9.95, previousClose: 9.9 };
57
+ const rowAt = (price: number) => buildCryptoRows(
58
+ [coin], "coin", new Map([[cryptoQuoteKey(coin), entry({ price, lastUpdated: CRYPTO_FIXTURE_NOW })]]), CRYPTO_FIXTURE_NOW,
59
+ )[0]!.priceText;
60
+ expect(rowAt(9.998)).toBe("9.998");
61
+ expect(rowAt(10.02)).toBe("10.020");
62
+ });
63
+
55
64
  test("tabs split coins from stablecoins and keep market-cap rank order", () => {
56
65
  const assets = cryptoFixture().assets;
57
66
  expect(buildCryptoRows(assets, "coin", new Map()).map((row) => row.code)).toEqual(["BTC", "HYPE"]);
@@ -41,6 +41,8 @@ export const cryptoQuoteKey = (asset: Pick<CryptoMarketAsset, "symbol">) =>
41
41
  const finite = (value: number | null | undefined): value is number =>
42
42
  typeof value === "number" && Number.isFinite(value);
43
43
 
44
+ const positive = (value: number | null | undefined): value is number => finite(value) && value > 0;
45
+
44
46
  const utcDay = (ms: number) => Math.floor(ms / DAY_MS);
45
47
 
46
48
  /** The close `days` UTC days before today, the basis the day change also uses. */
@@ -102,6 +104,9 @@ export function buildCryptoRow(
102
104
  ? [...closes, { date: new Date(Math.max(now, start + closes.length * DAY_MS)), close: price }]
103
105
  : closes;
104
106
  const updatedAt = quote?.lastUpdated ?? (asset.quoteTime ? Date.parse(asset.quoteTime) : null);
107
+ // The digits follow a price that holds still for the day, so a tick across a
108
+ // power of ten (0.9998 to 1.0002) keeps the row's decimals.
109
+ const referencePrice = [asset.previousClose, quote?.previousClose, quote?.open].find(positive) ?? asset.price;
105
110
  return {
106
111
  asset,
107
112
  id: asset.symbol,
@@ -109,7 +114,7 @@ export function buildCryptoRow(
109
114
  code: asset.code,
110
115
  name: asset.name,
111
116
  price,
112
- priceText: formatCryptoPrice(price, asset.kind === "stablecoin" ? 4 : 2),
117
+ priceText: formatCryptoPrice(price, asset.kind === "stablecoin" ? 4 : 2, referencePrice),
113
118
  changePercent,
114
119
  return7d: percentChange(price, closeDaysAgo(asset, 7, now)),
115
120
  return30d: percentChange(price, closeDaysAgo(asset, 30, now)),
@@ -149,10 +154,13 @@ export function buildCryptoRows(
149
154
  * Four significant digits and never fewer than two decimals, with trailing
150
155
  * zeros kept, so a column running from 84,399.24 to 0.000005660 reads evenly.
151
156
  * Stablecoins ask for four decimals so 1.0001 and 0.9998 show the same peg detail.
157
+ * The digits are counted at `referencePrice` (a session-fixed price such as the
158
+ * previous close) when there is one, so a live price crossing a power of ten
159
+ * keeps its decimals.
152
160
  */
153
- export function formatCryptoPrice(value: number | null, minimumDecimals = 2): string {
161
+ export function formatCryptoPrice(value: number | null, minimumDecimals = 2, referencePrice?: number | null): string {
154
162
  if (value == null || !Number.isFinite(value) || value <= 0) return "—";
155
- const magnitude = Math.floor(Math.log10(value));
163
+ const magnitude = Math.floor(Math.log10(positive(referencePrice) ? referencePrice : value));
156
164
  const decimals = Math.min(12, Math.max(minimumDecimals, 3 - magnitude));
157
165
  return value.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
158
166
  }
@@ -1,6 +1,6 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import type { Quote } from "../../../types/financials";
3
- import { liveViewColumns, overlayViewRow, viewStreamTargets } from "./live-quotes";
3
+ import { liveViewColumns, liveViewDecimalFloors, overlayViewRow, viewStreamTargets } from "./live-quotes";
4
4
 
5
5
  const quote: Quote = { symbol: "NVDA", price: 181.5, change: 3, changePercent: 1.68, volume: 12_000_000, currency: "USD", lastUpdated: 1 };
6
6
 
@@ -24,6 +24,11 @@ test("only a listing's own last price, day change and volume stream into a view"
24
24
  expect(overlayViewRow(row, quote, fields)).toEqual({ ...row, price: 181.5, changePercent: 1.68, volume: 12_000_000 });
25
25
  expect(overlayViewRow(row, { ...quote, currency: "EUR" }, fields)).toBe(row);
26
26
  expect(overlayViewRow(row, { ...quote, stale: true }, fields)).toBe(row);
27
+
28
+ // A live price keeps cents whatever the tick, and none for a yen-only view.
29
+ expect(liveViewDecimalFloors([row], fields)).toEqual(new Map([["price", 2], ["changePercent", 2]]));
30
+ expect(liveViewDecimalFloors([{ ...row, currency: "JPY" }], fields).get("price")).toBe(0);
31
+ expect(liveViewDecimalFloors([{ ...row, currency: "JPY" }, row], fields).get("price")).toBe(2);
27
32
  });
28
33
 
29
34
  test("a view streams the rows on screen fast and one screen either side in the background", () => {
@@ -1,4 +1,5 @@
1
1
  import { resolveEntryValue } from "../../../market-data/coordinator";
2
+ import { currencyMinorDigits } from "../../../market-data/market/format";
2
3
  import type { QueryEntry } from "../../../market-data/result-types";
3
4
  import type { QuoteSubscriptionTarget } from "../../../types/data-provider";
4
5
  import type { Quote } from "../../../types/financials";
@@ -41,6 +42,31 @@ export function liveViewColumns(columns: readonly HeadlessPaneColumn[]): Map<str
41
42
  return fields;
42
43
  }
43
44
 
45
+ /**
46
+ * The fewest decimals a live column shows, however a tick lands: a price its
47
+ * currency's minor unit (cents, none for yen), a percent change two.
48
+ */
49
+ export function liveViewDecimalFloors(rows: readonly ViewRow[], fields: ReadonlyMap<string, LiveViewField>): Map<string, number> {
50
+ const floors = new Map<string, number>();
51
+ let priceDigits: number | undefined;
52
+ for (const [key, field] of fields) {
53
+ if (field === "changePercent") {
54
+ floors.set(key, 2);
55
+ } else if (field === "price") {
56
+ priceDigits ??= rowsMinorDigits(rows);
57
+ floors.set(key, priceDigits);
58
+ }
59
+ }
60
+ return floors;
61
+ }
62
+
63
+ /** Cents unless every row trades in a currency without them. */
64
+ function rowsMinorDigits(rows: readonly ViewRow[]): number {
65
+ const currencies = new Set(rows.map((row) => (typeof row.currency === "string" ? row.currency.trim() : "")));
66
+ if (currencies.size === 0) return 2;
67
+ return Math.max(...[...currencies].map((currency) => Math.min(2, currencyMinorDigits(currency || undefined))));
68
+ }
69
+
44
70
  export function viewRowSymbol(row: ViewRow, symbolKey: string | null): string | null {
45
71
  if (!symbolKey) return null;
46
72
  const value = row[symbolKey];
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { apiClient, TeamRevisionConflictError } from "../../../api-client";
3
3
  import { DataTableView } from "../../../components/data-table/view";
4
4
  import { ChoiceDialog } from "../../../components/ui/choice-dialog";
@@ -29,6 +29,7 @@ import type { QuoteSubscriptionTarget } from "../../../types/data-provider";
29
29
  import { normalizeSymbol } from "../../../utils/exchanges";
30
30
  import {
31
31
  liveViewColumns,
32
+ liveViewDecimalFloors,
32
33
  overlayViewRow,
33
34
  viewRowQuote,
34
35
  viewRowSymbol,
@@ -39,8 +40,8 @@ import { loadViewSource, type LoadedView } from "./loader";
39
40
  import {
40
41
  applyViewProjection,
41
42
  formatViewValue,
43
+ growViewColumnDecimals,
42
44
  parseViewSpecOr,
43
- viewColumnDecimals,
44
45
  type ViewColumn,
45
46
  type ViewRow,
46
47
  type ViewSort,
@@ -56,6 +57,7 @@ const DEFAULT_COLUMN_WIDTH = 14;
56
57
  const NO_ROWS: ViewRow[] = [];
57
58
  const NO_SYMBOLS: readonly (string | null)[] = [];
58
59
  const NO_TARGETS: QuoteSubscriptionTarget[] = [];
60
+ const NO_DECIMALS: ReadonlyMap<string, number> = new Map();
59
61
  /** Live values move every tick; filters and sort follow them at most this often. */
60
62
  const LIVE_VIEW_ORDER_SAMPLE_MS = 5_000;
61
63
  /** Header and border rows above the table body. */
@@ -185,11 +187,20 @@ export function CustomViewPane({ focused, width, height }: PaneProps) {
185
187
  }
186
188
  return bases;
187
189
  }, [columns, rows]);
188
- const decimalsByColumn = useMemo(() => new Map(columns.flatMap((column) => {
189
- if (column.transform) return [];
190
- const decimals = viewColumnDecimals(rows, column.key);
191
- return decimals === undefined ? [] : [[column.key, decimals] as const];
192
- })), [columns, rows]);
190
+ const decimalFloors = useMemo(() => liveViewDecimalFloors(sourceRows, liveFields), [liveFields, sourceRows]);
191
+ // A column's decimals only grow while this spec is shown, so a tick that
192
+ // crosses 100 or lands on a whole number never reflows the column.
193
+ const heldDecimals = useRef<{ spec: ViewSpec | null; decimals: ReadonlyMap<string, number> }>({ spec: null, decimals: NO_DECIMALS });
194
+ const decimalsByColumn = useMemo(() => {
195
+ const held = heldDecimals.current.spec === spec ? heldDecimals.current.decimals : NO_DECIMALS;
196
+ const decimals = growViewColumnDecimals(held, rows, columns.flatMap((column) => (
197
+ column.transform === "percent" || column.transform === "index100"
198
+ ? []
199
+ : [{ key: column.key, minimum: decimalFloors.get(column.key) }]
200
+ )));
201
+ heldDecimals.current = { spec, decimals };
202
+ return decimals;
203
+ }, [columns, decimalFloors, rows, spec]);
193
204
  const activeSort = sort === undefined ? spec?.projection.sort ?? null : sort;
194
205
  // Memoized so the table's row memo holds while the selection moves.
195
206
  const rowKey = useCallback((row: ViewRow, index: number) => (
@@ -2,12 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  applyViewProjection,
4
4
  formatViewValue,
5
+ growViewColumnDecimals,
5
6
  normalizeViewSpec,
6
7
  parseViewSpec,
7
8
  parseViewSpecOr,
8
9
  serializeViewSpec,
9
10
  validateViewSpec,
10
- viewColumnDecimals,
11
+ type ViewRow,
11
12
  } from "./view-spec";
12
13
 
13
14
  const good = {
@@ -98,19 +99,36 @@ describe("applyViewProjection", () => {
98
99
  describe("formatViewValue", () => {
99
100
  test("applies transforms", () => {
100
101
  expect(formatViewValue(0.1234, "percent")).toBe("12.34%");
101
- expect(formatViewValue(1_234_567, "compact")).toBe("1.2M");
102
+ expect(formatViewValue(1_234_567, "compact")).toBe("1.23M");
103
+ expect(formatViewValue(12_300_000, "compact")).toBe("12.30M");
102
104
  expect(formatViewValue(-3, "abs")).toBe("3");
103
105
  expect(formatViewValue(110, "index100", 100)).toBe("110.0");
104
106
  expect(formatViewValue(3.14159, undefined)).toBe("3.14");
107
+ expect(formatViewValue(-0.001, undefined, undefined, 2)).toBe("0.00");
105
108
  expect(formatViewValue("text", "percent")).toBe("text");
106
109
  expect(formatViewValue(null, "compact")).toBe("");
107
110
  });
108
111
 
109
- test("reads midnight ISO stamps as dates and gives a numeric column one decimal count", () => {
112
+ test("reads midnight ISO stamps as dates", () => {
110
113
  expect(formatViewValue("2026-09-01T00:00:00.000Z", undefined)).toBe("2026-09-01");
111
114
  expect(formatViewValue("2026-09-01T14:30:00Z", undefined)).toBe("2026-09-01 14:30 UTC");
112
- const rows = [{ close: 236 }, { close: 232.1 }, { close: null }];
113
- const decimals = viewColumnDecimals(rows, "close");
114
- expect(rows.map((row) => formatViewValue(row.close, undefined, undefined, decimals))).toEqual(["236.0", "232.1", ""]);
115
+ });
116
+
117
+ test("a numeric column keeps one decimal count that only grows while the view is shown", () => {
118
+ const columns = [{ key: "close" }];
119
+ const format = (rows: ViewRow[], decimals: ReadonlyMap<string, number>) => (
120
+ rows.map((row) => formatViewValue(row.close, undefined, undefined, decimals.get("close")))
121
+ );
122
+ const loaded = [{ close: 236 }, { close: 232.1 }, { close: null }];
123
+ const held = growViewColumnDecimals(new Map(), loaded, columns);
124
+ expect(format(loaded, held)).toEqual(["236.0", "232.1", ""]);
125
+ // A tick lands on whole numbers: the column keeps its decimal rather than reflowing.
126
+ const whole = [{ close: 236 }, { close: 232 }];
127
+ expect(growViewColumnDecimals(held, whole, columns)).toBe(held);
128
+ // One row under 100 grows the whole column, and it stays grown.
129
+ const grown = growViewColumnDecimals(held, [{ close: 99.5 }], columns);
130
+ expect(format(whole, growViewColumnDecimals(grown, whole, columns))).toEqual(["236.00", "232.00"]);
131
+ // A live price keeps its floor on a whole-number tick.
132
+ expect(growViewColumnDecimals(new Map(), whole, [{ key: "close", minimum: 2 }]).get("close")).toBe(2);
115
133
  });
116
134
  });
@@ -5,6 +5,8 @@
5
5
  * version here never waits on a deploy. Modeled on `chart-composer/chart-spec`.
6
6
  */
7
7
 
8
+ import { formatCompact, formatCompactAmount } from "../../../utils/format";
9
+
8
10
  export const VIEW_SPEC_VERSION = 1;
9
11
  export const MAX_VIEW_COLUMNS = 24;
10
12
  export const MAX_VIEW_FILTERS = 12;
@@ -337,48 +339,78 @@ function naturalDecimals(value: number): number {
337
339
  return Number.isInteger(value) ? 0 : Math.abs(value) >= 100 ? 1 : 2;
338
340
  }
339
341
 
340
- /**
341
- * One decimal count for a plain numeric column, so a column never mixes
342
- * "236" with "232.1": the most any of its values would show on its own.
343
- */
344
- export function viewColumnDecimals(rows: readonly ViewRow[], key: string): number | undefined {
342
+ const MAX_NATURAL_DECIMALS = 2;
343
+
344
+ /** The most decimals any of a column's values would show on its own. */
345
+ function viewColumnDecimals(rows: readonly ViewRow[], key: string): number | undefined {
345
346
  let decimals: number | undefined;
346
347
  for (const row of rows) {
347
348
  const value = row[key];
348
349
  if (typeof value !== "number" || !Number.isFinite(value)) continue;
349
350
  decimals = Math.max(decimals ?? 0, naturalDecimals(value));
350
- if (decimals === 2) break;
351
+ if (decimals === MAX_NATURAL_DECIMALS) break;
351
352
  }
352
353
  return decimals;
353
354
  }
354
355
 
356
+ export interface ViewDecimalColumn {
357
+ key: string;
358
+ /** Decimals the column never shows fewer of, such as cents on a price. */
359
+ minimum?: number;
360
+ }
361
+
362
+ /**
363
+ * One decimal count per numeric column, so a column never mixes "236" with
364
+ * "232.1", grown with the rows now shown and never fewer than `held`. Live
365
+ * rows tick several times a second, and a count read from each tick flips the
366
+ * whole column when one value crosses 100 or lands on a whole number. Returns
367
+ * `held` itself when no count grew.
368
+ */
369
+ export function growViewColumnDecimals(
370
+ held: ReadonlyMap<string, number>,
371
+ rows: readonly ViewRow[],
372
+ columns: readonly ViewDecimalColumn[],
373
+ ): ReadonlyMap<string, number> {
374
+ let grown: Map<string, number> | null = null;
375
+ for (const { key, minimum = 0 } of columns) {
376
+ const current = held.get(key);
377
+ if (current !== undefined && current >= Math.max(minimum, MAX_NATURAL_DECIMALS)) continue;
378
+ const seen = viewColumnDecimals(rows, key);
379
+ if (seen === undefined && current === undefined) continue;
380
+ const decimals = Math.max(current ?? 0, seen ?? 0, minimum);
381
+ if (decimals === current) continue;
382
+ grown ??= new Map(held);
383
+ grown.set(key, decimals);
384
+ }
385
+ return grown ?? held;
386
+ }
387
+
388
+ /** A value that rounds to zero is unsigned: 0.00, never -0.00. */
389
+ function unsignedZero(text: string): string {
390
+ return text.startsWith("-") && !/[1-9]/.test(text) ? text.slice(1) : text;
391
+ }
392
+
355
393
  /**
356
394
  * A transform's display text, given the raw value, (for index100) the column's
357
- * first value, and (for plain numbers) the column's decimal count.
395
+ * first value, and (for plain, abs and compact numbers) the column's decimal count.
358
396
  */
359
397
  export function formatViewValue(value: unknown, transform: ViewTransform | undefined, base?: number, decimals?: number): string {
360
398
  if (value === null || value === undefined) return "";
361
399
  if (typeof value !== "number") return typeof value === "string" ? formatViewString(value) : JSON.stringify(value);
362
400
  if (!Number.isFinite(value)) return "";
401
+ const columnDecimals = decimals ?? naturalDecimals(value);
363
402
  switch (transform) {
364
403
  case "percent":
365
- return `${(value * 100).toFixed(2)}%`;
404
+ return unsignedZero(`${(value * 100).toFixed(2)}%`);
366
405
  case "compact":
367
- return compactNumber(value);
406
+ // A whole-number column (counts, volume) stays whole below 1,000; any
407
+ // other keeps two decimals there. Scaled values keep their zeros either way.
408
+ return columnDecimals === 0 ? formatCompact(value, { fixedDecimals: true }) : formatCompactAmount(value);
368
409
  case "abs":
369
- return String(Math.abs(value));
410
+ return Math.abs(value).toFixed(columnDecimals);
370
411
  case "index100":
371
- return base && Number.isFinite(base) && base !== 0 ? (value / base * 100).toFixed(1) : "";
412
+ return base && Number.isFinite(base) && base !== 0 ? unsignedZero((value / base * 100).toFixed(1)) : "";
372
413
  default:
373
- return value.toFixed(decimals ?? naturalDecimals(value));
374
- }
375
- }
376
-
377
- function compactNumber(value: number): string {
378
- const abs = Math.abs(value);
379
- const units: Array<[number, string]> = [[1e12, "T"], [1e9, "B"], [1e6, "M"], [1e3, "K"]];
380
- for (const [threshold, suffix] of units) {
381
- if (abs >= threshold) return `${(value / threshold).toFixed(abs / threshold >= 100 ? 0 : 1)}${suffix}`;
414
+ return unsignedZero(value.toFixed(columnDecimals));
382
415
  }
383
- return Number.isInteger(value) ? String(value) : value.toFixed(2);
384
416
  }
@@ -21,6 +21,15 @@ export const BUCKET_IDS = [
21
21
  "InYearFive",
22
22
  "AfterYearFive",
23
23
  ] as const;
24
+ /** Rolling-period concepts some filers (JPM from FY2023) use for the same buckets. */
25
+ const ROLLING_BUCKET_TAGS = [
26
+ "InNextRollingTwelveMonths",
27
+ "InRollingYearTwo",
28
+ "InRollingYearThree",
29
+ "InRollingYearFour",
30
+ "InRollingYearFive",
31
+ "InRollingAfterYearFive",
32
+ ] as const;
24
33
  const number = (value: unknown): value is number =>
25
34
  typeof value === "number" && Number.isFinite(value) && value >= 0;
26
35
  const nullableNumber = (value: unknown) => value === null || number(value);
@@ -177,8 +186,10 @@ export function validateDebtMaturities(
177
186
  : !sameFiling(bucket.fact) ||
178
187
  bucket.fact.end !== latest.asOf ||
179
188
  bucket.fact.start !== null ||
180
- bucket.fact.tag !==
181
- `LongTermDebtMaturitiesRepaymentsOfPrincipal${bucket.id}` ||
189
+ (bucket.fact.tag !==
190
+ `LongTermDebtMaturitiesRepaymentsOfPrincipal${bucket.id}` &&
191
+ bucket.fact.tag !==
192
+ `LongTermDebtMaturitiesRepaymentsOfPrincipal${ROLLING_BUCKET_TAGS[index]}`) ||
182
193
  bucket.fact.value !== bucket.value)
183
194
  )
184
195
  return invalid();
@@ -336,13 +347,15 @@ export interface DebtResource {
336
347
  stale: boolean;
337
348
  refreshError: string | null;
338
349
  }
350
+ // Seeds the first paint while the mount load runs; cache age is not staleness,
351
+ // and a failed refresh reports its own.
339
352
  export function cachedDebtMaturities(symbol: string): DebtResource | null {
340
353
  const cached = debtMaturitiesCache.get(symbol, { allowExpired: true });
341
354
  if (!cached) return null;
342
355
  try {
343
356
  return {
344
357
  payload: validateDebtMaturities(cached.data, symbol),
345
- stale: cached.stale,
358
+ stale: false,
346
359
  refreshError: null,
347
360
  };
348
361
  } catch {
@@ -214,6 +214,14 @@ describe("debt maturity Cloud boundary", () => {
214
214
  }
215
215
  });
216
216
 
217
+ test("accepts a bucket filed under its rolling-period concept, not another bucket's", () => {
218
+ const data = fixture();
219
+ data.latest!.buckets[1]!.fact!.tag = "LongTermDebtMaturitiesRepaymentsOfPrincipalInRollingYearTwo";
220
+ expect(validateDebtMaturities(data, "TEST").latest?.buckets[1]?.value).toBe(10);
221
+ data.latest!.buckets[1]!.fact!.tag = "LongTermDebtMaturitiesRepaymentsOfPrincipalInRollingYearThree";
222
+ expect(() => validateDebtMaturities(data, "TEST")).toThrow();
223
+ });
224
+
217
225
  test("missing bucket stays a chart gap and prevents a total and concentration denominator", () => {
218
226
  const data = fixture(),
219
227
  latest = data.latest!;