gloomberb 0.10.2 → 0.10.4

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 (164) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +2 -2
  3. package/package.json +2 -1
  4. package/src/api-client/auth.ts +8 -0
  5. package/src/api-client/index.ts +70 -0
  6. package/src/api-client/types.ts +49 -0
  7. package/src/app/runtime/desktop-deeplink.test.ts +8 -0
  8. package/src/app/runtime/desktop-deeplink.ts +14 -1
  9. package/src/components/chart/composite/composite-chart.test.tsx +340 -17
  10. package/src/components/chart/composite/composite-chart.tsx +859 -68
  11. package/src/components/chart/composite/interactions.test.ts +78 -22
  12. package/src/components/chart/composite/interactions.ts +45 -17
  13. package/src/components/chart/composite/rasterizer.ts +8 -2
  14. package/src/components/chart/composite/renderers.test.ts +61 -1
  15. package/src/components/chart/composite/scene.ts +14 -4
  16. package/src/components/chart/composite/tools.test.ts +371 -0
  17. package/src/components/chart/composite/tools.ts +508 -0
  18. package/src/components/chart/composite/types.ts +4 -0
  19. package/src/components/chart/core/pointer.ts +2 -0
  20. package/src/components/chart/native/raster/primitives.ts +18 -0
  21. package/src/components/chart/price-axis-labels.tsx +42 -2
  22. package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
  23. package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
  24. package/src/components/command-bar/action-runtime.ts +3 -0
  25. package/src/components/command-bar/assist/inventory.test.ts +93 -0
  26. package/src/components/command-bar/assist/inventory.ts +92 -0
  27. package/src/components/command-bar/assist/model.test.ts +104 -0
  28. package/src/components/command-bar/assist/model.ts +169 -0
  29. package/src/components/command-bar/assist/runtime.ts +185 -0
  30. package/src/components/command-bar/commands/direct/index.ts +1 -1
  31. package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
  32. package/src/components/command-bar/list/model.ts +15 -2
  33. package/src/components/command-bar/list/navigation.ts +6 -0
  34. package/src/components/command-bar/list/view.tsx +16 -3
  35. package/src/components/command-bar/pane-templates/workflow.ts +1 -0
  36. package/src/components/command-bar/panel/body.tsx +2 -0
  37. package/src/components/command-bar/panel/index.tsx +3 -0
  38. package/src/components/command-bar/panel/runtime.ts +6 -0
  39. package/src/components/command-bar/panel/types.ts +1 -0
  40. package/src/components/command-bar/routes/root/results.ts +28 -2
  41. package/src/components/command-bar/routes/root/runtime.ts +66 -36
  42. package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
  43. package/src/components/command-bar/routing/navigation-state.ts +17 -0
  44. package/src/components/command-bar/selection-runtime.ts +26 -0
  45. package/src/components/command-bar/surface/assist.test.tsx +276 -0
  46. package/src/components/command-bar/surface/index.tsx +93 -2
  47. package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
  48. package/src/components/command-bar/view-model.ts +7 -1
  49. package/src/components/command-bar/workflow/broker.test.ts +22 -0
  50. package/src/components/command-bar/workflow/broker.ts +1 -0
  51. package/src/components/index.ts +1 -1
  52. package/src/components/layout/detached-pane-shell.tsx +40 -2
  53. package/src/components/layout/floating-pane.tsx +4 -1
  54. package/src/components/layout/pane/header.tsx +64 -6
  55. package/src/components/layout/pane/index.tsx +4 -1
  56. package/src/components/layout/shell/index.tsx +23 -0
  57. package/src/components/layout/shell/pane/layers.tsx +5 -0
  58. package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
  59. package/src/components/onboarding/account-step/form-panel.tsx +157 -0
  60. package/src/components/onboarding/account-step/index.tsx +58 -0
  61. package/src/components/onboarding/account-step/model.test.ts +67 -0
  62. package/src/components/onboarding/account-step/model.ts +126 -0
  63. package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
  64. package/src/components/onboarding/onboarding-steps.tsx +91 -55
  65. package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
  66. package/src/components/onboarding/onboarding-wizard.tsx +93 -9
  67. package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
  68. package/src/components/onboarding/wizard-account.ts +235 -0
  69. package/src/components/onboarding/wizard-keyboard.ts +67 -1
  70. package/src/components/onboarding/wizard-model.ts +2 -2
  71. package/src/components/ui/data-table/index.tsx +1 -0
  72. package/src/components/ui/data-table/opentui/index.tsx +44 -2
  73. package/src/components/ui/data-table/types.ts +10 -0
  74. package/src/components/ui/data-table/visible-range.test.ts +29 -0
  75. package/src/components/ui/data-table/visible-range.ts +21 -0
  76. package/src/components/ui/ui.test.tsx +23 -1
  77. package/src/i18n/es.ts +1093 -0
  78. package/src/i18n/index.test.ts +5 -2
  79. package/src/i18n/index.ts +3 -0
  80. package/src/i18n/ja.ts +66 -3
  81. package/src/i18n/ko.ts +66 -3
  82. package/src/i18n/languages.test.ts +4 -1
  83. package/src/i18n/languages.ts +12 -1
  84. package/src/i18n/zh-cn.ts +66 -3
  85. package/src/i18n/zh-tw.ts +66 -3
  86. package/src/market-data/coordinator/index.ts +1 -1
  87. package/src/market-data/coordinator/quotes.ts +64 -1
  88. package/src/market-data/coordinator/subscriptions.test.ts +72 -0
  89. package/src/market-data/quotes/cadence.ts +2 -0
  90. package/src/plugins/builtin/account-management/footer.ts +9 -3
  91. package/src/plugins/builtin/account-management/model.ts +53 -1
  92. package/src/plugins/builtin/account-management/pane.tsx +107 -24
  93. package/src/plugins/builtin/ai/index.tsx +7 -2
  94. package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
  95. package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
  96. package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
  97. package/src/plugins/builtin/chart-composer/index.tsx +10 -2
  98. package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
  99. package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
  100. package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
  101. package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
  102. package/src/plugins/builtin/cloud/plugin.tsx +9 -1
  103. package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
  104. package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
  105. package/src/plugins/builtin/help/index.tsx +38 -0
  106. package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
  107. package/src/plugins/builtin/options/footer.test.ts +20 -112
  108. package/src/plugins/builtin/options/footer.ts +35 -78
  109. package/src/plugins/builtin/options/index.tsx +6 -0
  110. package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
  111. package/src/plugins/builtin/options/live-quotes.ts +93 -39
  112. package/src/plugins/builtin/options/view.test.tsx +5 -2
  113. package/src/plugins/builtin/options/view.tsx +49 -9
  114. package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
  115. package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
  116. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
  117. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
  118. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
  119. package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
  120. package/src/plugins/builtin/shared/live-streaming.ts +60 -0
  121. package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
  122. package/src/plugins/builtin/shared/plan-access.ts +109 -0
  123. package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
  124. package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
  125. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
  126. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
  127. package/src/plugins/registry/index.test.ts +51 -0
  128. package/src/plugins/registry/index.ts +12 -0
  129. package/src/plugins/registry/pane-settings.ts +29 -0
  130. package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
  131. package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
  132. package/src/renderers/electrobun/view/host/box.tsx +3 -0
  133. package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
  134. package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
  135. package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
  136. package/src/renderers/electrobun/view/host/input.tsx +16 -1
  137. package/src/renderers/electrobun/view/host/mouse.ts +1 -0
  138. package/src/renderers/electrobun/view/host/style.ts +1 -1
  139. package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
  140. package/src/renderers/electrobun/view/main.tsx +2 -0
  141. package/src/renderers/opentui/chart-surface.tsx +2 -0
  142. package/src/renderers/opentui/host.tsx +3 -0
  143. package/src/sources/gloomberb-cloud/index.test.ts +8 -0
  144. package/src/sources/gloomberb-cloud/index.ts +4 -4
  145. package/src/sources/provider-router/history.test.ts +3 -3
  146. package/src/sources/provider-router/history.ts +15 -4
  147. package/src/sources/yahoo-finance/history.test.ts +47 -0
  148. package/src/sources/yahoo-finance/history.ts +5 -1
  149. package/src/state/hooks/quote-streaming.test.tsx +181 -1
  150. package/src/state/hooks/quote-streaming.ts +93 -7
  151. package/src/theme/colors.ts +20 -1
  152. package/src/theme/themes.test.ts +3 -0
  153. package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
  154. package/src/time-series/resolution.test.ts +51 -0
  155. package/src/time-series/resolution.ts +41 -0
  156. package/src/time-series/resolve.test.ts +207 -1
  157. package/src/time-series/resolve.ts +196 -29
  158. package/src/time-series/spec-studies.test.ts +4 -0
  159. package/src/time-series/spec.ts +15 -1
  160. package/src/time-series/types.ts +7 -0
  161. package/src/time-series/use-chart-resolution.test.tsx +37 -0
  162. package/src/time-series/use-chart-resolution.ts +17 -1
  163. package/src/types/plugin.ts +9 -0
  164. package/src/ui/host.tsx +17 -0
