gloomberb 0.15.0 → 0.15.1

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 (180) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/central-bank-rates.ts +3 -0
  3. package/src/api-client/types.ts +1 -1
  4. package/src/app/pane-runtime/index.tsx +7 -5
  5. package/src/app/runtime/broker-import.test.tsx +89 -0
  6. package/src/app/runtime/broker-import.ts +29 -8
  7. package/src/brokers/sync-broker-instance.ts +67 -0
  8. package/src/cli/commands/overview.ts +6 -2
  9. package/src/components/chart/composite/composite-chart.tsx +21 -1
  10. package/src/components/chart/composite/format.ts +71 -8
  11. package/src/components/chart/composite/fx-precision.test.ts +21 -3
  12. package/src/components/chart/composite/scene.ts +13 -1
  13. package/src/components/chart/composite/types.ts +4 -0
  14. package/src/components/layout/market-summary.ts +4 -2
  15. package/src/components/ticker/badge/format.test.ts +3 -3
  16. package/src/components/ticker/badge/format.ts +11 -9
  17. package/src/components/ticker/badge/text.test.tsx +6 -6
  18. package/src/core/state/app/state.ts +8 -2
  19. package/src/market-data/coordinator/events.ts +5 -0
  20. package/src/market-data/coordinator/index.ts +44 -10
  21. package/src/market-data/market/format.test.ts +54 -0
  22. package/src/market-data/market/format.ts +164 -5
  23. package/src/market-data/query-store.test.ts +87 -0
  24. package/src/market-data/query-store.ts +66 -9
  25. package/src/plugins/builtin/alerts/format.ts +4 -3
  26. package/src/plugins/builtin/analytics/display.ts +3 -3
  27. package/src/plugins/builtin/analytics/index.test.tsx +22 -22
  28. package/src/plugins/builtin/analytics/pane-model.test.ts +10 -10
  29. package/src/plugins/builtin/analytics/pane-model.ts +8 -8
  30. package/src/plugins/builtin/analytics/portfolio-identity.test.tsx +6 -6
  31. package/src/plugins/builtin/analytics/risk-client.ts +6 -8
  32. package/src/plugins/builtin/analytics/risk-model.test.ts +21 -2
  33. package/src/plugins/builtin/analytics/risk-model.ts +8 -0
  34. package/src/plugins/builtin/analytics/view.tsx +2 -2
  35. package/src/plugins/builtin/backtest/engine.test.ts +0 -1
  36. package/src/plugins/builtin/backtest/engine.ts +1 -4
  37. package/src/plugins/builtin/backtest/pane.tsx +1 -1
  38. package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
  39. package/src/plugins/builtin/central-bank-rates/client.ts +2 -1
  40. package/src/plugins/builtin/central-bank-rates/headless.ts +2 -2
  41. package/src/plugins/builtin/central-bank-rates/model.test.ts +16 -1
  42. package/src/plugins/builtin/central-bank-rates/model.ts +4 -4
  43. package/src/plugins/builtin/central-bank-rates/pane.tsx +2 -2
  44. package/src/plugins/builtin/chart-composer/pane.tsx +2 -0
  45. package/src/plugins/builtin/chat/chat.test.tsx +2 -2
  46. package/src/plugins/builtin/cloud/askg/pane.tsx +1 -1
  47. package/src/plugins/builtin/cloud/thesis/board-pane.test.tsx +25 -0
  48. package/src/plugins/builtin/cloud/thesis/board-pane.tsx +3 -2
  49. package/src/plugins/builtin/cloud/thesis/store.ts +2 -2
  50. package/src/plugins/builtin/congress-trades/detail.tsx +1 -1
  51. package/src/plugins/builtin/congress-trades/table.tsx +1 -1
  52. package/src/plugins/builtin/cot/client.ts +2 -1
  53. package/src/plugins/builtin/cot/model.test.ts +9 -0
  54. package/src/plugins/builtin/cot/model.ts +30 -1
  55. package/src/plugins/builtin/cot/pane.tsx +2 -4
  56. package/src/plugins/builtin/credit-conditions/client.ts +2 -1
  57. package/src/plugins/builtin/crypto-board/client.ts +2 -1
  58. package/src/plugins/builtin/crypto-board/model.test.ts +9 -0
  59. package/src/plugins/builtin/crypto-board/model.ts +11 -3
  60. package/src/plugins/builtin/custom-view/live-quotes.test.ts +6 -1
  61. package/src/plugins/builtin/custom-view/live-quotes.ts +26 -0
  62. package/src/plugins/builtin/custom-view/pane.tsx +18 -7
  63. package/src/plugins/builtin/custom-view/view-spec.test.ts +24 -6
  64. package/src/plugins/builtin/custom-view/view-spec.ts +53 -21
  65. package/src/plugins/builtin/debt-maturities/client.ts +16 -3
  66. package/src/plugins/builtin/debt-maturities/debt-maturities.test.ts +8 -0
  67. package/src/plugins/builtin/debt-maturities/headless.ts +3 -2
  68. package/src/plugins/builtin/debt-maturities/model.ts +2 -1
  69. package/src/plugins/builtin/debt-maturities/pane.tsx +0 -8
  70. package/src/plugins/builtin/earnings-calls/pane.test.tsx +14 -0
  71. package/src/plugins/builtin/earnings-calls/pane.tsx +3 -1
  72. package/src/plugins/builtin/econ/fred-client.test.ts +2 -0
  73. package/src/plugins/builtin/econ/fred-series-map.ts +0 -1
  74. package/src/plugins/builtin/econ/index.tsx +4 -2
  75. package/src/plugins/builtin/econ-statistics/pane.tsx +4 -2
  76. package/src/plugins/builtin/econ-statistics/stats.ts +29 -24
  77. package/src/plugins/builtin/econ-statistics/view.test.ts +16 -0
  78. package/src/plugins/builtin/equity-screener/headless.test.ts +22 -0
  79. package/src/plugins/builtin/equity-screener/headless.ts +18 -10
  80. package/src/plugins/builtin/estimate-revisions/client.ts +3 -1
  81. package/src/plugins/builtin/estimate-revisions/model.test.ts +7 -0
  82. package/src/plugins/builtin/estimate-revisions/model.ts +0 -5
  83. package/src/plugins/builtin/futures/table.tsx +4 -3
  84. package/src/plugins/builtin/futures-curve/client.ts +2 -1
  85. package/src/plugins/builtin/futures-curve/model.test.ts +6 -4
  86. package/src/plugins/builtin/futures-curve/model.ts +18 -14
  87. package/src/plugins/builtin/futures-curve/pane.tsx +2 -2
  88. package/src/plugins/builtin/fx-matrix/index.tsx +21 -4
  89. package/src/plugins/builtin/fx-matrix/live-legs.ts +7 -0
  90. package/src/plugins/builtin/fx-matrix/pairs.test.ts +5 -0
  91. package/src/plugins/builtin/fx-matrix/pairs.ts +9 -3
  92. package/src/plugins/builtin/insider/index.tsx +15 -12
  93. package/src/plugins/builtin/insider/pane.test.tsx +24 -0
  94. package/src/plugins/builtin/market-movers/headless.ts +2 -2
  95. package/src/plugins/builtin/market-movers/model.ts +23 -10
  96. package/src/plugins/builtin/market-movers/screener.test.ts +5 -1
  97. package/src/plugins/builtin/market-movers/screener.ts +3 -0
  98. package/src/plugins/builtin/market-movers/table.ts +2 -2
  99. package/src/plugins/builtin/market-valuation/client.test.ts +3 -0
  100. package/src/plugins/builtin/market-valuation/client.ts +6 -1
  101. package/src/plugins/builtin/money-markets/client.ts +2 -1
  102. package/src/plugins/builtin/money-markets/pane.tsx +0 -1
  103. package/src/plugins/builtin/options/expiry-selection.test.tsx +1 -1
  104. package/src/plugins/builtin/options/footer.test.ts +2 -2
  105. package/src/plugins/builtin/options/footer.ts +2 -2
  106. package/src/plugins/builtin/options/market-reference.test.ts +2 -2
  107. package/src/plugins/builtin/options/market-reference.ts +14 -2
  108. package/src/plugins/builtin/options/table.ts +5 -8
  109. package/src/plugins/builtin/options/view.test.tsx +2 -2
  110. package/src/plugins/builtin/options/view.tsx +1 -1
  111. package/src/plugins/builtin/options-calculator/pane.test.tsx +6 -6
  112. package/src/plugins/builtin/options-calculator/pane.tsx +23 -8
  113. package/src/plugins/builtin/options-calculator/surface.test.ts +16 -1
  114. package/src/plugins/builtin/options-calculator/surface.ts +13 -6
  115. package/src/plugins/builtin/portfolio-list/column-values.ts +17 -9
  116. package/src/plugins/builtin/portfolio-list/index.test.ts +5 -5
  117. package/src/plugins/builtin/portfolio-list/metrics.test.ts +8 -8
  118. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +12 -12
  119. package/src/plugins/builtin/portfolio-list/position-cost-integrity.test.ts +2 -2
  120. package/src/plugins/builtin/portfolio-list/summary/index.tsx +7 -6
  121. package/src/plugins/builtin/rate-path/client.ts +2 -1
  122. package/src/plugins/builtin/rate-path/headless.ts +1 -1
  123. package/src/plugins/builtin/rate-path/pane.tsx +0 -1
  124. package/src/plugins/builtin/realized-vol/client.test.ts +7 -0
  125. package/src/plugins/builtin/realized-vol/headless.ts +1 -1
  126. package/src/plugins/builtin/realized-vol/model.test.ts +16 -4
  127. package/src/plugins/builtin/realized-vol/model.ts +24 -9
  128. package/src/plugins/builtin/relative-rotation/client.ts +2 -1
  129. package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -10
  130. package/src/plugins/builtin/risk-factors/data.test.ts +7 -0
  131. package/src/plugins/builtin/risk-factors/data.ts +8 -1
  132. package/src/plugins/builtin/risk-factors/pane.test.tsx +3 -0
  133. package/src/plugins/builtin/shared/screener-live-quotes.test.ts +3 -3
  134. package/src/plugins/builtin/shared/screener-live-quotes.ts +16 -1
  135. package/src/plugins/builtin/short-volume/client.ts +2 -1
  136. package/src/plugins/builtin/short-volume/pane.tsx +0 -1
  137. package/src/plugins/builtin/thirteenf/headless.ts +3 -3
  138. package/src/plugins/builtin/thirteenf/model.ts +14 -4
  139. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +12 -5
  140. package/src/plugins/builtin/thirteenf/overlap.test.ts +13 -1
  141. package/src/plugins/builtin/thirteenf/overlap.ts +31 -3
  142. package/src/plugins/builtin/thirteenf/pane.test.tsx +2 -1
  143. package/src/plugins/builtin/thirteenf/pane.tsx +12 -9
  144. package/src/plugins/builtin/thirteenf/table.ts +9 -3
  145. package/src/plugins/builtin/ticker-detail/financials/model.ts +0 -12
  146. package/src/plugins/builtin/ticker-detail/headless-schema.ts +4 -1
  147. package/src/plugins/builtin/ticker-detail/index.test.tsx +1 -1
  148. package/src/plugins/builtin/ticker-detail/overview/components.tsx +9 -10
  149. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  150. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +6 -9
  151. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +3 -2
  152. package/src/plugins/builtin/time-sales/model.test.ts +17 -1
  153. package/src/plugins/builtin/time-sales/model.ts +14 -3
  154. package/src/plugins/builtin/time-sales/pane.tsx +8 -4
  155. package/src/plugins/builtin/treasury-auctions/pane.tsx +2 -2
  156. package/src/plugins/builtin/vol-surface/client.ts +4 -1
  157. package/src/plugins/builtin/vol-surface/model.test.ts +4 -2
  158. package/src/plugins/builtin/vol-surface/model.ts +14 -2
  159. package/src/plugins/builtin/vol-surface/pane.tsx +9 -2
  160. package/src/plugins/builtin/volatility/client.test.ts +13 -8
  161. package/src/plugins/builtin/volatility/client.ts +3 -2
  162. package/src/plugins/builtin/volatility/headless.test.ts +2 -2
  163. package/src/plugins/builtin/volatility/model.test.ts +4 -0
  164. package/src/plugins/builtin/volatility/model.ts +6 -7
  165. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -2
  166. package/src/renderers/electrobun/bun/desktop/workspace/index.test.ts +33 -1
  167. package/src/renderers/electrobun/bun/desktop/workspace/index.ts +34 -1
  168. package/src/renderers/electrobun/bun/index.ts +2 -1
  169. package/src/sources/provider-router/profile-enrichment.test.ts +0 -3
  170. package/src/sources/yahoo-finance.test.ts +5 -1
  171. package/src/sources/yahoo-finance.ts +2 -1
  172. package/src/time-series/forward-valuation.ts +6 -0
  173. package/src/time-series/resolve.ts +5 -1
  174. package/src/time-series/valuation-currency.test.ts +2 -0
  175. package/src/time-series/valuation-currency.ts +3 -1
  176. package/src/utils/exchange-rate-snapshot.test.ts +12 -1
  177. package/src/utils/exchange-rate-snapshot.ts +3 -2
  178. package/src/utils/format.ts +55 -20
  179. package/src/utils/fx-market-hours.test.ts +28 -0
  180. package/src/utils/fx-market-hours.ts +42 -0
