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
package/bin/gloomberb CHANGED
@@ -1,3 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import "../src/cli/entry.ts"
3
+ // Bun applies patchedDependencies only to the root project, so a global
4
+ // `bun install -g gloomberb` runs react-reconciler without
5
+ // patches/react-reconciler@0.33.0.patch. Its dev build then records a
6
+ // performance.measure for every component render, which Bun keeps forever.
7
+ // The reconciler reads console.timeStamp once at load to decide, so hide it
8
+ // before React is imported.
9
+ delete console.timeStamp;
10
+
11
+ await import("../src/cli/entry.ts");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/index.tsx",
@@ -26,6 +26,9 @@ export interface CentralBankRow {
26
26
  status: "available" | "stale" | "unavailable"
27
27
  unavailableReason: "source-unavailable" | "metadata-mismatch" | "metadata-unavailable" | "no-observations" | "no-policy-rate" | "no-unified-rate" | null
28
28
  notes: string[]
29
+ /** When the bank's own site last showed the same level as a lagging BIS observation. */
30
+ confirmedAt?: string | null
31
+ confirmationSourceUrl?: string | null
29
32
  nextMeeting: { date: string; sourceUrl: string; verifiedAt: string } | null
30
33
  }
31
34
 
@@ -820,7 +820,7 @@ export interface CloudRiskReportListPayload {
820
820
  cik: string | null;
821
821
  name: string;
822
822
  shortName: string;
823
- };
823
+ } | null;
824
824
  reports: CloudRiskReportSummaryPayload[];
825
825
  }
826
826
 