@@ -7,14 +7,11 @@ import type { OptionTableRow } from "./types";
7
7
  export const OPTIONS_QUOTE_EXCHANGE = "OPTIONS";
8
8
  export const OPTIONS_CHAIN_REFRESH_INTERVAL_MS = 10 * 60_000;
9
9
  export const OPTIONS_STREAM_FRESHNESS_MS = 2 * 60_000;
10
+ export const OPTIONS_STREAM_CONNECTING_GRACE_MS = 15_000;
10
11
 
11
- const OPTIONS_STREAM_MAX_TARGETS = 16;
12
- const OPTIONS_STREAM_MAX_ROWS = OPTIONS_STREAM_MAX_TARGETS / 2;
13
12
  const OPTIONS_STREAM_OVERSCAN_ROWS = 4;
14
13
 
15
14
  export interface OptionsQuoteFreshness {
16
- chainAsOf?: string;
17
- chainDataSource?: "live" | "delayed";
18
15
  now: number;
19
16
  subscriptionStartedAt: number;
20
17
  }
@@ -23,35 +20,64 @@ function isFiniteNumber(value: number | undefined): value is number {
23
20
  return typeof value === "number" && Number.isFinite(value);
24
21
  }
25
22
 
26
- function optionStreamRowCount(height: number): number {
27
- const visibleRows = Math.max(1, Math.floor(height) - 3);
28
- return Math.min(
29
- OPTIONS_STREAM_MAX_ROWS,
30
- visibleRows + OPTIONS_STREAM_OVERSCAN_ROWS,
31
- );
23
+ export interface OptionQuoteTargetWindow {
24
+ fallbackHeight: number;
25
+ selectedIndex: number;
26
+ visibleRange: { start: number; end: number } | null;
27
+ }
28
+
29
+ export type OptionQuoteCoverageStatus = "live" | "mixed" | "connecting" | "delayed";
30
+
31
+ export interface OptionQuoteCoverage {
32
+ fallbackCount: number;
33
+ liveCount: number;
34
+ status: OptionQuoteCoverageStatus;
35
+ totalCount: number;
32
36
  }
33
37
 
34
- function optionStreamWindow(totalRows: number, selectedIndex: number, height: number): [number, number] {
35
- const count = Math.min(totalRows, optionStreamRowCount(height));
38
+ function fallbackVisibleRange(
39
+ totalRows: number,
40
+ selectedIndex: number,
41
+ height: number,
42
+ ): { start: number; end: number } {
43
+ const count = Math.min(totalRows, Math.max(1, Math.floor(height) - 3));
36
44
  const clampedIndex = Math.max(0, Math.min(selectedIndex, Math.max(0, totalRows - 1)));
37
45
  const start = Math.max(0, Math.min(clampedIndex - Math.floor(count / 2), totalRows - count));
38
- return [start, start + count];
46
+ return { start, end: start + count };
47
+ }
48
+
49
+ function normalizeVisibleRange(
50
+ totalRows: number,
51
+ window: OptionQuoteTargetWindow,
52
+ ): { start: number; end: number } {
53
+ if (!window.visibleRange) {
54
+ return fallbackVisibleRange(totalRows, window.selectedIndex, window.fallbackHeight);
55
+ }
56
+ const start = Math.max(0, Math.min(totalRows, Math.floor(window.visibleRange.start)));
57
+ const end = Math.max(start, Math.min(totalRows, Math.ceil(window.visibleRange.end)));
58
+ return { start, end };
39
59
  }
40
60
 
41
61
  export function buildOptionQuoteTargets(
42
62
  rows: readonly OptionTableRow[],
43
- selectedIndex: number,
44
- height: number,
63
+ window: OptionQuoteTargetWindow,
45
64
  ): QuoteSubscriptionTarget[] {
46
65
  if (rows.length === 0) return [];
47
- const [start, end] = optionStreamWindow(rows.length, selectedIndex, height);
66
+ const visibleRange = normalizeVisibleRange(rows.length, window);
67
+ const start = Math.max(0, visibleRange.start - OPTIONS_STREAM_OVERSCAN_ROWS);
68
+ const end = Math.min(rows.length, visibleRange.end + OPTIONS_STREAM_OVERSCAN_ROWS);
48
69
  const targets = new Map<string, QuoteSubscriptionTarget>();
49
70
 
50
- for (let index = start; index < end; index += 1) {
71
+ const addRow = (index: number) => {
51
72
  const row = rows[index];
52
- if (!row) continue;
53
- const selected = index === selectedIndex;
54
- const distance = Math.abs(index - selectedIndex);
73
+ if (!row) return;
74
+ const selected = index === window.selectedIndex;
75
+ const visible = index >= visibleRange.start && index < visibleRange.end;
76
+ const distance = visible
77
+ ? 0
78
+ : index < visibleRange.start
79
+ ? visibleRange.start - index
80
+ : index - visibleRange.end + 1;
55
81
  for (const contract of [row.call, row.put]) {
56
82
  const symbol = contract?.contractSymbol.trim().toUpperCase();
57
83
  if (!symbol) continue;
@@ -59,11 +85,18 @@ export function buildOptionQuoteTargets(
59
85
  symbol,
60
86
  exchange: OPTIONS_QUOTE_EXCHANGE,
61
87
  surface: "options",
62
- visible: true,
88
+ visible,
63
89
  selected,
64
- weight: Math.max(50, 100 - distance),
90
+ weight: selected ? 100 : visible ? 80 : Math.max(50, 70 - distance),
65
91
  });
66
92
  }
93
+ };
94
+
95
+ for (let index = start; index < end; index += 1) {
96
+ addRow(index);
97
+ }
98
+ if (window.selectedIndex < start || window.selectedIndex >= end) {
99
+ addRow(window.selectedIndex);
67
100
  }
68
101
 
69
102
  return [...targets.values()];
@@ -99,21 +132,11 @@ function freshOptionQuote(entry: QueryEntry<Quote> | undefined, freshness: Optio
99
132
  const receivedAt = quote.receivedAt ?? 0;
100
133
  if (
101
134
  !Number.isFinite(receivedAt) ||
102
- receivedAt <= freshness.subscriptionStartedAt ||
135
+ receivedAt < freshness.subscriptionStartedAt ||
103
136
  freshness.now - receivedAt > OPTIONS_STREAM_FRESHNESS_MS
104
137
  ) {
105
138
  return null;
106
139
  }
107
- const chainAsOf = freshness.chainAsOf ? Date.parse(freshness.chainAsOf) : Number.NaN;
108
- if (
109
- freshness.chainDataSource === "live" &&
110
- quote.dataSource === "live" &&
111
- Number.isFinite(chainAsOf) &&
112
- Number.isFinite(quote.lastUpdated) &&
113
- quote.lastUpdated < chainAsOf
114
- ) {
115
- return null;
116
- }
117
140
  return quote;
118
141
  }
119
142
 
@@ -135,13 +158,44 @@ export function overlayOptionRowQuotes(
135
158
  }));
136
159
  }