@@ -1,4 +1,4 @@
1
- import { formatMarketPriceWithCurrency } from "../../../market-data/market/format";
1
+ import { formatMarketPriceWithCurrency, liveQuoteFormatOptions } from "../../../market-data/market/format";
2
2
  import type { Quote } from "../../../types/financials";
3
3
  import { displayWidth } from "../../../utils/format";
4
4
 
@@ -12,13 +12,11 @@ export type TickerBadgeStatus = "loading" | "ready" | "ambiguous";
12
12
  /** Padding inside the chip plus the gap that separates it from the next one. */
13
13
  export const TICKER_BADGE_CHROME_WIDTH = 3;
14
14
 
15
+ /** One decimal on every tick, so a chip does not narrow when the move lands on a whole percent. */
15
16
  export function formatTickerBadgeChange(changePercent: number): string {
16
- const rounded = Math.round(changePercent * 10) / 10;
17
- const normalized = Object.is(rounded, -0) ? 0 : rounded;
18
- if (normalized === 0) return "0%";
19
- const abs = Math.abs(normalized);
20
- const body = Number.isInteger(abs) ? abs.toFixed(0) : abs.toFixed(1);
21
- return `${normalized > 0 ? "+" : "-"}${body}%`;
17
+ const body = Math.abs(changePercent).toFixed(1);
18
+ if (!/[1-9]/.test(body)) return `${body}%`;
19
+ return `${changePercent > 0 ? "+" : "-"}${body}%`;
22
20
  }