@@ -172,11 +172,14 @@ export function useAppPaneRuntime({
172
172
  options?: PaneTemplateInstanceConfig,
173
173
  ) => {
174
174
  const { width, height } = pluginRegistry.getTermSizeFn();
175
+ // Templates can await ticker resolution before placing their pane, so the
176
+ // layout from the render that started the request may be out of date.
177
+ const { config: { layout }, focusedPaneId } = stateRef.current;
175
178
  const relativeTo = options?.relativeToPaneId
176
- ? resolvePaneTarget(options.relativeToPaneId)
177
- : (state.focusedPaneId && isPaneInLayout(state.config.layout, state.focusedPaneId) ? state.focusedPaneId : null);
179
+ ? resolvePaneTarget(options.relativeToPaneId, layout)
180
+ : (focusedPaneId && isPaneInLayout(layout, focusedPaneId) ? focusedPaneId : null);
178
181
  const relativePosition = options?.relativePosition ?? "right";
179
- let nextLayout = state.config.layout;
182
+ let nextLayout = layout;
180
183
  const dockedPaneIds = getDockedPaneIds(nextLayout);
181
184
 
182
185
  if (options?.placement === "floating" || (options?.placement !== "docked" && paneDef.defaultMode === "floating")) {
@@ -204,8 +207,7 @@ export function useAppPaneRuntime({
204
207
  persistLayout,
205
208
  pluginRegistry,
206
209
  resolvePaneTarget,
207
- state.config.layout,
208
- state.focusedPaneId,
210
+ stateRef,
209
211
  ]);
210
212
 
211
213
  const showPane = useCallback((paneId: string) => {
@@ -0,0 +1,89 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { act } from "react";
3
+ import { testRender } from "../../renderers/opentui/test-utils";
4
+ import { setConfigStoreHost, type ConfigStoreHost } from "../../data/config/store";
5
+ import { addPaneToLayout, getDockedPaneIds, isPaneInLayout } from "../../plugins/pane-manager";
6
+ import { appReducer, createInitialState, type AppAction, type AppState } from "../../state/app/context";
7
+ import { createDefaultConfig, createPaneInstance, type AppConfig, type BrokerInstanceConfig } from "../../types/config";
8
+ import type { BrokerAdapter } from "../../types/broker";
9
+ import type { TickerRecord } from "../../types/ticker";
10
+ import { useBrokerImportRuntime } from "./broker-import";
11
+
12
+ const saved: AppConfig[] = [];
13
+ setConfigStoreHost({
14
+ saveConfig: async (config: AppConfig) => { saved.push(config); },
15
+ } as unknown as ConfigStoreHost);
16
+
17
+ let rendered: Awaited<ReturnType<typeof testRender>> | undefined;
18
+ afterEach(async () => {
19
+ saved.length = 0;
20
+ if (rendered) await act(async () => { rendered!.renderer.destroy(); });
21
+ rendered = undefined;
22
+ });
23
+
24
+ // The startup broker sync runs while the app is already usable, and a broker
25
+ // such as IBKR Flex takes seconds to answer. It used to commit the whole config
26
+ // it had copied before asking, which closed any pane opened in the meantime.
27
+ test("the startup broker sync keeps a pane opened while the broker was answering", async () => {
28
+ const instance: BrokerInstanceConfig = { id: "demo-broker", brokerType: "demo", label: "Demo", config: {}, enabled: true };
29
+ const config = { ...createDefaultConfig("/tmp/gloomberb-broker-import-test"), brokerInstances: [instance] };
30
+ let answer!: () => void;
31
+ const answered = new Promise<void>((resolve) => { answer = resolve; });
32
+ const broker = {
33
+ id: "demo",
34
+ name: "Demo",
35
+ configSchema: [],
36
+ validate: async () => true,
37
+ listAccounts: async () => [{ accountId: "ACC-1", name: "Primary", currency: "USD" }],
38
+ importPositions: async () => { await answered; return []; },
39
+ } as unknown as BrokerAdapter;
40
+ const tickers = new Map<string, TickerRecord>();
41
+ const tickerRepository = {
42
+ loadAllTickers: async () => [...tickers.values()],
43
+ loadTicker: async (symbol: string) => tickers.get(symbol) ?? null,
44
+ saveTicker: async (ticker: TickerRecord) => { tickers.set(ticker.metadata.ticker, ticker); },
45
+ } as any;
46
+
47
+ // AppProvider's dispatch reduces into the ref synchronously.
48
+ const stateRef: { current: AppState } = { current: createInitialState(config) };
49
+ const dispatch = (action: AppAction) => { stateRef.current = appReducer(stateRef.current, action); };
50
+ let runtime!: ReturnType<typeof useBrokerImportRuntime>;
51
+ function Harness() {
52
+ runtime = useBrokerImportRuntime({
53
+ dispatch,
54
+ pluginRegistry: {
55
+ brokers: new Map([["demo", broker]]),
56
+ persistence: { resources: undefined },
57
+ events: { emit() {} },
58
+ } as any,
59
+ refreshQuote: () => {},
60
+ stateRef,
61
+ tickerRepository,
62
+ });
63
+ return <text>harness</text>;
64
+ }
65
+ rendered = await testRender(<Harness />, { width: 20, height: 2 });
66
+ await act(async () => { await rendered!.renderOnce(); });
67
+
68
+ const importing = runtime.autoImportBrokerPositions(new Map(tickers));
69
+ await new Promise((resolve) => setTimeout(resolve, 10));
70
+ const pane = createPaneInstance("news", { instanceId: "news:opened-during-sync" });
71
+ const docked = getDockedPaneIds(stateRef.current.config.layout);
72
+ dispatch({ type: "PUSH_LAYOUT_HISTORY" });
73
+ dispatch({
74
+ type: "UPDATE_LAYOUT",
75
+ layout: addPaneToLayout(stateRef.current.config.layout, pane, { relativeTo: docked[docked.length - 1]!, position: "right" }),
76
+ });
77
+ answer();
78
+ await importing;
79
+
80
+ const { config: live } = stateRef.current;
81
+ expect(isPaneInLayout(live.layout, pane.instanceId)).toBe(true);
82
+ expect(live.portfolios.some((portfolio) => portfolio.brokerInstanceId === instance.id)).toBe(true);
83
+ expect(live.brokerInstances[0]?.lastSyncedAt).toBeNumber();
84
+ expect(saved.length).toBeGreaterThan(0);
85
+ expect(saved.every((entry) => isPaneInLayout(entry.layout, pane.instanceId))).toBe(true);
86
+ // The sync leaves the layout alone, so opening the pane can still be undone.
87
+ dispatch({ type: "UNDO_LAYOUT" });
88
+ expect(isPaneInLayout(stateRef.current.config.layout, pane.instanceId)).toBe(false);
89
+ });
@@ -1,5 +1,10 @@
1
1
  import { useCallback, type Dispatch } from "react";
2
- import { restoreBrokerPortfoliosFromTickerPositions, syncBrokerInstance, syncBrokerInstances } from "../../brokers/sync-broker-instance";
2
+ import {
3
+ rebaseBrokerSyncConfig,
4
+ restoreBrokerPortfoliosFromTickerPositions,
5
+ syncBrokerInstance,
6
+ syncBrokerInstances,
7
+ } from "../../brokers/sync-broker-instance";
3
8
  import type { SyncBrokerInstanceResult } from "../../brokers/sync-broker-instance";
4
9
  import type { AppTickerRepositoryPort } from "../../core/app-service-ports";
5
10
  import type { PluginRegistry } from "../../plugins/registry";
@@ -7,6 +12,7 @@ import { saveConfigImmediately } from "../../state/config-save-scheduler";
7
12
  import type { AppAction, AppState } from "../../state/app/context";
8
13
  import type { AppConfig } from "../../types/config";
9
14
  import type { TickerRecord } from "../../types/ticker";
15
+ import { getBrokerInstance } from "../../utils/broker-instances";
10
16
 
11
17
  export interface AppBrokerImportRuntime {
12
18
  importBrokerPositions: (
@@ -49,17 +55,24 @@ export function useBrokerImportRuntime({
49
55
  instanceId: string,
50
56
  result: SyncBrokerInstanceResult,
51
57
  baseConfig: AppConfig,
52
- options?: Pick<BrokerImportOptions, "refreshImportedTickers" | "signal">,
58
+ options?: Pick<BrokerImportOptions, "refreshImportedTickers" | "signal"> & {
59
+ /** The caller passed its own draft config, so the result replaces the live one. */
60
+ callerOwnsConfig?: boolean;
61
+ },
53
62
  ) => {
54
63
  throwIfBrokerImportCancelled(options?.signal);
55
64
  dispatch({ type: "SET_BROKER_ACCOUNTS", instanceId, accounts: result.brokerAccounts });
56
65
 
57
- if (result.config !== baseConfig) {
66
+ const liveConfig = stateRef.current.config;
67
+ const nextConfig = options?.callerOwnsConfig
68
+ ? (result.config !== baseConfig ? result.config : liveConfig)
69
+ : rebaseBrokerSyncConfig(liveConfig, baseConfig, result.config, instanceId);
70
+ if (nextConfig !== liveConfig) {
58
71
  throwIfBrokerImportCancelled(options?.signal);
59
- dispatch({ type: "SET_CONFIG", config: result.config });
60
- await saveConfigImmediately(result.config);
72
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
73
+ await saveConfigImmediately(nextConfig);
61
74
  throwIfBrokerImportCancelled(options?.signal);
62
- pluginRegistry.events.emit("config:changed", { config: result.config });
75
+ pluginRegistry.events.emit("config:changed", { config: nextConfig });
63
76
  }
64
77
 
65
78
  for (const ticker of result.addedTickers) {
@@ -79,7 +92,7 @@ export function useBrokerImportRuntime({
79
92
  refreshQuote(position.ticker, position.exchange, undefined, 1);
80
93
  }
81
94
  }
82
- }, [dispatch, pluginRegistry.events, refreshQuote]);
95
+ }, [dispatch, pluginRegistry.events, refreshQuote, stateRef]);
83
96
 
84
97
  const importBrokerPositions = useCallback(async (
85
98
  instanceId: string,
@@ -99,6 +112,8 @@ export function useBrokerImportRuntime({
99
112
  deferPersistence: true,
100
113
  });
101
114
 
115
+ // A profile removed while its broker was answering stays removed, positions included.
116
+ if (!options?.config && !getBrokerInstance(stateRef.current.config.brokerInstances, instanceId)) return result;
102
117
  let commitStarted = false;
103
118
  try {
104
119
  throwIfBrokerImportCancelled(options?.signal);
@@ -106,7 +121,10 @@ export function useBrokerImportRuntime({
106
121
  commitStarted = true;
107
122
  await result.commit();
108
123
  throwIfBrokerImportCancelled(options?.signal);
109
- await applyBrokerImportResult(instanceId, result, baseConfig, options);
124
+ await applyBrokerImportResult(instanceId, result, baseConfig, {
125
+ ...options,
126
+ callerOwnsConfig: options?.config !== undefined,
127
+ });
110
128
 
111
129
  return result;
112
130
  } finally {
@@ -128,7 +146,10 @@ export function useBrokerImportRuntime({
128
146
  tickerRepository,
129
147
  existingTickers: tickerMap,
130
148
  resources: pluginRegistry.persistence.resources,
149
+ deferPersistence: true,
131
150
  onResult: async (result, instance, previousConfig) => {
151
+ if (!getBrokerInstance(stateRef.current.config.brokerInstances, instance.id)) return;
152
+ await result.commit();
132
153
  await applyBrokerImportResult(instance.id, result, previousConfig, {
133
154
  refreshImportedTickers: false,
134
155
  });
@@ -48,6 +48,8 @@ export interface SyncBrokerInstancesArgs {
48
48
  existingTickers: Map<string, TickerRecord>;
49
49
  resources?: AppResourceStorePort;
50
50
  persistResolvedBrokerConfig?: boolean;
51
+ /** Leave each result's ticker writes to `onResult`, which calls `result.commit()`. */
52
+ deferPersistence?: boolean;
51
53
  onResult?: (result: SyncBrokerInstanceResult, instance: BrokerInstanceConfig, previousConfig: AppConfig) => void | Promise<void>;
52
54
  }
53
55
 
@@ -77,6 +79,69 @@ function throwIfBrokerImportCancelled(signal?: AbortSignal): void {
77
79
  }
78
80
  }
79
81
 
82
+ /** The fields a sync changed, over an entry the user may have edited meanwhile (a rename, a disable). */
83
+ function rebaseFields<T extends object>(live: T, base: T, synced: T): T {
84
+ const next = { ...live } as Record<string, unknown>;
85
+ const before = base as Record<string, unknown>;
86
+ const after = synced as Record<string, unknown>;
87
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
88
+ if (after[key] === before[key]) continue;
89
+ if (key in after) next[key] = after[key];
90
+ else delete next[key];
91
+ }
92
+ return next as T;
93
+ }
94
+
95
+ /** Applies the entries a sync added, changed or removed (by id) onto the live list. */
96
+ function rebaseEntriesById<T extends { id: string }>(live: T[], base: T[], synced: T[]): T[] {
97
+ if (synced === base) return live;
98
+ const baseById = new Map(base.map((entry) => [entry.id, entry]));
99
+ const syncedById = new Map(synced.map((entry) => [entry.id, entry]));
100
+ const liveIds = new Set(live.map((entry) => entry.id));
101
+ let changed = false;
102
+ const next: T[] = [];
103
+ for (const entry of live) {
104
+ const before = baseById.get(entry.id);
105
+ const after = syncedById.get(entry.id);
106
+ if (before && !after) {
107
+ changed = true;
108
+ continue;
109
+ }
110
+ if (after && after !== before) {
111
+ changed = true;
112
+ next.push(before ? rebaseFields(entry, before, after) : after);
113
+ continue;
114
+ }
115
+ next.push(entry);
116
+ }
117
+ for (const entry of synced) {
118
+ if (baseById.has(entry.id) || liveIds.has(entry.id)) continue;
119
+ changed = true;
120
+ next.push(entry);
121
+ }
122
+ return changed ? next : live;
123
+ }
124
+
125
+ /**
126
+ * A broker can take seconds to answer, and the app is usable meanwhile. A sync
127
+ * only touches portfolios and broker instances, so only its changes to those
128
+ * are carried onto the live config; a pane opened or a tab switched while the
129
+ * broker was answering stays. A profile removed meanwhile stays removed.
130
+ */
131
+ export function rebaseBrokerSyncConfig(
132
+ live: AppConfig,
133
+ base: AppConfig,
134
+ synced: AppConfig,
135
+ instanceId: string,
136
+ ): AppConfig {
137
+ if (synced === base || !getBrokerInstance(live.brokerInstances, instanceId)) return live;
138
+ const portfolios = rebaseEntriesById(live.portfolios, base.portfolios, synced.portfolios);
139
+ const brokerInstances = rebaseEntriesById(live.brokerInstances, base.brokerInstances, synced.brokerInstances);
140
+ return portfolios === live.portfolios && brokerInstances === live.brokerInstances
141
+ ? live
142
+ : { ...live, portfolios, brokerInstances };
143
+ }
144
+
80
145
  async function maybePersistResolvedBrokerConfig(
81
146
  config: AppConfig,
82
147
  instance: BrokerInstanceConfig,
@@ -311,6 +376,7 @@ export async function syncBrokerInstances({
311
376
  existingTickers,
312
377
  resources,
313
378
  persistResolvedBrokerConfig = false,
379
+ deferPersistence = false,
314
380
  onResult,
315
381
  }: SyncBrokerInstancesArgs): Promise<SyncBrokerInstancesResult> {
316
382
  let nextConfig = config;
@@ -331,6 +397,7 @@ export async function syncBrokerInstances({
331
397
  existingTickers: nextTickers,
332
398
  resources,
333
399
  persistResolvedBrokerConfig,
400
+ deferPersistence,
334
401
  });
335
402
 
336
403
  nextConfig = result.config;
@@ -9,7 +9,7 @@ import {
9
9
  rankScreenerQuotes,
10
10
  type ScreenerCategory,
11
11
  } from "../../plugins/builtin/market-movers/screener";
12
- import { formatMoverPrice } from "../../plugins/builtin/market-movers/model";
12
+ import { formatMoverPrice, moverReferencePrice } from "../../plugins/builtin/market-movers/model";
13
13
  import { loadCalendar, matchesCountry, matchesImpact, type CountryFilter, type ImpactFilter } from "../../plugins/builtin/econ/calendar-model";
14
14
  import { isoDate, requireArg, takeOption } from "./command-utils";
15
15
  import { buildCorrelationSeries } from "../../plugins/builtin/correlation/matrix/model";
@@ -32,7 +32,11 @@ const BASKET_NAMES = new Map<string, string>([
32
32
  // Priced like the MOST pane. Index and yield levels (^GSPC, ^TNX) carry no currency sign.
33
33
  const PRICE_COLUMN = { key: "price", header: "Last", align: "right" as const,
34
34
  format: (value: unknown, row: Record<string, unknown>) => typeof value === "number"
35
- ? formatMoverPrice(value, typeof row.currency === "string" && !String(row.symbol ?? "").startsWith("^") ? row.currency : "") : "" };
35
+ ? formatMoverPrice(
36
+ value,
37
+ typeof row.currency === "string" && !String(row.symbol ?? "").startsWith("^") ? row.currency : "",
38
+ moverReferencePrice(row as Parameters<typeof moverReferencePrice>[0]),
39
+ ) : "" };
36
40
  const MOVER_COLUMNS = [
37
41
  { key: "symbol", header: "Symbol" },
38
42
  { key: "name", header: "Name" },
@@ -8,6 +8,8 @@ export async function smokeOpenTuiNative(): Promise<void> {
8
8
 
9
9
  export async function smokeOpenTuiRuntime(): Promise<void> {
10
10
  await import("../renderers/opentui/start");
11
+ const { assertRendersLeaveNoTimingEntries } = await import("../renderers/opentui/render-timing-smoke");
12
+ await assertRendersLeaveNoTimingEntries();
11
13
  }
12
14
 
13
15
  /**
@@ -53,6 +53,7 @@ import {
53
53
  formatCompositeCursorValue,
54
54
  formatCompositePointDetails,
55
55
  formatCompositeSeriesValue,
56
+ seriesPriceReference,
56
57
  formatCompositeTimeAxisDate,
57
58
  type CompositeAxisValueFormatter,
58
59
  } from "./format";
@@ -1450,9 +1451,27 @@ function legendValue(
1450
1451
  series: ResolvedSeries,
1451
1452
  value: number | null,
1452
1453
  formatValue: CompositeChartProps["formatValue"],
1454
+ scene: CompositeChartScene | null,
1453
1455
  ): string {
1454
1456
  if (value === null) return "—";
1455
- return formatValue ? formatValue(value, series) : formatCompositeSeriesValue(value, series);
1457
+ return formatValue ? formatValue(value, series) : formatCompositeSeriesValue(value, series, axisPriceReference(scene, series));
1458
+ }
1459
+
1460
+ /** The reference the axis holding a series keeps for its asset, so an average
1461
+ * of the price shows the price's decimals rather than its own float tail. A
1462
+ * series far from that price (another coin on a shared axis) keeps its own. */
1463
+ function axisPriceReference(scene: CompositeChartScene | null, series: ResolvedSeries): number | undefined {
1464
+ const own = seriesPriceReference(series);
1465
+ for (const panel of scene?.panels ?? []) {
1466
+ for (const domain of [panel.axes.left, panel.axes.right]) {
1467
+ if (!domain?.seriesIds.includes(series.id)) continue;
1468
+ const shared = domain.priceReferences?.[series.priceAssetCategory ?? ""];
1469
+ if (shared === undefined || own === undefined) return own ?? shared;
1470
+ const ratio = Math.abs(own / shared);
1471
+ return ratio > 0.1 && ratio < 10 ? shared : own;
1472
+ }
1473
+ }
1474
+ return own;
1456
1475
  }
1457
1476
 
1458
1477
  const LISTED_TICKER_PATTERN = /(^|[\s(])([A-Z0-9^][A-Z0-9.^=/-]*):([A-Z0-9]{2,})(?=$|[\s),])/g;
@@ -1511,6 +1530,7 @@ function CompositeLegend({
1511
1530
  entry,
1512
1531
  cursorValue?.value ?? null,
1513
1532
  formatValue,
1533
+ scene,
1514
1534
  )}${changeText}`;
1515
1535
  const fullText = [entry.label, valueText].filter(Boolean).join(" ");
1516
1536
  const details = formatCompositePointDetails(cursorValue?.point);
@@ -1,4 +1,4 @@
1
- import { formatMarketPriceWithCurrency, type MarketFormatOptions } from "../../../market-data/market/format";
1
+ import { formatMarketPriceWithCurrency, stablePriceFractionDigits, type MarketFormatOptions } from "../../../market-data/market/format";
2
2
  import type { ResolvedSeries, TimeSeriesPoint } from "../../../time-series/types";
3
3
  import type { CompositeAxisDomain, CompositePanelScene } from "./types";
4
4
 
@@ -48,12 +48,55 @@ function formatFullCurrencyValue(
48
48
  return currency ? formatMarketPriceWithCurrency(value, currency, { ...options, assetCategory }) : null;
49
49
  }
50
50
 
51
+ function pointPriceValue(point: TimeSeriesPoint | undefined): number | undefined {
52
+ const value = point && Number.isFinite(point.value) ? point.value : point?.close;
53
+ return typeof value === "number" && Number.isFinite(value) && value !== 0 ? value : undefined;
54
+ }
55
+
56
+ /**
57
+ * The value a live price label takes its decimals from: the last completed
58
+ * bar, which holds still while the live one moves and sits near today's price
59
+ * even on a ten-year chart. Only market prices have one; fundamentals such as
60
+ * EPS keep formatting each value on its own.
61
+ */
62
+ export function seriesPriceReference(series: ResolvedSeries): number | undefined {
63
+ if (series.observationKind !== "market" && series.dataShape !== "ohlcv" && series.priceAssetCategory === undefined) {
64
+ return undefined;
65
+ }
66
+ for (let index = series.points.length - 2; index >= 0; index -= 1) {
67
+ const value = pointPriceValue(series.points[index]);
68
+ if (value !== undefined) return value;
69
+ }
70
+ return pointPriceValue(series.points.at(-1));
71
+ }
72
+
73
+ /** A price label that moves with live data (the last price, the cursor, a
74
+ * legend value) keeps one decimal count for its asset, read from a reference
75
+ * price rather than the value itself: $150.10 does not become $150.1. */
76
+ function stablePriceOptions(unit: string, assetCategory: string | undefined, referencePrice: number | undefined): MarketFormatOptions {
77
+ if (referencePrice === undefined) return {};
78
+ return {
79
+ referencePrice,
80
+ fixedFractionDigits: stablePriceFractionDigits({
81
+ assetCategory,
82
+ currency: unitCurrencyCode(unit) ?? undefined,
83
+ referencePrice,
84
+ sessionPrices: [referencePrice],
85
+ }),
86
+ };
87
+ }
88
+
51
89
  /** An axis can serve several price series. Keep the most precise value the
52
90
  * shared formatter produces, rather than inheriting the first asset's rounding. */
53
- function formatAxisPriceValue(value: number, domain: CompositeAxisDomain, options: MarketFormatOptions = {}): string {
91
+ function formatAxisPriceValue(
92
+ value: number,
93
+ domain: CompositeAxisDomain,
94
+ options: MarketFormatOptions | ((category: string | undefined) => MarketFormatOptions) = {},
95
+ ): string {
54
96
  const categories = domain.priceAssetCategories?.length ? domain.priceAssetCategories : [undefined];
55
97
  return categories.reduce<string>((best, category) => {
56
- const formatted = formatFullCurrencyValue(value, domain.unit, category, options) ?? "";
98
+ const categoryOptions = typeof options === "function" ? options(category) : options;
99
+ const formatted = formatFullCurrencyValue(value, domain.unit, category, categoryOptions) ?? "";
57
100
  return formatted.length > best.length ? formatted : best;
58
101
  }, "");
59
102
  }
@@ -190,11 +233,24 @@ function compactResolution(value: number): number {
190
233
  return absolute > 0 ? 10 ** (Math.floor(Math.log10(absolute)) - 2) : 0;
191
234
  }
192
235
 
193
- export function formatCompositeSeriesValue(value: number, series: ResolvedSeries): string {
194
- return formatChartLegendValue(value, series.unit, series.unitGroup, series.priceAssetCategory);
236
+ /** `referencePrice` defaults to the series' own; a study on a price axis
237
+ * passes the axis's reference so it shares the price's decimals. */
238
+ export function formatCompositeSeriesValue(
239
+ value: number,
240
+ series: ResolvedSeries,
241
+ referencePrice = seriesPriceReference(series),
242
+ ): string {
243
+ return formatChartLegendValue(value, series.unit, series.unitGroup, series.priceAssetCategory, referencePrice);
195
244
  }
196
245
 
197
- export function formatChartLegendValue(value: number, unit: string, unitGroup = "", assetCategory?: string): string {
246
+ export function formatChartLegendValue(
247
+ value: number,
248
+ unit: string,
249
+ unitGroup = "",
250
+ assetCategory?: string,
251
+ /** Set for a live series: its price keeps the decimals this reference calls for. */
252
+ referencePrice?: number,
253
+ ): string {
198
254
  const trimmed = unit.trim();
199
255
  const group = unitGroup.toLowerCase();
200
256
  const compact = compactNumber(value);
@@ -212,7 +268,12 @@ export function formatChartLegendValue(value: number, unit: string, unitGroup =
212
268
  return prefix ? `${value < 0 ? "-" : ""}${prefix}${amount}` : `${amount} ${trimmed}`.trim();
213
269
  }
214
270
  }
215
- const fullPrice = formatFullCurrencyValue(value, trimmed, assetCategory);
271
+ const fullPrice = formatFullCurrencyValue(
272
+ value,
273
+ trimmed,
274
+ assetCategory,
275
+ referencePrice === undefined ? {} : stablePriceOptions(trimmed, assetCategory, referencePrice),
276
+ );
216
277
  if (fullPrice) return fullPrice;
217
278
  return trimmed && trimmed.length <= 6 ? `${compact}${trimmed.startsWith("/") ? "" : " "}${trimmed}` : compact;
218
279
  }
@@ -267,7 +328,9 @@ export function formatCompositeCursorValue(value: number, domain: CompositeAxisD
267
328
  if (group.split(":")[0] === "currency-total") {
268
329
  return formatChartLegendValue(value, domain.unit, domain.unitGroup);
269
330
  }
270
- const fullPrice = formatAxisPriceValue(value, domain);
331
+ const fullPrice = formatAxisPriceValue(value, domain, (category) => (
332
+ stablePriceOptions(domain.unit, category, domain.priceReferences?.[category ?? ""])
333
+ ));
271
334
  if (fullPrice) return fullPrice;
272
335
  return formatCompositeAxisValue(value, domain);
273
336
  }
@@ -6,9 +6,10 @@ import { buildPriceChartPreset } from "../../../plugins/builtin/chart-composer/p
6
6
  import { formatMarketPriceWithCurrency } from "../../../market-data/market/format";
7
7
  import { applyResolvedSeriesTransform } from "../../../time-series/transforms";
8
8
  import type { HeadlessPaneContext } from "../../../types/headless";
9
- import { formatCompositeCursorValue, formatCompositeSeriesValue } from "./format";
9
+ import { formatCompositeCursorValue, formatCompositeSeriesValue, seriesPriceReference } from "./format";
10
10
  import { applyCompositeChartCursor, buildCompositeChartScene, resolveCompositeCursorDate } from "./scene";
11
11
  import { reuseResolvedSeriesIdentity } from "./panel-series";
12
+ import { pricePointsToResolvedSeries } from "./price-series";
12
13
 
13
14
  const cases = [
14
15
  ["EURUSD=X", "USD", "CURRENCY", 1.1602274179458618, "$1.160227"],
@@ -26,7 +27,9 @@ const cases = [
26
27
 
27
28
  async function priceModel(symbol: string, currency: string, instrumentType: string, value: number) {
28
29
  const step = Math.min(0.0001, value / 10);
29
- const points = [0, 1, 2].map((offset) => ({ date: new Date(Date.UTC(2026, 8, 9 + offset)), close: value + (offset - 2) * step }));
30
+ // The last completed bar closed where the quote is: a live label takes its
31
+ // decimals from that close, which a real feed writes like the quote.
32
+ const points = [value + step, value, value].map((close, offset) => ({ date: new Date(Date.UTC(2026, 8, 9 + offset)), close }));
30
33
  const spec = buildPriceChartPreset(symbol);
31
34
  spec.viewport = { ...spec.viewport, range: "1M", resolution: "1d", dateWindow: { start: "2026-09-09", end: "2026-09-11" } };
32
35
  spec.studies = [{ id: "sma", kind: "sma", inputSeriesIds: [spec.series[0]!.id], parameters: { period: 2 }, panelId: "main", axis: "left" }];
@@ -64,7 +67,9 @@ for (const [symbol, currency, category, value, expected] of cases) {
64
67
  expect(formatCompositeCursorValue(cursor.value!, panel.axes[series.axis]!)).toBe(expected);
65
68
  const average = model.chart.series.find((entry) => entry.id === "sma")!;
66
69
  const averageValue = average.points.at(-1)!.value!;
67
- expect(formatCompositeSeriesValue(averageValue, average)).toBe(formatMarketPriceWithCurrency(averageValue, currency, { assetCategory: category }));
70
+ // On the price's axis, a study of the price is written like the price.
71
+ expect(formatCompositeSeriesValue(averageValue, average, seriesPriceReference(series)))
72
+ .toBe(formatCompositeSeriesValue(averageValue, series));
68
73
  });
69
74
  }
70
75
 
@@ -91,3 +96,16 @@ test("FX metadata does not turn normalized returns or ratios into currency price
91
96
  expect(formatCompositeSeriesValue(latest.value!, percent)).toEndWith("%");
92
97
  expect(formatCompositeSeriesValue(1.25, { ...fx, unit: "x", unitGroup: "ratio" })).toBe("1.25x");
93
98
  });
99
+
100
+ // Live labels read their decimals from the last completed bar, not the start
101
+ // of the window: a ten-year chart that began under $1 still prints cents.
102
+ test("a long-range chart labels today's price at today's precision", () => {
103
+ const closes = [0.0376, 12.5, 180.07, 180.12];
104
+ const series = pricePointsToResolvedSeries(
105
+ closes.map((close, day) => ({ date: new Date(Date.UTC(2016 + day * 3, 0, 1)), close })),
106
+ { id: "nvda", label: "NVDA", color: "#fff", unit: "USD" },
107
+ );
108
+ expect(formatCompositeSeriesValue(180.12, series)).toBe("$180.12");
109
+ const earnings = { ...series, dataShape: "scalar" as const, unitGroup: "currency-per-share" };
110
+ expect(formatCompositeSeriesValue(2.35, earnings)).toBe("$2.35");
111
+ });
@@ -20,7 +20,7 @@ import {
20
20
  projectCompositeTimestamp,
21
21
  unprojectCompositeTimestamp,
22
22
  } from "./time-scale";
23
- import { compositeAxisMaxTicks } from "./format";
23
+ import { compositeAxisMaxTicks, seriesPriceReference } from "./format";
24
24
  import type { CompositeLastPriceMarker, CompositeTimeScale } from "./types";
25
25
 
26
26
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -290,6 +290,17 @@ function signBounded(rawMin: number, rawMax: number, padded: { min: number; max:
290
290
  };
291
291
  }
292
292
 
293
+ function axisPriceReferences(axisSeries: ResolvedSeries[]): Record<string, number> {
294
+ const references: Record<string, number> = {};
295
+ for (const entry of axisSeries) {
296
+ const key = entry.priceAssetCategory ?? "";
297
+ if (key in references) continue;
298
+ const value = seriesPriceReference(entry);
299
+ if (value !== undefined) references[key] = value;
300
+ }
301
+ return references;
302
+ }
303
+
293
304
  function buildAxisDomain(
294
305
  side: CompositeAxisSide,
295
306
  series: ResolvedSeries[],
@@ -308,6 +319,7 @@ function buildAxisDomain(
308
319
  unit: first.unit,
309
320
  unitGroup: first.unitGroup,
310
321
  priceAssetCategories: [...new Set(axisSeries.flatMap((entry) => entry.priceAssetCategory ? [entry.priceAssetCategory] : []))],
322
+ priceReferences: axisPriceReferences(axisSeries),
311
323
  seriesIds: axisSeries.map((entry) => entry.id),
312
324
  maxTicks: compositeAxisMaxTicks(rows),
313
325
  tickRows: rows,
@@ -26,6 +26,10 @@ export interface CompositeAxisDomain {
26
26
  unitGroup: string;
27
27
  /** Each source's price category, retained when different assets share an axis. */
28
28
  priceAssetCategories?: string[];
29
+ /** The reference price of the axis's first market series of each price
30
+ * category (keyed by category, "" for none). It sets the decimals of the
31
+ * cursor and last-price labels, so they do not follow the live value. */
32
+ priceReferences?: Record<string, number>;
29
33
  seriesIds: string[];
30
34
  /** Most labeled ticks the axis shows, sized to the panel height. */
31
35
  maxTicks?: number;