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.
- package/README.md +3 -3
- package/README.zh-CN.md +2 -2
- package/package.json +2 -1
- package/src/api-client/auth.ts +8 -0
- package/src/api-client/index.ts +70 -0
- package/src/api-client/types.ts +49 -0
- package/src/app/runtime/desktop-deeplink.test.ts +8 -0
- package/src/app/runtime/desktop-deeplink.ts +14 -1
- package/src/components/chart/composite/composite-chart.test.tsx +340 -17
- package/src/components/chart/composite/composite-chart.tsx +859 -68
- package/src/components/chart/composite/interactions.test.ts +78 -22
- package/src/components/chart/composite/interactions.ts +45 -17
- package/src/components/chart/composite/rasterizer.ts +8 -2
- package/src/components/chart/composite/renderers.test.ts +61 -1
- package/src/components/chart/composite/scene.ts +14 -4
- package/src/components/chart/composite/tools.test.ts +371 -0
- package/src/components/chart/composite/tools.ts +508 -0
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/native/raster/primitives.ts +18 -0
- package/src/components/chart/price-axis-labels.tsx +42 -2
- package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
- package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
- package/src/components/command-bar/action-runtime.ts +3 -0
- package/src/components/command-bar/assist/inventory.test.ts +93 -0
- package/src/components/command-bar/assist/inventory.ts +92 -0
- package/src/components/command-bar/assist/model.test.ts +104 -0
- package/src/components/command-bar/assist/model.ts +169 -0
- package/src/components/command-bar/assist/runtime.ts +185 -0
- package/src/components/command-bar/commands/direct/index.ts +1 -1
- package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
- package/src/components/command-bar/list/model.ts +15 -2
- package/src/components/command-bar/list/navigation.ts +6 -0
- package/src/components/command-bar/list/view.tsx +16 -3
- package/src/components/command-bar/pane-templates/workflow.ts +1 -0
- package/src/components/command-bar/panel/body.tsx +2 -0
- package/src/components/command-bar/panel/index.tsx +3 -0
- package/src/components/command-bar/panel/runtime.ts +6 -0
- package/src/components/command-bar/panel/types.ts +1 -0
- package/src/components/command-bar/routes/root/results.ts +28 -2
- package/src/components/command-bar/routes/root/runtime.ts +66 -36
- package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
- package/src/components/command-bar/routing/navigation-state.ts +17 -0
- package/src/components/command-bar/selection-runtime.ts +26 -0
- package/src/components/command-bar/surface/assist.test.tsx +276 -0
- package/src/components/command-bar/surface/index.tsx +93 -2
- package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
- package/src/components/command-bar/view-model.ts +7 -1
- package/src/components/command-bar/workflow/broker.test.ts +22 -0
- package/src/components/command-bar/workflow/broker.ts +1 -0
- package/src/components/index.ts +1 -1
- package/src/components/layout/detached-pane-shell.tsx +40 -2
- package/src/components/layout/floating-pane.tsx +4 -1
- package/src/components/layout/pane/header.tsx +64 -6
- package/src/components/layout/pane/index.tsx +4 -1
- package/src/components/layout/shell/index.tsx +23 -0
- package/src/components/layout/shell/pane/layers.tsx +5 -0
- package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
- package/src/components/onboarding/account-step/form-panel.tsx +157 -0
- package/src/components/onboarding/account-step/index.tsx +58 -0
- package/src/components/onboarding/account-step/model.test.ts +67 -0
- package/src/components/onboarding/account-step/model.ts +126 -0
- package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
- package/src/components/onboarding/onboarding-steps.tsx +91 -55
- package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
- package/src/components/onboarding/onboarding-wizard.tsx +93 -9
- package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
- package/src/components/onboarding/wizard-account.ts +235 -0
- package/src/components/onboarding/wizard-keyboard.ts +67 -1
- package/src/components/onboarding/wizard-model.ts +2 -2
- package/src/components/ui/data-table/index.tsx +1 -0
- package/src/components/ui/data-table/opentui/index.tsx +44 -2
- package/src/components/ui/data-table/types.ts +10 -0
- package/src/components/ui/data-table/visible-range.test.ts +29 -0
- package/src/components/ui/data-table/visible-range.ts +21 -0
- package/src/components/ui/ui.test.tsx +23 -1
- package/src/i18n/es.ts +1093 -0
- package/src/i18n/index.test.ts +5 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/ja.ts +66 -3
- package/src/i18n/ko.ts +66 -3
- package/src/i18n/languages.test.ts +4 -1
- package/src/i18n/languages.ts +12 -1
- package/src/i18n/zh-cn.ts +66 -3
- package/src/i18n/zh-tw.ts +66 -3
- package/src/market-data/coordinator/index.ts +1 -1
- package/src/market-data/coordinator/quotes.ts +64 -1
- package/src/market-data/coordinator/subscriptions.test.ts +72 -0
- package/src/market-data/quotes/cadence.ts +2 -0
- package/src/plugins/builtin/account-management/footer.ts +9 -3
- package/src/plugins/builtin/account-management/model.ts +53 -1
- package/src/plugins/builtin/account-management/pane.tsx +107 -24
- package/src/plugins/builtin/ai/index.tsx +7 -2
- package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
- package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
- package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
- package/src/plugins/builtin/chart-composer/index.tsx +10 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
- package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
- package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
- package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
- package/src/plugins/builtin/cloud/plugin.tsx +9 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
- package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
- package/src/plugins/builtin/help/index.tsx +38 -0
- package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
- package/src/plugins/builtin/options/footer.test.ts +20 -112
- package/src/plugins/builtin/options/footer.ts +35 -78
- package/src/plugins/builtin/options/index.tsx +6 -0
- package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
- package/src/plugins/builtin/options/live-quotes.ts +93 -39
- package/src/plugins/builtin/options/view.test.tsx +5 -2
- package/src/plugins/builtin/options/view.tsx +49 -9
- package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
- package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
- package/src/plugins/builtin/shared/live-streaming.ts +60 -0
- package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
- package/src/plugins/builtin/shared/plan-access.ts +109 -0
- package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
- package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
- package/src/plugins/registry/index.test.ts +51 -0
- package/src/plugins/registry/index.ts +12 -0
- package/src/plugins/registry/pane-settings.ts +29 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
- package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
- package/src/renderers/electrobun/view/host/box.tsx +3 -0
- package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
- package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
- package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
- package/src/renderers/electrobun/view/host/input.tsx +16 -1
- package/src/renderers/electrobun/view/host/mouse.ts +1 -0
- package/src/renderers/electrobun/view/host/style.ts +1 -1
- package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
- package/src/renderers/electrobun/view/main.tsx +2 -0
- package/src/renderers/opentui/chart-surface.tsx +2 -0
- package/src/renderers/opentui/host.tsx +3 -0
- package/src/sources/gloomberb-cloud/index.test.ts +8 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -4
- package/src/sources/provider-router/history.test.ts +3 -3
- package/src/sources/provider-router/history.ts +15 -4
- package/src/sources/yahoo-finance/history.test.ts +47 -0
- package/src/sources/yahoo-finance/history.ts +5 -1
- package/src/state/hooks/quote-streaming.test.tsx +181 -1
- package/src/state/hooks/quote-streaming.ts +93 -7
- package/src/theme/colors.ts +20 -1
- package/src/theme/themes.test.ts +3 -0
- package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
- package/src/time-series/resolution.test.ts +51 -0
- package/src/time-series/resolution.ts +41 -0
- package/src/time-series/resolve.test.ts +207 -1
- package/src/time-series/resolve.ts +196 -29
- package/src/time-series/spec-studies.test.ts +4 -0
- package/src/time-series/spec.ts +15 -1
- package/src/time-series/types.ts +7 -0
- package/src/time-series/use-chart-resolution.test.tsx +37 -0
- package/src/time-series/use-chart-resolution.ts +17 -1
- package/src/types/plugin.ts +9 -0
- 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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
44
|
-
height: number,
|
|
63
|
+
window: OptionQuoteTargetWindow,
|
|
45
64
|
): QuoteSubscriptionTarget[] {
|
|
46
65
|
if (rows.length === 0) return [];
|
|
47
|
-
const
|
|
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
|
-
|
|
71
|
+
const addRow = (index: number) => {
|
|
51
72
|
const row = rows[index];
|
|
52
|
-
if (!row)
|
|
53
|
-
const selected = index === selectedIndex;
|
|
54
|
-
const
|
|
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
|
|
88
|
+
visible,
|
|
63
89
|
selected,
|
|
64
|
-
weight: Math.max(50,
|
|
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
|
|
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
|
|
161
|
+
export function resolveOptionQuoteCoverage(
|
|
162
|
+
targets: readonly QuoteSubscriptionTarget[],
|
|
139
163
|
quoteEntries: ReadonlyMap<string, QueryEntry<Quote>>,
|
|
140
164
|
freshness: OptionsQuoteFreshness,
|
|
141
|
-
):
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
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,
|
|
136
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
+
}
|