23
21
 
24
22
  export interface TickerBadgeTextOptions {
@@ -48,10 +46,14 @@ function tickerBadgeTextCandidates({
48
46
  const quoteForDisplay = liveQuote ? quote : null;
49
47
  const candidates: string[] = [];
50
48
  if (hovered && quoteForDisplay) {
51
- candidates.push(`${symbol} ${formatMarketPriceWithCurrency(quoteForDisplay.price, quoteForDisplay.currency, { minimumFractionDigits: 2 })}`);
49
+ candidates.push(`${symbol} ${formatMarketPriceWithCurrency(
50
+ quoteForDisplay.price,
51
+ quoteForDisplay.currency,
52
+ liveQuoteFormatOptions(quoteForDisplay, quoteForDisplay.currency),
53
+ )}`);
52
54
  }
53
55
  if (liveQuote) {
54
- if (status === "ready" && quoteForDisplay) {
56
+ if (status === "ready" && quoteForDisplay && Number.isFinite(quoteForDisplay.changePercent)) {
55
57
  candidates.push(`${symbol} ${formatTickerBadgeChange(quoteForDisplay.changePercent)}`);
56
58
  } else if (status === "loading") {
57
59
  candidates.push(`${symbol} \u2026`);
@@ -44,7 +44,7 @@ describe("TickerBadgeText", () => {
44
44
 
45
45
  await testSetup.renderOnce();
46
46
 
47
- expect(testSetup.captureCharFrame()).toContain("TSLA -5%");
47
+ expect(testSetup.captureCharFrame()).toContain("TSLA -5.0%");
48
48
  });
49
49
 
50
50
  test("falls back to the raw token when resolution failed", async () => {
@@ -63,7 +63,7 @@ describe("TickerBadgeText", () => {
63
63
 
64
64
  const frame = testSetup.captureCharFrame();
65
65
  expect(frame).toContain("$TSLA");
66
- expect(frame).not.toContain("TSLA -5%");
66
+ expect(frame).not.toContain("TSLA -5.0%");
67
67
  });
68
68
 
69
69
  test("opens the Ticker Research pane when a badge is clicked", async () => {
@@ -82,8 +82,8 @@ describe("TickerBadgeText", () => {
82
82
  await testSetup.renderOnce();
83
83
 
84
84
  const lines = testSetup.captureCharFrame().split("\n");
85
- const row = lines.findIndex((line) => line.includes("TSLA -5%"));
86
- const col = lines[row]?.indexOf("TSLA -5%") ?? -1;
85
+ const row = lines.findIndex((line) => line.includes("TSLA -5.0%"));
86
+ const col = lines[row]?.indexOf("TSLA -5.0%") ?? -1;
87
87
 
88
88
  expect(row).toBeGreaterThanOrEqual(0);
89
89
  expect(col).toBeGreaterThanOrEqual(0);
@@ -112,7 +112,7 @@ describe("TickerBadgeText", () => {
112
112
 
113
113
  const frame = testSetup.captureCharFrame();
114
114
  expect(frame).toContain("https://example.com");
115
- expect(frame).toContain("TSLA -5%");
115
+ expect(frame).toContain("TSLA -5.0%");
116
116
  });
117
117
 
118
118
  test("renders usernames as clickable tags when a username opener is provided", async () => {
@@ -186,7 +186,7 @@ describe("TickerBadgeText", () => {
186
186
 
187
187
  expect(firstRow).toBeGreaterThanOrEqual(0);
188
188
  expect(secondRow).toBeGreaterThan(firstRow);
189
- expect(lines[firstRow]).toContain("TSLA -5%");
189
+ expect(lines[firstRow]).toContain("TSLA -5.0%");
190
190
  });
191
191
 
192
192
  test("opens detected links without trailing punctuation when clicked", async () => {
@@ -41,9 +41,14 @@ export function appReducer(state: AppState, action: AppAction): AppState {
41
41
  if (layoutState) return layoutState;
42
42
 
43
43
  switch (action.type) {
44
- case "SET_CONFIG":
44
+ case "SET_CONFIG": {
45
+ // A config that leaves the layouts alone (a broker sync, a setting) keeps
46
+ // the layout undo history; one that replaces them starts it over.
47
+ const sameLayouts = action.config.layout === state.config.layout
48
+ && action.config.layouts === state.config.layouts
49
+ && action.config.activeLayoutIndex === state.config.activeLayoutIndex;
45
50
  return withFocusedPane(
46
- { ...state, themePreview: null, layoutHistory: {} },
51
+ { ...state, themePreview: null, layoutHistory: sameLayouts ? state.layoutHistory : {} },
47
52
  action.config,
48
53
  {
49
54
  paneState: {
@@ -52,6 +57,7 @@ export function appReducer(state: AppState, action: AppAction): AppState {
52
57
  },
53
58
  },
54
59
  );
60
+ }
55
61
 
56
62
  case "SET_ONBOARDING_STATE":
57
63
  return {
@@ -49,6 +49,11 @@ export class MarketDataCoordinatorEvents {
49
49
  return this.version;
50
50
  }
51
51
 
52
+ /** True while a mounted pane is subscribed to this key. */
53
+ hasKeyListeners(key: string): boolean {
54
+ return (this.keyListeners.get(key)?.size ?? 0) > 0;
55
+ }
56
+
52
57
  getKeysVersion(keys: readonly string[]): number {
53
58
  let version = 0;
54
59
  for (const key of new Set(keys)) {
@@ -2,7 +2,7 @@ import type { CachedAssetArgs, CachedAssetMethod, CachedAssetValue, DataProvider
2
2
  import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../../types/financials";
3
3
  import { fetchHistoryResult, type HistoryResultRequest } from "../../sources/history-result";
4
4
  import type { ChartRequest, InstrumentRef, OptionsRequest, SecFilingsRequest } from "../request-types";
5
- import { QueryStore } from "../query-store";
5
+ import { QueryStore, type QueryStoreRetention } from "../query-store";
6
6
  import type { QueryEntry } from "../result-types";
7
7
  import type { CachedQueryHandle } from "../../data/cached-query";
8
8
  import {
@@ -23,6 +23,7 @@ import { resolveTickerFinancialsQuoteState } from "../quotes/resolution";
23
23
  import { measurePerf } from "../../utils/perf-marks";
24
24
  import { hasLikelyQuoteUnitMismatch } from "../../utils/currency-units";
25
25
  import { hasUsablePriceHistory } from "../../utils/price-history";
26
+ import { fxFreshUntil } from "../../utils/fx-market-hours";
26
27
  import {
27
28
  createBaselineChartRequest,
28
29
  createChartLoadingEntry,
@@ -90,6 +91,23 @@ const STREAM_QUOTE_BASELINE_TTL_MS = 60_000;
90
91
  const STREAM_RECEIPT_REFRESH_MS = 5_000;
91
92
  /** Keeps a long session of browsing from holding a baseline per visited symbol. */
92
93
  const STREAM_QUOTE_BASELINE_LIMIT = 256;
94
+ /**
95
+ * Per-store ceiling for entries nothing on screen is watching. Quotes and FX
96
+ * rates stay uncapped: they are a few fields each, and a large portfolio
97
+ * legitimately holds thousands. The capped stores are the heavy ones, where a
98
+ * single entry is a full price history, statement set or filing document.
99
+ *
100
+ * A key a pane is watching is never evicted, so this only governs entries left
101
+ * behind by navigation. 64 keeps a deep back-and-forth instant while bounding
102
+ * what browsing can retain. For scale: the refresh plan in #452 asked for 17
103
+ * financials on a 1934-ticker install, and a session there measured 2.33 MB of
104
+ * resident memory per distinct ticker visited.
105
+ *
106
+ * ponytail: one number for every capped store. Filing text is the heaviest
107
+ * entry of the group by some margin, so give secContentStore its own smaller
108
+ * cap if this ceiling turns out to be too generous for it.
109
+ */
110
+ const RETAINED_ENTRIES_PER_STORE = 64;
93
111
 
94
112
  // A store hands out a fresh idle entry for every key it has never seen, so
95
113
  // two idle reads of the same key must count as the same entry.
@@ -112,14 +130,30 @@ export class MarketDataCoordinator {
112
130
  private readonly cachedQueries = new Map<string, { query: CachedQueryHandle<unknown>; dispose: () => void }>();
113
131
  private readonly streamQuoteBaselines = new Map<string, { baseline: TickerFinancials | null; readAt: number }>();
114
132
 
133
+ private readonly retained: QueryStoreRetention = {
134
+ maxRetainedEntries: RETAINED_ENTRIES_PER_STORE,
135
+ isWatched: (key) => this.events.hasKeyListeners(key),
136
+ };
137
+
138
+ /**
139
+ * The merged financials view below is derived from a snapshot entry and keyed
140
+ * the same way, so it has to go when that entry does. Left behind it would
141
+ * hold its own normalized copy of the price history, which is the larger half
142
+ * of what the snapshot key costs.
143
+ */
144
+ private readonly retainedSnapshots: QueryStoreRetention = {
145
+ ...this.retained,
146
+ onEvict: (key) => { this.financialsSnapshotCache.delete(key); },
147
+ };
148
+
115
149
  private readonly quoteStore = new QueryStore<Quote>((key) => this.events.bump(key));
116
- private readonly snapshotStore = new QueryStore<TickerFinancials>((key) => this.events.bump(key));
117
- private readonly chartStore = new QueryStore<PricePoint[]>((key) => this.events.bump(key));
118
- private readonly optionsStore = new QueryStore<OptionsChain>((key) => this.events.bump(key));
119
- private readonly secFilingsStore = new QueryStore<SecFilingItem[]>((key) => this.events.bump(key));
120
- private readonly secDocumentsStore = new QueryStore<SecFilingDocument[]>((key) => this.events.bump(key));
121
- private readonly secContentStore = new QueryStore<string | null>((key) => this.events.bump(key));
122
- private readonly articleSummaryStore = new QueryStore<string | null>((key) => this.events.bump(key));
150
+ private readonly snapshotStore = new QueryStore<TickerFinancials>((key) => this.events.bump(key), this.retainedSnapshots);
151
+ private readonly chartStore = new QueryStore<PricePoint[]>((key) => this.events.bump(key), this.retained);
152
+ private readonly optionsStore = new QueryStore<OptionsChain>((key) => this.events.bump(key), this.retained);
153
+ private readonly secFilingsStore = new QueryStore<SecFilingItem[]>((key) => this.events.bump(key), this.retained);
154
+ private readonly secDocumentsStore = new QueryStore<SecFilingDocument[]>((key) => this.events.bump(key), this.retained);
155
+ private readonly secContentStore = new QueryStore<string | null>((key) => this.events.bump(key), this.retained);
156
+ private readonly articleSummaryStore = new QueryStore<string | null>((key) => this.events.bump(key), this.retained);
123
157
  private readonly liveFxRates = new Map<string, { rate: number; observedAt: number; receivedAt: number }>();
124
158
  /**
125
159
  * The last rate a request loaded per currency; a streamed rate must stay
@@ -135,7 +169,7 @@ export class MarketDataCoordinator {
135
169
  private readonly fxLegsByCurrency = new Map<string, FxLeg>();
136
170
  private readonly fxStore = new QueryStore<number>(
137
171
  (key) => this.events.bump(key),
138
- (key, entry) => this.projectLiveFxRate(key, entry),
172
+ { project: (key, entry) => this.projectLiveFxRate(key, entry) },
139
173
  );
140
174
  private readonly financialCacheStores: FinancialCacheStores = {
141
175
  quoteStore: this.quoteStore,
@@ -635,7 +669,7 @@ export class MarketDataCoordinator {
635
669
  error: null,
636
670
  asOf: live.observedAt,
637
671
  fetchedAt: Math.max(entry.fetchedAt ?? 0, live.receivedAt),
638
- staleAt: live.observedAt + FX_LIVE_RATE_STALE_MS,
672
+ staleAt: fxFreshUntil(live.observedAt, FX_LIVE_RATE_STALE_MS),
639
673
  };
640
674
  }
641
675
 
@@ -11,7 +11,9 @@ import {
11
11
  formatMarketPriceWithCurrency,
12
12
  formatMarketQuantity,
13
13
  formatSignedMarketPrice,
14
+ liveQuoteFormatOptions,
14
15
  resolveAssetDisplayKind,
16
+ stablePriceFractionDigits,
15
17
  } from "./format";
16
18
 
17
19
  describe("resolveAssetDisplayKind", () => {
@@ -228,3 +230,55 @@ test("money prices pad to the currency's minor unit without cutting crypto, FX o
228
230
  expect(pad(3025, "JPY", { assetCategory: "EQUITY" })).toBe("¥3,025");
229
231
  expect(pad(87, "USD", { assetCategory: "BOND", priceBasis: "percent-of-par" })).toBe("87% par");
230
232
  });
233
+
234
+ // Streamed quotes re-render several times a second. Decimals chosen from each
235
+ // tick made 150.10 print as 150.1 and a coin crossing $100 drop two digits, so
236
+ // the numbers visibly jumped.
237
+ describe("live quote precision", () => {
238
+ const live = (values: number[], quote: Parameters<typeof liveQuoteFormatOptions>[0], currency = "USD") =>
239
+ values.map((value) => formatMarketPrice(value, liveQuoteFormatOptions(quote, currency)));
240
+
241
+ test("keeps one decimal count per instrument whatever the tick", () => {
242
+ expect(live([150.1, 150.12, 150], { instrumentType: "EQUITY", previousClose: 149.99 }))
243
+ .toEqual(["150.10", "150.12", "150.00"]);
244
+ expect(live([0.9987, 1.0012, 1.01], { instrumentType: "EQUITY", previousClose: 0.9987 }))
245
+ .toEqual(["0.9987", "1.0012", "1.0100"]);
246
+ expect(live([99.9912, 100.01, 100], { instrumentType: "CRYPTOCURRENCY", previousClose: 101 }))
247
+ .toEqual(["99.99", "100.01", "100.00"]);
248
+ expect(live([0.16234, 0.16], { instrumentType: "CRYPTOCURRENCY", previousClose: 0.16234 }))
249
+ .toEqual(["0.16234", "0.16000"]);
250
+ expect(live([1.17364, 1.17], { instrumentType: "CURRENCY", previousClose: 1.17364 }))
251
+ .toEqual(["1.17364", "1.17000"]);
252
+ expect(live([153.554, 153.5], { instrumentType: "CURRENCY", previousClose: 153.5540008544922 }, "JPY"))
253
+ .toEqual(["153.554", "153.500"]);
254
+ expect(live([6012.25, 6012.5, 6013], { instrumentType: "FUTURE", previousClose: 6010.75 }))
255
+ .toEqual(["6,012.25", "6,012.50", "6,013.00"]);
256
+ expect(live([2.35, 2.4], { instrumentType: "OPTION", previousClose: 2.35 })).toEqual(["2.35", "2.40"]);
257
+ });
258
+
259
+ test("takes decimals from the currency's minor unit and from session prices, never from the tick", () => {
260
+ expect(live([3025, 3026], { instrumentType: "EQUITY", previousClose: 3025 }, "JPY")).toEqual(["3,025", "3,026"]);
261
+ expect(live([2710, 2710.5], { instrumentType: "EQUITY", previousClose: 2710, open: 2708.5 }, "JPY"))
262
+ .toEqual(["2,710.0", "2,710.5"]);
263
+ expect(live([35.365, 35.1], { instrumentType: "EQUITY", previousClose: 35.32, providerPriceDivisor: 100 }, "GBP"))
264
+ .toEqual(["35.3650", "35.1000"]);
265
+ // A provider's float32 close does not ask for its binary tail.
266
+ expect(stablePriceFractionDigits({ assetCategory: "FUTURE", referencePrice: 157.8800048828125, sessionPrices: [157.8800048828125] }))
267
+ .toBe(2);
268
+ });
269
+
270
+ test("a tick finer than the session prices keeps its digits, and a tiny move never prints as zero", () => {
271
+ // A half-yen print after whole-yen closes is not rounded to a price that never traded.
272
+ expect(live([1850.5], { instrumentType: "EQUITY", previousClose: 1850, open: 1851 }, "JPY")).toEqual(["1,850.5"]);
273
+ const shib = liveQuoteFormatOptions({ instrumentType: "CRYPTOCURRENCY", previousClose: 0.00001234 }, "USD");
274
+ expect(formatSignedMarketPrice(-0.00000036, { ...shib, maxWidth: 9 })).toBe("-3.6e-7");
275
+ });
276
+
277
+ test("changes use the price's decimals, and a move that rounds to zero is unsigned", () => {
278
+ const options = liveQuoteFormatOptions({ instrumentType: "EQUITY", previousClose: 150 }, "USD");
279
+ expect([0.135, 1.2, 0, -0.004, -0.5].map((change) => formatSignedMarketPrice(change, options)))
280
+ .toEqual(["+0.14", "+1.20", "0.00", "0.00", "-0.50"]);
281
+ expect(formatMarketChangeWithCurrency(-0.004, "USD", options)).toBe("$0.00");
282
+ expect(formatMarketPriceWithCurrency(-0.001, "USD", options)).toBe("$0.00");
283
+ });
284
+ });
@@ -22,6 +22,10 @@ export interface MarketFormatOptions extends AssetDisplayContext {
22
22
  fixedFractionDigits?: number;
23
23
  /** Source units per displayed currency unit; a price quoted in pence keeps its pence decimals in pounds. */
24
24
  quotedUnitDivisor?: number;
25
+ /** A price that holds still for the session, such as the previous close. With
26
+ * `fixedFractionDigits`, the kind's decimal ceiling is read at this price
27
+ * instead of at the value, so a live price keeps its decimals as it moves. */
28
+ referencePrice?: number;
25
29
  }
26
30
 
27
31
  /** Current price fields use the quote's source metadata; stored cost/mark
@@ -117,6 +121,11 @@ function formatVariableNumber(
117
121
  if (fitsWidth(formatted, maxWidth)) return formatted;
118
122
  }
119
123
  }
124
+ // A cell too narrow for the minimum gives up decimals one at a time.
125
+ for (let decimals = minimumFractionDigits - 1; decimals > 0; decimals -= 1) {
126
+ const formatted = getNumberFormatter(decimals, decimals, false).format(value);
127
+ if (fitsWidth(formatted, maxWidth)) return formatted;
128
+ }
120
129
 
121
130
  return getNumberFormatter(0, 0, false).format(value);
122
131
  }
@@ -194,7 +203,11 @@ function formatPriceNumber(value: number, decimals: number, maxWidth: number | u
194
203
  ? "0"
195
204
  : formatVariableNumber(value, Math.max(decimals, tinyDecimals), tinyDecimals > 0 ? undefined : maxWidth, minimumDecimals);
196
205
  if (value === 0 || !Number.isFinite(value) || (Number(rendered.replaceAll(",", "")) !== 0 && fitsWidth(rendered, maxWidth))) return rendered;
197
- // A constrained cell must not imply a worthless asset or unchanged price.
206
+ return scientificPrice(value, maxWidth);
207
+ }
208
+
209
+ /** A constrained cell must not imply a worthless asset or unchanged price. */
210
+ function scientificPrice(value: number, maxWidth: number | undefined): string {
198
211
  for (let precision = 4; precision >= 1; precision -= 1) {
199
212
  const scientific = Number(value.toPrecision(precision)).toExponential();
200
213
  if (fitsWidth(scientific, maxWidth)) return scientific;
@@ -310,11 +323,21 @@ export function formatMarketPrice(value: number | undefined, options: MarketForm
310
323
  const quotedUnitDigits = kind === "equity" || kind === "other" ? quotedUnitFractionDigits(options.quotedUnitDivisor) : 0;
311
324
  const fixedFractionDigits = options.fixedFractionDigits;
312
325
  if (fixedFractionDigits !== undefined) {
326
+ const reference = positiveMagnitude(options.referencePrice);
313
327
  const maxFractionDigits = kind === "other" && options.priceRange !== undefined
314
328
  ? 6
315
- : getBasePriceMaxFractionDigits(kind, value) + quotedUnitDigits;
316
- const clampedFixedFractionDigits = Math.max(0, Math.min(fixedFractionDigits, maxFractionDigits));
317
- return formatVariableNumber(value, clampedFixedFractionDigits, options.maxWidth, clampedFixedFractionDigits);
329
+ : reference === undefined
330
+ ? getBasePriceMaxFractionDigits(kind, value) + quotedUnitDigits
331
+ : marketPriceFractionDigitCeiling(reference, options) + quotedUnitDigits;
332
+ let digits = Math.max(0, Math.min(fixedFractionDigits, maxFractionDigits));
333
+ // A live price keeps its instrument's decimals, but a trade finer than the
334
+ // session prices showed (a half-yen print after whole-yen closes) is not
335
+ // rounded to a price that never traded.
336
+ if (reference !== undefined) digits = Math.max(digits, writtenFractionDigits(Math.abs(value), maxFractionDigits));
337
+ const fitted = formatVariableNumber(value, digits, options.maxWidth, digits);
338
+ return hasNonZeroDigit(fitted) || Number(Math.abs(value).toFixed(digits)) === 0
339
+ ? fitted
340
+ : scientificPrice(value, options.maxWidth);
318
341
  }
319
342
  const minimumFractionDigits = Math.max(
320
343
  0,
@@ -344,6 +367,13 @@ export function formatMarketCost(value: number | undefined, options: MarketForma
344
367
  export function formatSignedMarketPrice(value: number | undefined, options: MarketFormatOptions = {}): string {
345
368
  if (value === undefined || value === null || Number.isNaN(value)) return "—";
346
369
  if (resolvePriceBasis(options.priceBasis, options.assetCategory) === null) return "—";
370
+ if (options.fixedFractionDigits !== undefined) {
371
+ // A move at the instrument's fixed decimals: the sign always has its column,
372
+ // and a move that rounds to zero is unsigned (0.00, never +0.00 or -0.00).
373
+ const maxWidth = options.maxWidth == null ? undefined : Math.max(1, options.maxWidth - 1);
374
+ const body = formatMarketPrice(Math.abs(value), { ...options, maxWidth });
375
+ return hasNonZeroDigit(body) ? `${value > 0 ? "+" : "-"}${body}` : body;
376
+ }
347
377
  if (value > 0) {
348
378
  const maxWidth = options.maxWidth == null ? undefined : Math.max(1, options.maxWidth - 1);
349
379
  return `+${formatMarketPrice(value, { ...options, maxWidth })}`;
@@ -363,6 +393,11 @@ export function formatMarketChangeWithCurrency(
363
393
  ): string {
364
394
  if (value == null || !Number.isFinite(value)) return "—";
365
395
  if (resolvePriceBasis(options.priceBasis, options.assetCategory) !== "per-unit") return formatSignedMarketPrice(value, options);
396
+ if (options.fixedFractionDigits !== undefined) {
397
+ const maxWidth = options.maxWidth == null ? undefined : Math.max(1, options.maxWidth - 1);
398
+ const body = formatMarketPriceWithCurrency(Math.abs(value), currency, { ...options, maxWidth });
399
+ return hasNonZeroDigit(body) ? `${value > 0 ? "+" : "-"}${body}` : body;
400
+ }
366
401
  if (resolveAssetDisplayKind(options) === "contract" || quotedUnitFractionDigits(options.quotedUnitDivisor) > 0) {
367
402
  return `${value > 0 ? "+" : ""}${formatMarketPriceWithCurrency(value, currency, {
368
403
  ...options, minimumFractionDigits: Math.max(2, options.minimumFractionDigits ?? 0),
@@ -396,6 +431,130 @@ export function withCurrencyMinorDigits(options: MarketFormatOptions, currency:
396
431
  return { ...options, minimumFractionDigits: Math.max(options.minimumFractionDigits ?? 0, Math.min(2, currencyMinorDigits(currency))) };
397
432
  }
398
433
 
434
+ function positiveMagnitude(value: number | null | undefined): number | undefined {
435
+ return value != null && Number.isFinite(value) && value !== 0 ? Math.abs(value) : undefined;
436
+ }
437
+
438
+ function hasNonZeroDigit(text: string): boolean {
439
+ return /[1-9]/.test(text);
440
+ }
441
+
442
+ /** Decimals a price was written with, ignoring binary noise: 6012.25 is 2, so
443
+ * is a provider's float32 157.8800048828125, and a change of
444
+ * 0.00004999999999988347 is 5. */
445
+ function writtenFractionDigits(value: number, cap: number): number {
446
+ const float32 = Math.fround(value) === value;
447
+ for (let digits = 0; digits < cap; digits += 1) {
448
+ const written = Number(value.toFixed(digits));
449
+ if (Math.abs(written - value) <= 1e-9 * value) return digits;
450
+ if (float32 && Math.fround(written) === value) return digits;
451
+ }
452
+ return cap;
453
+ }
454
+
455
+ const OPTION_CONTRACT_TYPES = new Set(["OPT", "OPTION", "OPTIONS", "FOP"]);
456
+
457
+ export interface StablePriceContext extends AssetDisplayContext {
458
+ /** The price's currency; its minor unit sets an equity's decimals (USD 2, JPY 0). */
459
+ currency?: string;
460
+ quotedUnitDivisor?: number;
461
+ /** A price that holds still for the session, such as the previous close.
462
+ * Never the live price: a precision read from each tick is what makes the
463
+ * digits jump. */
464
+ referencePrice?: number;
465
+ /** Session-fixed prices as the feed wrote them (previous close, open).
466
+ * Their decimals can ask for more than the default, such as a half-yen
467
+ * Tokyo line. */
468
+ sessionPrices?: readonly (number | null | undefined)[];
469
+ }
470
+
471
+ /**
472
+ * The decimals a live price keeps for its instrument, whatever the tick: an
473
+ * equity above $1 always shows cents (150.10, not 150.1), and a coin, pair or
474
+ * contract takes its count from the reference price, never from the latest
475
+ * trade, so crossing $100 or 1.0000 on a tick changes nothing. A floor per kind
476
+ * is raised to the decimals the reference and session prices were written
477
+ * with (a half-yen close, a 5-decimal FX feed), up to the kind's ceiling.
478
+ * Changes, bid, ask and ranges of the same instrument use the same count.
479
+ */
480
+ export function stablePriceFractionDigits(context: StablePriceContext): number {
481
+ const reference = positiveMagnitude(context.referencePrice);
482
+ const kind = resolveAssetDisplayKind(context);
483
+ const unitDigits = kind === "equity" || kind === "other" ? quotedUnitFractionDigits(context.quotedUnitDivisor) : 0;
484
+ const ceiling = reference === undefined ? 8 : marketPriceFractionDigitCeiling(reference, context) + unitDigits;
485
+ const below = (limit: number) => reference !== undefined && reference < limit;
486
+ // Four significant digits for a sub-unit price.
487
+ const subUnitDigits = reference === undefined ? 4 : Math.min(8, Math.max(4, 3 - Math.floor(Math.log10(reference))));
488
+ let digits: number;
489
+ if (resolvePriceBasis(context.priceBasis, context.assetCategory) === "percent-of-par") {
490
+ digits = 2;
491
+ } else if (kind === "equity" || kind === "other") {
492
+ // A sub-unit price keeps four decimals of the major unit, pence lines
493
+ // included; above one unit the currency's minor unit (and pence) apply.
494
+ digits = below(1) ? 4 : Math.min(2, currencyMinorDigits(context.currency)) + unitDigits;
495
+ } else if (kind === "cash") {
496
+ digits = below(10) ? 4 : 2;
497
+ } else if (kind === "crypto") {
498
+ digits = below(1) ? subUnitDigits : below(100) ? 4 : 2;
499
+ } else if (kind === "contract") {
500
+ const type = normalizeType(context.contractSecType || context.assetCategory);
501
+ digits = OPTION_CONTRACT_TYPES.has(type) || !CONTRACT_TYPES.has(type) || !below(1) ? 2 : subUnitDigits;
502
+ } else {
503
+ digits = 2;
504
+ }
505
+ if (kind !== "crypto" && reference !== undefined) digits = Math.max(digits, tinyPriceFractionDigits(reference));
506
+ for (const price of context.sessionPrices ?? []) {
507
+ const magnitude = positiveMagnitude(price);
508
+ if (magnitude !== undefined) digits = Math.max(digits, writtenFractionDigits(magnitude, ceiling));
509
+ }
510
+ return digits;
511
+ }
512
+
513
+ type LiveQuotePrices = Pick<Quote, "price" | "previousClose" | "regularClose" | "open">;
514
+
515
+ /** The session-fixed price a quote's decimals are chosen from. */
516
+ export function quoteReferencePrice(quote: Partial<LiveQuotePrices> | null | undefined): number | undefined {
517
+ return positiveMagnitude(quote?.previousClose)
518
+ ?? positiveMagnitude(quote?.regularClose)
519
+ ?? positiveMagnitude(quote?.open)
520
+ ?? positiveMagnitude(quote?.price);
521
+ }
522
+
523
+ /**
524
+ * Pins a live price's options to its instrument's decimals (see
525
+ * stablePriceFractionDigits). `prices` supplies the session-fixed prices, from
526
+ * the quote when there is one or a stored mark when there is not.
527
+ */
528
+ export function withStablePriceDigits(
529
+ options: MarketFormatOptions,
530
+ currency: string | undefined,
531
+ prices: Partial<LiveQuotePrices> | null | undefined,
532
+ ): MarketFormatOptions {
533
+ if (resolvePriceBasis(options.priceBasis, options.assetCategory) === null) return options;
534
+ // Without any session price, the live price can only give the magnitude.
535
+ const referencePrice = quoteReferencePrice(prices);
536
+ return {
537
+ ...options,
538
+ referencePrice,
539
+ fixedFractionDigits: stablePriceFractionDigits({
540
+ ...options,
541
+ currency,
542
+ referencePrice,
543
+ sessionPrices: [prices?.previousClose, prices?.regularClose, prices?.open],
544
+ }),
545
+ };
546
+ }
547
+
548
+ /** Options for a quote's live prices and changes: its instrument's fixed decimals, whatever the tick. */
549
+ export function liveQuoteFormatOptions(
550
+ quote: (Pick<Quote, "instrumentType" | "priceBasis" | "providerPriceDivisor"> & Partial<LiveQuotePrices>) | null | undefined,
551
+ currency: string | undefined,
552
+ fallbackAssetCategory?: string,
553
+ metadataInstrumentType?: string,
554
+ ): MarketFormatOptions {
555
+ return withStablePriceDigits(quoteFormatOptions(quote, fallbackAssetCategory, metadataInstrumentType), currency, quote);
556
+ }
557
+
399
558
  export function formatMarketPriceWithCurrency(
400
559
  value: number | undefined,
401
560
  currency = "USD",
@@ -410,7 +569,7 @@ export function formatMarketPriceWithCurrency(
410
569
  ? undefined
411
570
  : Math.max(1, options.maxWidth - sign.length - symbol.length);
412
571
  const body = formatMarketPrice(Math.abs(value), { ...options, maxWidth: numericWidth });
413
- return `${sign}${symbol}${body}`;
572
+ return `${hasNonZeroDigit(body) ? sign : ""}${symbol}${body}`;
414
573
  }
415
574
 
416
575
  export function formatMarketCostWithCurrency(
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { QueryStore } from "./query-store";
3
+ import type { QueryEntry } from "./result-types";
4
+
5
+ function ready(value: string): QueryEntry<string> {
6
+ return {
7
+ phase: "ready",
8
+ data: value,
9
+ lastGoodData: value,
10
+ source: "test",
11
+ fetchedAt: 0,
12
+ staleAt: null,
13
+ error: null,
14
+ attempts: [],
15
+ };
16
+ }
17
+
18
+ describe("QueryStore retention", () => {
19
+ it("keeps every entry when no ceiling is configured", () => {
20
+ const store = new QueryStore<string>(() => {});
21
+ for (let index = 0; index < 500; index += 1) store.set(`k${index}`, ready(`v${index}`));
22
+ expect(store.size).toBe(500);
23
+ });
24
+
25
+ it("drops the oldest unwatched entries once past the ceiling", () => {
26
+ const store = new QueryStore<string>(() => {}, { maxRetainedEntries: 3 });
27
+ for (const key of ["a", "b", "c", "d", "e"]) store.set(key, ready(key));
28
+
29
+ expect(store.size).toBe(3);
30
+ expect(store.get("a").phase).toBe("idle");
31
+ expect(store.get("b").phase).toBe("idle");
32
+ expect(store.get("e").data).toBe("e");
33
+ });
34
+
35
+ it("never evicts a key a pane is watching, even past the ceiling", () => {
36
+ const watched = new Set(["a", "b", "c", "d"]);
37
+ const store = new QueryStore<string>(() => {}, {
38
+ maxRetainedEntries: 2,
39
+ isWatched: (key) => watched.has(key),
40
+ });
41
+ for (const key of ["a", "b", "c", "d"]) store.set(key, ready(key));
42
+
43
+ // Four visible panes outrank a ceiling of two: blanking one would be worse
44
+ // than holding the memory.
45
+ expect(store.size).toBe(4);
46
+
47
+ // Once they unmount, the next write collects them.
48
+ watched.clear();
49
+ store.set("e", ready("e"));
50
+ expect(store.size).toBe(2);
51
+ expect(store.get("e").data).toBe("e");
52
+ });
53
+
54
+ it("evicts around watched keys rather than stopping at the first one", () => {
55
+ const store = new QueryStore<string>(() => {}, {
56
+ maxRetainedEntries: 2,
57
+ isWatched: (key) => key === "a",
58
+ });
59
+ for (const key of ["a", "b", "c", "d"]) store.set(key, ready(key));
60
+
61
+ expect(store.size).toBe(2);
62
+ expect(store.get("a").data).toBe("a");
63
+ expect(store.get("d").data).toBe("d");
64
+ });
65
+
66
+ it("reports every evicted key so derived caches can drop it too", () => {
67
+ const evicted: string[] = [];
68
+ const store = new QueryStore<string>(() => {}, {
69
+ maxRetainedEntries: 2,
70
+ onEvict: (key) => evicted.push(key),
71
+ });
72
+ for (const key of ["a", "b", "c", "d"]) store.set(key, ready(key));
73
+
74
+ expect(evicted).toEqual(["a", "b"]);
75
+ });
76
+
77
+ it("returns an evicted key to idle so the next read refetches", () => {
78
+ const store = new QueryStore<string>(() => {}, { maxRetainedEntries: 1 });
79
+ store.set("a", ready("a"));
80
+ store.set("b", ready("b"));
81
+
82
+ const evicted = store.get("a");
83
+ expect(evicted.phase).toBe("idle");
84
+ expect(evicted.data).toBeNull();
85
+ expect(evicted.lastGoodData).toBeNull();
86
+ });
87
+ });