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
@@ -10,7 +10,7 @@ import { useQuoteEntry, useResolvedEntryValue } from "../../market-data/hooks";
10
10
  import { useQuoteStreaming } from "../../state/hooks/quote-streaming";
11
11
  import type { QuoteSubscriptionTarget } from "../../types/data-provider";
12
12
  import { formatPercentRaw } from "../../utils/format";
13
- import { formatMarketPrice } from "../../market-data/market/format";
13
+ import { formatMarketPrice, liveQuoteFormatOptions } from "../../market-data/market/format";
14
14
  import { getActiveQuoteDisplay, marketStateColor, marketStateCountdown, marketStateLabel } from "../../market-data/market/status";
15
15
 
16
16
  /**
@@ -115,8 +115,10 @@ export function useMarketSummary(): MarketSummary {
115
115
 
116
116
  const activeSpyQuote = getActiveQuoteDisplay(spyQuote);
117
117
  const spyColor = activeSpyQuote?.change != null ? priceColor(activeSpyQuote.change, colors) : colors.textDim;
118
+ // Fixed decimals and a percent slot as wide as "+0.53%", so a streamed tick
119
+ // never slides the market-state label or re-fits the cluster.
118
120
  const spyText = activeSpyQuote
119
- ? `SPY ${formatMarketPrice(activeSpyQuote.price, { assetCategory: "ETF" })} ${formatPercentRaw(activeSpyQuote.changePercent)}`
121
+ ? `SPY ${formatMarketPrice(activeSpyQuote.price, liveQuoteFormatOptions(spyQuote, spyQuote?.currency, "ETF"))} ${formatPercentRaw(activeSpyQuote.changePercent).padStart(6)}`
120
122
  : "SPY —";
121
123
 
122
124
  const mktCountdown = mktState ? marketStateCountdown(mktState, now) : null;
@@ -16,10 +16,10 @@ const quote: Quote = {
16
16
  };
17
17
 
18
18
  describe("formatTickerBadgeChange", () => {
19
- test("keeps one decimal and loses a pointless minus on zero", () => {
19
+ test("keeps one decimal on whole moves and loses a pointless minus on zero", () => {
20
20
  expect(formatTickerBadgeChange(1.24)).toBe("+1.2%");
21
- expect(formatTickerBadgeChange(-5)).toBe("-5%");
22
- expect(formatTickerBadgeChange(-0.01)).toBe("0%");
21
+ expect(formatTickerBadgeChange(-5)).toBe("-5.0%");
22
+ expect(formatTickerBadgeChange(-0.01)).toBe("0.0%");
23
23
  });
24
24
  });
25
25
 
@@ -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 () => {
@@ -22,7 +22,8 @@ export { Menu, MenuPopover } from "./menu";
22
22
  export type { MenuItem, MenuProps, MenuPopoverProps } from "./menu";
23
23
 
24
24
  export { MultiSelectDialogButton, MultiSelectDialogContent } from "./multi-select/dialog";
25
- export type { MultiSelectDialogButtonHandle, MultiSelectPopoverAnchorPoint } from "./multi-select/dialog";
25
+ export type { MultiSelectDialogButtonHandle, MultiSelectPopoverAnchorPoint, MultiSelectRowAction } from "./multi-select/dialog";
26
+ export { NumberPromptDialog } from "./number-prompt-dialog";
26
27
 
27
28
  export { SelectButton } from "./select-button";
28
29
  export type { SelectButtonOption, SelectButtonProps, SelectControl } from "./select-button";
@@ -37,6 +37,19 @@ import {
37
37
  type MultiSelectOption,
38
38
  } from "./index";
39
39
 
40
+ /**
41
+ * One action on the highlighted row, shown beside Done: editing a value the
42
+ * option carries, such as an indicator's period.
43
+ */
44
+ export interface MultiSelectRowAction {
45
+ label: string;
46
+ shortcut: string;
47
+ /** Whether the highlighted option offers it; `selected` is its checkbox. */
48
+ appliesTo(value: string, selected: boolean): boolean;
49
+ /** A returned label replaces the row's label while the dialog stays open. */
50
+ run(value: string): Promise<string | void> | string | void;
51
+ }
52
+
40
53
  export interface MultiSelectDialogContentProps extends AlertContext {
41
54
  title: string;
42
55
  options: MultiSelectOption[];
@@ -45,6 +58,7 @@ export interface MultiSelectDialogContentProps extends AlertContext {
45
58
  ordered?: boolean;
46
59
  emptyLabel?: string;
47
60
  idPrefix?: string;
61
+ rowAction?: MultiSelectRowAction;
48
62
  }
49
63
 
50
64
  export interface MultiSelectDialogButtonProps {
@@ -61,6 +75,8 @@ export interface MultiSelectDialogButtonProps {
61
75
  shortcutKey?: string | string[];
62
76
  shortcutActive?: boolean;
63
77
  onOpenChange?: (open: boolean) => void;
78
+ /** Opens the dialog on desktop too, since the popover menu has no row actions. */
79
+ rowAction?: MultiSelectRowAction;
64
80
  }
65
81
 
66
82
  export interface MultiSelectPopoverAnchorPoint {
@@ -197,6 +213,7 @@ export function MultiSelectDialogContent({
197
213
  onChange,
198
214
  ordered = false,
199
215
  idPrefix,
216
+ rowAction,
200
217
  }: MultiSelectDialogContentProps) {
201
218
  const isDesktopWeb = useUiHost().kind === "desktop-web";
202
219
  const optionByValue = useMemo(() => new Map(options.map((option) => [option.value, option])), [options]);
@@ -216,6 +233,11 @@ export function MultiSelectDialogContent({
216
233
  const canMoveDown = ordered
217
234
  && selectedValueOrder >= 0
218
235
  && selectedValueOrder < knownSelectedValues.length - 1;
236
+ const [labelOverrides, setLabelOverrides] = useState<Record<string, string>>({});
237
+ const canRunRowAction = !!rowAction
238
+ && !!selectedOption
239
+ && !selectedOption.disabled
240
+ && rowAction.appliesTo(selectedOption.value, selectedValues.includes(selectedOption.value));
219
241
 
220
242
  useEffect(() => {
221
243
  setSelectedValues((values) => normalizeDialogSelectedValues(options, values, ordered));
@@ -235,7 +257,7 @@ export function MultiSelectDialogContent({
235
257
 
236
258
  return {
237
259
  id: option.value,
238
- label: option.label,
260
+ label: labelOverrides[option.value] ?? option.label,
239
261
  disabled: option.disabled,
240
262
  enabled: selectedValues.includes(option.value),
241
263
  description: [option.description, orderDescription].filter((entry): entry is string => !!entry).join(" "),
@@ -278,6 +300,13 @@ export function MultiSelectDialogContent({
278
300
  );
279
301
  };
280
302
 
303
+ const runRowAction = async () => {
304
+ if (!rowAction || !canRunRowAction || !selectedOption) return;
305
+ const value = selectedOption.value;
306
+ const label = await rowAction.run(value);
307
+ if (typeof label === "string") setLabelOverrides((current) => ({ ...current, [value]: label }));
308
+ };
309
+
281
310
  useDialogKeyboard((event) => {
282
311
  event.stopPropagation();
283
312
  const move = listCursorMove(event, pageSize);
@@ -290,6 +319,8 @@ export function MultiSelectDialogContent({
290
319
  void moveOption("up").catch(() => {});
291
320
  } else if (event.name === "]" && ordered) {
292
321
  void moveOption("down").catch(() => {});
322
+ } else if (rowAction && event.name === rowAction.shortcut && !event.ctrl && !event.meta && !event.alt) {
323
+ void runRowAction().catch(() => {});
293
324
  } else if (event.name === "enter" || event.name === "return" || event.name === "escape" || isDetailBackNavigationKey(event)) {
294
325
  dismiss();
295
326
  }
@@ -331,6 +362,15 @@ export function MultiSelectDialogContent({
331
362
  <Button label="Move Down" shortcut="]" variant="ghost" disabled={!canMoveDown} onPress={() => { void moveOption("down").catch(() => {}); }} />
332
363
  </>
333
364
  )}
365
+ {rowAction && (
366
+ <Button
367
+ label={rowAction.label}
368
+ shortcut={rowAction.shortcut}
369
+ variant="ghost"
370
+ disabled={!canRunRowAction}
371
+ onPress={() => { void runRowAction().catch(() => {}); }}
372
+ />
373
+ )}
334
374
  <Button label="Done" shortcut="Enter" variant="primary" onPress={dismiss} />
335
375
  </Box>
336
376
  </Box>
@@ -352,6 +392,7 @@ function MultiSelectDialogButtonInner({
352
392
  shortcutKey,
353
393
  shortcutActive = false,
354
394
  onOpenChange,
395
+ rowAction,
355
396
  }: MultiSelectDialogButtonProps, ref: ForwardedRef<MultiSelectDialogButtonHandle>) {
356
397
  const isDesktopWeb = useUiHost().kind === "desktop-web";
357
398
  const dialog = useDialog();
@@ -364,7 +405,7 @@ function MultiSelectDialogButtonInner({
364
405
  const openDialog = useCallback((event?: DialogTriggerEvent, anchorPoint?: MultiSelectPopoverAnchorPoint) => {
365
406
  stopMouseEvent(event);
366
407
  if (disabled) return;
367
- if (isDesktopWeb && !ordered) {
408
+ if (isDesktopWeb && !ordered && !rowAction) {
368
409
  setPopoverAnchorPoint(anchorPoint ?? null);
369
410
  setPopoverOpen(true);
370
411
  onOpenChange?.(true);
@@ -383,10 +424,11 @@ function MultiSelectDialogButtonInner({
383
424
  ordered={ordered}
384
425
  emptyLabel={emptyLabel}
385
426
  idPrefix={idPrefix}
427
+ rowAction={rowAction}
386
428
  />
387
429
  ),
388
430
  }).catch(() => {}).finally(() => onOpenChange?.(false));
389
- }, [dialog, disabled, emptyLabel, idPrefix, isDesktopWeb, label, onChange, onOpenChange, options, ordered, selectedValues, title]);
431
+ }, [dialog, disabled, emptyLabel, idPrefix, isDesktopWeb, label, onChange, onOpenChange, options, ordered, rowAction, selectedValues, title]);
390
432
 
391
433
  const closePopover = useCallback(() => {
392
434
  setPopoverOpen(false);
@@ -469,7 +511,7 @@ function MultiSelectDialogButtonInner({
469
511
  );
470
512
  }
471
513
 
472
- if (isDesktopWeb && !ordered) {
514
+ if (isDesktopWeb && !ordered && !rowAction) {
473
515
  return (
474
516
  <Popover
475
517
  open={popoverOpen}
@@ -0,0 +1,71 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Box, Text, type InputRenderable } from "../../ui";
3
+ import type { PromptContext } from "../../ui/dialog";
4
+ import { useThemeColors } from "../../theme/theme-context";
5
+ import { DialogFrame } from "./frame";
6
+ import { TextField } from "./fields";
7
+
8
+ /** A whole number within [min, max], or null for anything else. */
9
+ export function parseWholeNumber(text: string, min: number, max: number): number | null {
10
+ const trimmed = text.trim();
11
+ if (!/^\d+$/.test(trimmed)) return null;
12
+ const value = Number(trimmed);
13
+ return value >= min && value <= max ? value : null;
14
+ }
15
+
16
+ export interface NumberPromptDialogProps extends PromptContext<number> {
17
+ title: string;
18
+ initialValue: number;
19
+ min: number;
20
+ max: number;
21
+ /** Shown under the field when Enter is pressed on something out of range. */
22
+ invalidMessage: string;
23
+ }
24
+
25
+ /**
26
+ * One prefilled number field. Enter keeps a valid value, Esc closes without
27
+ * one (the dialog host resolves the prompt with undefined).
28
+ */
29
+ export function NumberPromptDialog({
30
+ resolve,
31
+ title,
32
+ initialValue,
33
+ min,
34
+ max,
35
+ invalidMessage,
36
+ }: NumberPromptDialogProps) {
37
+ const colors = useThemeColors();
38
+ const inputRef = useRef<InputRenderable>(null);
39
+ const [value, setValue] = useState(String(initialValue));
40
+ const [invalid, setInvalid] = useState(false);
41
+
42
+ useEffect(() => {
43
+ inputRef.current?.focus?.();
44
+ }, []);
45
+
46
+ return (
47
+ <DialogFrame title={title}>
48
+ <Box flexDirection="column" width={32}>
49
+ <TextField
50
+ inputRef={inputRef}
51
+ value={value}
52
+ focused
53
+ onChange={(next) => {
54
+ setValue(next);
55
+ setInvalid(false);
56
+ }}
57
+ onSubmit={(submitted) => {
58
+ const parsed = parseWholeNumber(submitted, min, max);
59
+ if (parsed === null) setInvalid(true);
60
+ else resolve(parsed);
61
+ }}
62
+ />
63
+ {invalid && (
64
+ <Box height={1}>
65
+ <Text fg={colors.negative}>{invalidMessage}</Text>
66
+ </Box>
67
+ )}
68
+ </Box>
69
+ </DialogFrame>
70
+ );
71
+ }
@@ -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
+ });