137
160
 
138
- export function hasLiveOptionQuote(
161
+ export function resolveOptionQuoteCoverage(
162
+ targets: readonly QuoteSubscriptionTarget[],
139
163
  quoteEntries: ReadonlyMap<string, QueryEntry<Quote>>,
140
164
  freshness: OptionsQuoteFreshness,
141
- ): boolean {
142
- for (const entry of quoteEntries.values()) {
143
- const quote = freshOptionQuote(entry, freshness);
144
- if (quote?.dataSource === "live" && quote.delivery === "stream") return true;
165
+ ): OptionQuoteCoverage {
166
+ const visibleSymbols = new Set(
167
+ targets
168
+ .filter((target) => target.visible === true)
169
+ .map((target) => target.symbol.trim().toUpperCase())
170
+ .filter(Boolean),
171
+ );
172
+ let liveCount = 0;
173
+ let fallbackCount = 0;
174
+ for (const symbol of visibleSymbols) {
175
+ const quote = freshOptionQuote(
176
+ quoteEntries.get(buildOptionQuoteKey(symbol)),
177
+ freshness,
178
+ );
179
+ if (
180
+ quote?.dataSource === "live"
181
+ && quote.delivery === "stream"
182
+ && quote.stale === false
183
+ ) {
184
+ liveCount += 1;
185
+ } else if (quote) {
186
+ fallbackCount += 1;
187
+ }
145
188
  }
146
- return false;
189
+
190
+ const totalCount = visibleSymbols.size;
191
+ const status: OptionQuoteCoverageStatus = totalCount > 0 && liveCount === totalCount
192
+ ? "live"
193
+ : liveCount > 0
194
+ ? "mixed"
195
+ : fallbackCount === 0
196
+ && totalCount > 0
197
+ && freshness.now - freshness.subscriptionStartedAt < OPTIONS_STREAM_CONNECTING_GRACE_MS
198
+ ? "connecting"
199
+ : "delayed";
200
+ return { fallbackCount, liveCount, status, totalCount };
147
201
  }
@@ -164,7 +164,7 @@ test("defaults the table around the nearest strike to the current quote", async
164
164
  expect(frame).not.toContain(" 50 ");
165
165
  });
166
166
 
167
- test("streams a bounded options window and overlays live contract quotes", async () => {
167
+ test("streams every visible option row and overlays live contract quotes", async () => {
168
168
  const strikes = Array.from({ length: 100 }, (_, index) => 50 + index);
169
169
  const subscriptions: QuoteSubscriptionTarget[][] = [];
170
170
  let emitQuote:
@@ -193,8 +193,9 @@ test("streams a bounded options window and overlays live contract quotes", async
193
193
  await renderSettled();
194
194
 
195
195
  const targets = subscriptions.at(-1) ?? [];
196
- expect(targets.length).toBeGreaterThan(0);
196
+ expect(targets.length).toBeGreaterThan(16);
197
197
  expect(targets.length).toBeLessThanOrEqual(80);
198
+ expect(targets.filter((target) => target.visible === true).length).toBeGreaterThan(16);
198
199
  expect(
199
200
  targets.every(
200
201
  (target) => target.exchange === "OPTIONS" && target.surface === "options",
@@ -218,6 +219,8 @@ test("streams a bounded options window and overlays live contract quotes", async
218
219
  changePercent: 0,
219
220
  lastUpdated: Date.now(),
220
221
  dataSource: "live",
222
+ delivery: "stream",
223
+ stale: false,
221
224
  });
222
225
  await Promise.resolve();
223
226
  await testSetup!.renderOnce();
@@ -5,7 +5,13 @@ import { colors } from "../../../theme/colors";
5
5
  import { isPlainKey } from "../../../utils/keyboard";
6
6
  import { formatExpDate, resolveOptionsTarget } from "../../../utils/options";
7
7
  import { useOptionsQuery, useResolvedEntryValue } from "../../../market-data/hooks";
8
- import { DataTableView, Spinner, Tabs, type DataTableKeyEvent } from "../../../components";
8
+ import {
9
+ DataTableView,
10
+ Spinner,
11
+ Tabs,
12
+ type DataTableKeyEvent,
13
+ type DataTableVisibleRange,
14
+ } from "../../../components";
9
15
  import { useShortcut } from "../../../react/input";
10
16
  import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming";
11
17
  import {
@@ -19,18 +25,24 @@ import {
19
25
  import type { OptionColumn, OptionTableRow, OptionsViewProps } from "./types";
20
26
  import {
21
27
  buildOptionQuoteTargets,
22
- hasLiveOptionQuote,
23
28
  OPTIONS_CHAIN_REFRESH_INTERVAL_MS,
24
29
  overlayOptionRowQuotes,
30
+ resolveOptionQuoteCoverage,
25
31
  } from "./live-quotes";
26
32
  import { useOptionsAccessFooter } from "./footer";
33
+ import { useLiveStreamingSetting } from "../shared/live-streaming";
27
34
 
28
35
  export function OptionsView({ width, height, focused, onCapture = () => {} }: OptionsViewProps) {
29
36
  const { ticker, financials } = usePaneTicker();
37
+ const liveStreaming = useLiveStreamingSetting();
30
38
  const [expIdx, setExpIdx] = useState(0);
31
39
  const [strikeIdx, setStrikeIdx] = useState(0);
32
40
  const [autoScrollVersion, setAutoScrollVersion] = useState(0);
33
41
  const [scrollToIndexAlign, setScrollToIndexAlign] = useState<"nearest" | "center">("nearest");
42
+ const [visibleStrikeViewport, setVisibleStrikeViewport] = useState<{
43
+ key: string;
44
+ range: DataTableVisibleRange;
45
+ } | null>(null);
34
46
  const [interactive, setInteractive] = useState(false);
35
47
  const userSelectedStrikeRef = useRef(false);
36
48
  const onCaptureRef = useRef(onCapture);
@@ -54,6 +66,7 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
54
66
  const initialChainEntry = useOptionsQuery(baseRequest);
55
67
  const initialChain = useResolvedEntryValue(initialChainEntry);
56
68
  const selectedExpiration = initialChain?.expirationDates[expIdx];
69
+ const viewportKey = `${effectiveTicker}:${selectedExpiration ?? "initial"}`;
57
70
  const expirationChainEntry = useOptionsQuery(
58
71
  baseRequest && selectedExpiration != null
59
72
  ? { ...baseRequest, expirationDate: selectedExpiration }
@@ -131,28 +144,53 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
131
144
  put: putsByStrike.get(strike),
132
145
  isPositionStrike: !!parsed && Math.abs(strike - parsed.strike) < 0.01,
133
146
  })), [callsByStrike, parsed, putsByStrike, strikes]);
147
+ const visibleStrikeRange = visibleStrikeViewport?.key === viewportKey
148
+ ? visibleStrikeViewport.range
149
+ : null;
150
+ const handleVisibleStrikeRangeChange = useCallback((range: DataTableVisibleRange) => {
151
+ setVisibleStrikeViewport((current) => (
152
+ current?.key === viewportKey
153
+ && current.range.start === range.start
154
+ && current.range.end === range.end
155
+ ? current
156
+ : { key: viewportKey, range }
157
+ ));
158
+ }, [viewportKey]);
134
159
  const optionQuoteTargets = useMemo(
135
- () => buildOptionQuoteTargets(snapshotRows, strikeIdx, height),
136
- [height, snapshotRows, strikeIdx],
160
+ () => buildOptionQuoteTargets(snapshotRows, {
161
+ fallbackHeight: height,
162
+ selectedIndex: strikeIdx,
163
+ visibleRange: visibleStrikeRange,
164
+ }),
165
+ [height, snapshotRows, strikeIdx, visibleStrikeRange],
137
166
  );
138
167
  const {
139
168
  entries: optionQuoteEntries,
140
169
  freshnessNow,
141
170
  subscriptionStartedAt,
142
- } = useLiveQuoteEntries(optionQuoteTargets);
171
+ } = useLiveQuoteEntries(optionQuoteTargets, {
172
+ freshnessScopeKey: viewportKey,
173
+ liveStreaming,
174
+ });
143
175
  const optionQuoteFreshness = useMemo(
144
176
  () => ({
145
- chainAsOf: chain?.asOf,
146
- chainDataSource: chain?.dataSource,
147
177
  now: freshnessNow,
148
178
  subscriptionStartedAt,
149
179
  }),
150
- [chain?.asOf, chain?.dataSource, freshnessNow, subscriptionStartedAt],
180
+ [freshnessNow, subscriptionStartedAt],
151
181
  );
152
182
  const rows = useMemo(
153
183
  () => overlayOptionRowQuotes(snapshotRows, optionQuoteEntries, optionQuoteFreshness),
154
184
  [optionQuoteEntries, optionQuoteFreshness, snapshotRows],
155
185
  );
186
+ const optionQuoteCoverage = useMemo(
187
+ () => resolveOptionQuoteCoverage(
188
+ optionQuoteTargets,
189
+ optionQuoteEntries,
190
+ optionQuoteFreshness,
191
+ ),
192
+ [optionQuoteEntries, optionQuoteFreshness, optionQuoteTargets],
193
+ );
156
194
  const optionColumns: OptionColumn[] = OPTION_COLUMNS.map((column) => ({
157
195
  ...column,
158
196
  headerColor: optionColumnColor(column.id, colors.panel),
@@ -162,8 +200,8 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
162
200
  chain,
163
201
  error,
164
202
  focused,
165
- hasLiveQuote: hasLiveOptionQuote(optionQuoteEntries, optionQuoteFreshness),
166
203
  loading,
204
+ quoteCoverage: optionQuoteCoverage,
167
205
  });
168
206
 
169
207
  useEffect(() => {
@@ -333,6 +371,8 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
333
371
  sortDirection="asc"
334
372
  onHeaderClick={() => {}}
335
373
  onTableMouseDown={enterInteractive}
374
+ visibleRangeKey={viewportKey}
375
+ onVisibleRangeChange={handleVisibleStrikeRangeChange}
336
376
  getItemKey={(row) => String(row.strike)}
337
377
  renderCell={renderOptionCell}
338
378
  emptyStateTitle="No strikes available."
@@ -9,6 +9,10 @@ import {
9
9
  } from "./settings";
10
10
  import { portfolioCliCommand } from "./cli/portfolio-command";
11
11
  import { watchlistCliCommand } from "./cli/watchlist-command";
12
+ import {
13
+ LIVE_STREAMING_QUICK_SETTING,
14
+ withLiveStreamingSetting,
15
+ } from "../shared/live-streaming";
12
16
 
13
17
  export { shouldToggleCashMarginDrawer };
14
18
 
@@ -43,10 +47,14 @@ export const portfolioListModule: PluginModule = {
43
47
  defaultPosition: "left",
44
48
  defaultMode: "floating",
45
49
  defaultWidth: "40%",
46
- settings: (context) => buildPortfolioPaneSettingsDef(
47
- context.config,
48
- getPortfolioPaneSettings(context.settings),
49
- context.activeCollectionId,
50
+ quickSettings: [LIVE_STREAMING_QUICK_SETTING],
51
+ settings: (context) => withLiveStreamingSetting(
52
+ buildPortfolioPaneSettingsDef(
53
+ context.config,
54
+ getPortfolioPaneSettings(context.settings),
55
+ context.activeCollectionId,
56
+ ),
57
+ context.settings,
50
58
  ),
51
59
  },
52
60
  ],
@@ -51,6 +51,8 @@ import {
51
51
  } from "./data";
52
52
  import { usePortfolioPaneStreaming } from "./streaming";
53
53
  import { usePortfolioSupplementalData } from "./supplemental";
54
+ import { useLiveStreamingSetting } from "../../shared/live-streaming";
55
+ import { useThrottledTickerOrder } from "../use-throttled-ticker-order";
54
56
 
55
57
  export function PortfolioListPane({ focused, width, height }: PaneProps) {
56
58
  const { pinTicker } = usePluginTickerActions();
@@ -63,6 +65,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
63
65
  const brokerAccounts = useAppSelector((state) => state.brokerAccounts);
64
66
  const refreshingSize = useAppSelector((state) => state.refreshing.size);
65
67
  const paneCollection = usePaneCollection();
68
+ const liveStreaming = useLiveStreamingSetting();
66
69
 
67
70
  const [currentCollectionId, setCurrentCollectionId] = usePaneStateValue<string>("collectionId", paneCollection.collectionId ?? "");
68
71
  const [committedCursorSymbol, setCommittedCursorSymbol] = usePaneStateValue<string | null>("cursorSymbol", null);
@@ -169,10 +172,27 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
169
172
  ]);
170
173
 
171
174
  const activeSort = resolveCollectionSortPreference(activeCollectionId, isPortfolioTab, collectionSorts);
172
- const sortedTickers = useMemo(
175
+ const candidateSortedTickers = useMemo(
173
176
  () => sortTickers(tickers, financialsMap, activeSort, columnContext, columns),
174
177
  [tickers, financialsMap, activeSort, columnContext, columns],
175
178
  );
179
+ const candidateSymbols = useMemo(
180
+ () => candidateSortedTickers.map((ticker) => ticker.metadata.ticker),
181
+ [candidateSortedTickers],
182
+ );
183
+ const orderResetKey = `${activeCollectionId}|${activeSort.columnId ?? ""}|${activeSort.direction}`;
184
+ const orderedSymbols = useThrottledTickerOrder(candidateSymbols, orderResetKey);
185
+ const tickerBySymbol = useMemo(
186
+ () => new Map(candidateSortedTickers.map((ticker) => [ticker.metadata.ticker, ticker])),
187
+ [candidateSortedTickers],
188
+ );
189
+ const sortedTickers = useMemo(
190
+ () => orderedSymbols.flatMap((symbol) => {
191
+ const ticker = tickerBySymbol.get(symbol);
192
+ return ticker ? [ticker] : [];
193
+ }),
194
+ [orderedSymbols, tickerBySymbol],
195
+ );
176
196
 
177
197
  const selectedIdx = sortedTickers.findIndex((ticker) => ticker.metadata.ticker === cursorSymbol);
178
198
  const safeSelectedIdx = selectedIdx >= 0 ? selectedIdx : 0;
@@ -322,6 +342,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
322
342
  activeSort,
323
343
  financialsMap,
324
344
  visibleWarmupRequirements,
345
+ liveStreaming,
325
346
  });
326
347
 
327
348
  const summaryFooterInfo = useMemo(() => buildPortfolioFooterSegments({
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useMemo, useRef } from "react";
2
2
  import { getSharedMarketDataCoordinator } from "../../../../market-data/coordinator";
3
3
  import { instrumentFromTicker, quoteSubscriptionTargetFromTicker } from "../../../../market-data/request-types";
4
- import { useQuoteStreaming } from "../../../../state/hooks/quote-streaming";
4
+ import { useQuoteUpdates } from "../../../../state/hooks/quote-streaming";
5
5
  import type { TickerFinancials } from "../../../../types/financials";
6
6
  import type { TickerRecord } from "../../../../types/ticker";
7
7
  import type { CollectionSortPreference } from "../../../../state/app/context";
@@ -32,6 +32,7 @@ export function usePortfolioPaneStreaming({
32
32
  activeSort,
33
33
  financialsMap,
34
34
  visibleWarmupRequirements,
35
+ liveStreaming,
35
36
  }: {
36
37
  appActive: boolean;
37
38
  activeCollectionId?: string;
@@ -42,6 +43,7 @@ export function usePortfolioPaneStreaming({
42
43
  activeSort: CollectionSortPreference;
43
44
  financialsMap: Map<string, TickerFinancials>;
44
45
  visibleWarmupRequirements: VisibleWarmupRequirements;
46
+ liveStreaming: boolean;
45
47
  }) {
46
48
  const sharedCoordinator = getSharedMarketDataCoordinator();
47
49
  const mountedRef = useRef(true);
@@ -110,14 +112,16 @@ export function usePortfolioPaneStreaming({
110
112
  const quoteSnapshotQueue: TickerRecord[] = [];
111
113
  const snapshotQueue: TickerRecord[] = [];
112
114
  const snapshotQueueSymbols = new Set<string>();
113
- const useSnapshotForQuoteWarmup = sortPreferenceUsesQuote(activeSort);
114
- const quoteWarmupTickers = selectQuoteWarmupTickers(
115
- sortedTickers,
116
- streamWindow,
117
- financialsMap,
118
- activeSort,
119
- nowTimestamp,
120
- );
115
+ const useSnapshotForQuoteWarmup = liveStreaming && sortPreferenceUsesQuote(activeSort);
116
+ const quoteWarmupTickers = liveStreaming
117
+ ? selectQuoteWarmupTickers(
118
+ sortedTickers,
119
+ streamWindow,
120
+ financialsMap,
121
+ activeSort,
122
+ nowTimestamp,
123
+ )
124
+ : [];
121
125
  for (const ticker of quoteWarmupTickers) {
122
126
  const financials = financialsMap.get(ticker.metadata.ticker);
123
127
  const quoteKey = visibleWarmupKey("quote", ticker);
@@ -210,10 +214,10 @@ export function usePortfolioPaneStreaming({
210
214
  cancelled = true;
211
215
  clearTimeout(timeoutId);
212
216
  };
213
- }, [activeSort, appActive, financialsMap, instrumentOptions, sharedCoordinator, sortedTickers, streamWindow, visibleFinancialTickers, visibleWarmupRequirements]);
217
+ }, [activeSort, appActive, financialsMap, instrumentOptions, liveStreaming, sharedCoordinator, sortedTickers, streamWindow, visibleFinancialTickers, visibleWarmupRequirements]);
214
218
 
215
219
  useEffect(() => {
216
- if (!appActive) return;
220
+ if (!liveStreaming || !appActive) return;
217
221
  if (!sharedCoordinator) return;
218
222
 
219
223
  let cancelled = false;
@@ -262,7 +266,7 @@ export function usePortfolioPaneStreaming({
262
266
  cancelled = true;
263
267
  clearInterval(intervalId);
264
268
  };
265
- }, [appActive, sharedCoordinator]);
269
+ }, [appActive, liveStreaming, sharedCoordinator]);
266
270
 
267
- useQuoteStreaming(streamTargets);
271
+ useQuoteUpdates(streamTargets, { liveStreaming });
268
272
  }
@@ -0,0 +1,76 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, useState } from "react";
3
+ import { testRender } from "../../../renderers/opentui/test-utils";
4
+ import { useThrottledTickerOrder } from "./use-throttled-ticker-order";
5
+
6
+ const TEST_THROTTLE_MS = 80;
7
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
8
+ let updateCandidates: ((symbols: string[]) => void) | null = null;
9
+ let updateResetKey: ((key: string) => void) | null = null;
10
+ let latestOrder: string[] = [];
11
+
12
+ function ThrottledOrderHarness() {
13
+ const [candidates, setCandidates] = useState(["AAPL", "MSFT", "NVDA"]);
14
+ const [resetKey, setResetKey] = useState("price:desc");
15
+ latestOrder = useThrottledTickerOrder(candidates, resetKey, TEST_THROTTLE_MS);
16
+ updateCandidates = setCandidates;
17
+ updateResetKey = setResetKey;
18
+ return <text>{latestOrder.join(",")}</text>;
19
+ }
20
+
21
+ afterEach(async () => {
22
+ if (testSetup) {
23
+ await act(async () => testSetup!.renderer.destroy());
24
+ testSetup = undefined;
25
+ }
26
+ updateCandidates = null;
27
+ updateResetKey = null;
28
+ latestOrder = [];
29
+ });
30
+
31
+ describe("useThrottledTickerOrder", () => {
32
+ test("coalesces rapid market-driven reorders into the latest trailing order", async () => {
33
+ testSetup = await testRender(<ThrottledOrderHarness />, { width: 32, height: 1 });
34
+ await act(async () => testSetup!.renderOnce());
35
+
36
+ await act(async () => {
37
+ updateCandidates?.(["MSFT", "AAPL", "NVDA"]);
38
+ await Promise.resolve();
39
+ });
40
+ await act(async () => testSetup!.renderOnce());
41
+ expect(latestOrder).toEqual(["MSFT", "AAPL", "NVDA"]);
42
+
43
+ await act(async () => {
44
+ updateCandidates?.(["NVDA", "MSFT", "AAPL"]);
45
+ await Promise.resolve();
46
+ });
47
+ await act(async () => testSetup!.renderOnce());
48
+ expect(latestOrder).toEqual(["MSFT", "AAPL", "NVDA"]);
49
+
50
+ await act(async () => {
51
+ await new Promise((resolve) => setTimeout(resolve, TEST_THROTTLE_MS + 20));
52
+ });
53
+ await act(async () => testSetup!.renderOnce());
54
+ expect(latestOrder).toEqual(["NVDA", "MSFT", "AAPL"]);
55
+ });
56
+
57
+ test("applies user sort changes and ticker membership changes immediately", async () => {
58
+ testSetup = await testRender(<ThrottledOrderHarness />, { width: 32, height: 1 });
59
+ await act(async () => testSetup!.renderOnce());
60
+
61
+ await act(async () => {
62
+ updateCandidates?.(["NVDA", "MSFT", "AAPL"]);
63
+ updateResetKey?.("price:asc");
64
+ await Promise.resolve();
65
+ });
66
+ await act(async () => testSetup!.renderOnce());
67
+ expect(latestOrder).toEqual(["NVDA", "MSFT", "AAPL"]);
68
+
69
+ await act(async () => {
70
+ updateCandidates?.(["AMD", "NVDA", "MSFT", "AAPL"]);
71
+ await Promise.resolve();
72
+ });
73
+ await act(async () => testSetup!.renderOnce());
74
+ expect(latestOrder).toEqual(["AMD", "NVDA", "MSFT", "AAPL"]);
75
+ });
76
+ });
@@ -0,0 +1,78 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ export const PORTFOLIO_REORDER_THROTTLE_MS = 5_000;
4
+
5
+ function hasSameOrder(left: readonly string[], right: readonly string[]): boolean {
6
+ return left.length === right.length && left.every((symbol, index) => symbol === right[index]);
7
+ }
8
+
9
+ function hasSameMembership(left: readonly string[], right: readonly string[]): boolean {
10
+ if (left.length !== right.length) return false;
11
+ const symbols = new Set(left);
12
+ return right.every((symbol) => symbols.has(symbol));
13
+ }
14
+
15
+ export function useThrottledTickerOrder(
16
+ candidateSymbols: string[],
17
+ resetKey: string,
18
+ throttleMs = PORTFOLIO_REORDER_THROTTLE_MS,
19
+ ): string[] {
20
+ const [committedSymbols, setCommittedSymbols] = useState(candidateSymbols);
21
+ const committedRef = useRef(candidateSymbols);
22
+ const latestCandidateRef = useRef(candidateSymbols);
23
+ const resetKeyRef = useRef(resetKey);
24
+ const lastCommitAtRef = useRef(0);
25
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
26
+ const candidateKey = JSON.stringify(candidateSymbols);
27
+
28
+ useEffect(() => {
29
+ latestCandidateRef.current = candidateSymbols;
30
+ const current = committedRef.current;
31
+ const resetChanged = resetKeyRef.current !== resetKey;
32
+ const membershipChanged = !hasSameMembership(current, candidateSymbols);
33
+ resetKeyRef.current = resetKey;
34
+
35
+ const commit = (nextSymbols: string[]) => {
36
+ committedRef.current = nextSymbols;
37
+ lastCommitAtRef.current = Date.now();
38
+ setCommittedSymbols(nextSymbols);
39
+ };
40
+
41
+ if (resetChanged || membershipChanged) {
42
+ if (timerRef.current) clearTimeout(timerRef.current);
43
+ timerRef.current = null;
44
+ if (!hasSameOrder(current, candidateSymbols)) commit(candidateSymbols);
45
+ return;
46
+ }
47
+ if (hasSameOrder(current, candidateSymbols)) return;
48
+
49
+ const elapsed = Date.now() - lastCommitAtRef.current;
50
+ const remaining = Math.max(0, throttleMs - elapsed);
51
+ if (remaining === 0) {
52
+ if (timerRef.current) clearTimeout(timerRef.current);
53
+ timerRef.current = null;
54
+ commit(candidateSymbols);
55
+ return;
56
+ }
57
+ if (timerRef.current) return;
58
+ timerRef.current = setTimeout(() => {
59
+ timerRef.current = null;
60
+ const latest = latestCandidateRef.current;
61
+ if (!hasSameOrder(committedRef.current, latest)) commit(latest);
62
+ }, remaining);
63
+ }, [candidateKey, resetKey, throttleMs]);
64
+
65
+ useEffect(() => () => {
66
+ if (timerRef.current) clearTimeout(timerRef.current);
67
+ }, []);
68
+
69
+ return useMemo(() => {
70
+ const currentSymbols = new Set(candidateSymbols);
71
+ const ordered = committedSymbols.filter((symbol) => currentSymbols.has(symbol));
72
+ const orderedSet = new Set(ordered);
73
+ for (const symbol of candidateSymbols) {
74
+ if (!orderedSet.has(symbol)) ordered.push(symbol);
75
+ }
76
+ return ordered;
77
+ }, [candidateKey, committedSymbols]);
78
+ }