gloomberb 0.10.2 → 0.10.3
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/package.json +1 -1
- package/src/components/index.ts +1 -1
- 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/ja.ts +2 -0
- package/src/i18n/ko.ts +2 -0
- package/src/i18n/zh-cn.ts +2 -0
- package/src/i18n/zh-tw.ts +2 -0
- package/src/market-data/coordinator/index.ts +1 -1
- package/src/market-data/coordinator/quotes.ts +1 -0
- package/src/market-data/coordinator/subscriptions.test.ts +39 -0
- package/src/plugins/builtin/options/footer.test.ts +21 -9
- package/src/plugins/builtin/options/footer.ts +22 -7
- 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 +46 -9
- package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
- package/src/state/hooks/quote-streaming.test.tsx +58 -1
- package/src/state/hooks/quote-streaming.ts +5 -2
|
@@ -5,8 +5,8 @@ import type { OptionTableRow } from "./types";
|
|
|
5
5
|
import {
|
|
6
6
|
buildOptionQuoteKey,
|
|
7
7
|
buildOptionQuoteTargets,
|
|
8
|
-
hasLiveOptionQuote,
|
|
9
8
|
overlayOptionRowQuotes,
|
|
9
|
+
resolveOptionQuoteCoverage,
|
|
10
10
|
} from "./live-quotes";
|
|
11
11
|
|
|
12
12
|
function contract(strike: number, side: "C" | "P"): OptionContract {
|
|
@@ -51,22 +51,92 @@ function readyQuote(quote: Quote): QueryEntry<Quote> {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
describe("options live quotes", () => {
|
|
54
|
-
test("
|
|
54
|
+
test("subscribes every visible contract plus non-visible overscan", () => {
|
|
55
55
|
const rows = Array.from({ length: 100 }, (_, index) => row(50 + index));
|
|
56
|
-
const targets = buildOptionQuoteTargets(rows,
|
|
56
|
+
const targets = buildOptionQuoteTargets(rows, {
|
|
57
|
+
fallbackHeight: 14,
|
|
58
|
+
selectedIndex: 50,
|
|
59
|
+
visibleRange: { start: 40, end: 64 },
|
|
60
|
+
});
|
|
61
|
+
const visibleTargets = targets.filter((target) => target.visible === true);
|
|
57
62
|
|
|
58
|
-
expect(targets).toHaveLength(
|
|
63
|
+
expect(targets).toHaveLength(64);
|
|
64
|
+
expect(visibleTargets).toHaveLength(48);
|
|
59
65
|
expect(
|
|
60
66
|
targets.every(
|
|
61
|
-
(target) => target.exchange === "OPTIONS" && target.surface === "options"
|
|
67
|
+
(target) => target.exchange === "OPTIONS" && target.surface === "options",
|
|
62
68
|
),
|
|
63
69
|
).toBe(true);
|
|
64
70
|
expect(targets.filter((target) => target.selected)).toHaveLength(2);
|
|
65
71
|
expect(targets.some((target) => target.symbol === rows[50]!.call!.contractSymbol)).toBe(true);
|
|
72
|
+
expect(visibleTargets.some((target) => target.symbol === rows[40]!.call!.contractSymbol)).toBe(true);
|
|
73
|
+
expect(visibleTargets.some((target) => target.symbol === rows[63]!.put!.contractSymbol)).toBe(true);
|
|
74
|
+
expect(targets.find((target) => target.symbol === rows[36]!.call!.contractSymbol)?.visible).toBe(false);
|
|
66
75
|
expect(targets.some((target) => target.symbol === rows[0]!.call!.contractSymbol)).toBe(false);
|
|
67
76
|
expect(targets.some((target) => target.symbol === rows.at(-1)!.put!.contractSymbol)).toBe(false);
|
|
68
77
|
});
|
|
69
78
|
|
|
79
|
+
test("requires fresh live stream metadata for every visible contract", () => {
|
|
80
|
+
const rows = [row(100), row(105), row(110)];
|
|
81
|
+
const targets = buildOptionQuoteTargets(rows, {
|
|
82
|
+
fallbackHeight: 14,
|
|
83
|
+
selectedIndex: 1,
|
|
84
|
+
visibleRange: { start: 1, end: 2 },
|
|
85
|
+
});
|
|
86
|
+
const freshness = {
|
|
87
|
+
chainAsOf: new Date(1_799_999_000_000).toISOString(),
|
|
88
|
+
chainDataSource: "live" as const,
|
|
89
|
+
now: 1_800_000_030_000,
|
|
90
|
+
subscriptionStartedAt: 1_799_999_500_000,
|
|
91
|
+
};
|
|
92
|
+
const visibleRow = rows[1]!;
|
|
93
|
+
const quote = (symbol: string): Quote => ({
|
|
94
|
+
symbol,
|
|
95
|
+
providerId: "gloomberb-cloud",
|
|
96
|
+
price: 2.4,
|
|
97
|
+
mark: 2.5,
|
|
98
|
+
bid: 2.45,
|
|
99
|
+
ask: 2.55,
|
|
100
|
+
currency: "USD",
|
|
101
|
+
change: 0,
|
|
102
|
+
changePercent: 0,
|
|
103
|
+
lastUpdated: 1_800_000_000_000,
|
|
104
|
+
receivedAt: 1_800_000_010_000,
|
|
105
|
+
dataSource: "live",
|
|
106
|
+
delivery: "stream",
|
|
107
|
+
stale: false,
|
|
108
|
+
});
|
|
109
|
+
const entries = new Map([
|
|
110
|
+
[buildOptionQuoteKey(visibleRow.call!.contractSymbol), readyQuote(quote(visibleRow.call!.contractSymbol))],
|
|
111
|
+
[buildOptionQuoteKey(visibleRow.put!.contractSymbol), readyQuote(quote(visibleRow.put!.contractSymbol))],
|
|
112
|
+
[buildOptionQuoteKey(rows[0]!.call!.contractSymbol), readyQuote(quote(rows[0]!.call!.contractSymbol))],
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
expect(resolveOptionQuoteCoverage(targets, entries, freshness)).toEqual({
|
|
116
|
+
fallbackCount: 0,
|
|
117
|
+
liveCount: 2,
|
|
118
|
+
status: "live",
|
|
119
|
+
totalCount: 2,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
entries.delete(buildOptionQuoteKey(visibleRow.put!.contractSymbol));
|
|
123
|
+
expect(resolveOptionQuoteCoverage(targets, entries, freshness)).toMatchObject({
|
|
124
|
+
liveCount: 1,
|
|
125
|
+
status: "mixed",
|
|
126
|
+
totalCount: 2,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
entries.clear();
|
|
130
|
+
entries.set(
|
|
131
|
+
buildOptionQuoteKey(rows[0]!.call!.contractSymbol),
|
|
132
|
+
readyQuote(quote(rows[0]!.call!.contractSymbol)),
|
|
133
|
+
);
|
|
134
|
+
expect(resolveOptionQuoteCoverage(targets, entries, {
|
|
135
|
+
...freshness,
|
|
136
|
+
now: freshness.subscriptionStartedAt + 1_000,
|
|
137
|
+
})).toMatchObject({ liveCount: 0, status: "connecting", totalCount: 2 });
|
|
138
|
+
});
|
|
139
|
+
|
|
70
140
|
test("overlays streamed quote fields while preserving chain-only Greeks and open interest", () => {
|
|
71
141
|
const original = row(100);
|
|
72
142
|
const symbol = original.call!.contractSymbol;
|
|
@@ -103,10 +173,9 @@ describe("options live quotes", () => {
|
|
|
103
173
|
impliedVolatility: 0.25,
|
|
104
174
|
openInterest: 20,
|
|
105
175
|
});
|
|
106
|
-
expect(hasLiveOptionQuote(entries, freshness)).toBe(true);
|
|
107
176
|
});
|
|
108
177
|
|
|
109
|
-
test("rejects
|
|
178
|
+
test("rejects quotes from before this subscription without comparing contracts to a global chain timestamp", () => {
|
|
110
179
|
const original = row(100);
|
|
111
180
|
const symbol = original.call!.contractSymbol;
|
|
112
181
|
const quote: Quote = {
|
|
@@ -122,6 +191,8 @@ describe("options live quotes", () => {
|
|
|
122
191
|
lastUpdated: 1_800_000_000_000,
|
|
123
192
|
receivedAt: 1_800_000_010_000,
|
|
124
193
|
dataSource: "live",
|
|
194
|
+
delivery: "stream",
|
|
195
|
+
stale: false,
|
|
125
196
|
};
|
|
126
197
|
const entries = new Map([[buildOptionQuoteKey(symbol), readyQuote(quote)]]);
|
|
127
198
|
|
|
@@ -131,16 +202,33 @@ describe("options live quotes", () => {
|
|
|
131
202
|
subscriptionStartedAt: 1_800_000_020_000,
|
|
132
203
|
};
|
|
133
204
|
expect(overlayOptionRowQuotes([original], entries, beforeSubscription)[0]!.call!.lastPrice).toBe(1);
|
|
134
|
-
|
|
205
|
+
const targets = buildOptionQuoteTargets([original], {
|
|
206
|
+
fallbackHeight: 14,
|
|
207
|
+
selectedIndex: 0,
|
|
208
|
+
visibleRange: { start: 0, end: 1 },
|
|
209
|
+
});
|
|
210
|
+
expect(resolveOptionQuoteCoverage(targets, entries, beforeSubscription).status).not.toBe("live");
|
|
135
211
|
|
|
136
|
-
const
|
|
212
|
+
const heterogeneousChainSnapshot = {
|
|
137
213
|
chainAsOf: new Date(1_800_000_005_000).toISOString(),
|
|
138
214
|
chainDataSource: "live" as const,
|
|
139
215
|
now: 1_800_000_030_000,
|
|
140
216
|
subscriptionStartedAt: 1_800_000_000_000,
|
|
141
217
|
};
|
|
142
|
-
expect(overlayOptionRowQuotes([original], entries,
|
|
143
|
-
expect(
|
|
218
|
+
expect(overlayOptionRowQuotes([original], entries, heterogeneousChainSnapshot)[0]!.call!.lastPrice).toBe(9);
|
|
219
|
+
expect(resolveOptionQuoteCoverage(targets, entries, heterogeneousChainSnapshot)).toMatchObject({
|
|
220
|
+
liveCount: 1,
|
|
221
|
+
status: "mixed",
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const newerContract = row(100);
|
|
225
|
+
newerContract.call = {
|
|
226
|
+
...newerContract.call!,
|
|
227
|
+
lastUpdated: 1_800_000_005_000,
|
|
228
|
+
};
|
|
229
|
+
expect(
|
|
230
|
+
overlayOptionRowQuotes([newerContract], entries, heterogeneousChainSnapshot)[0]!.call!.lastPrice,
|
|
231
|
+
).toBe(1);
|
|
144
232
|
});
|
|
145
233
|
|
|
146
234
|
test("accepts a freshly delivered delayed quote older than the delayed chain fetch", () => {
|
|
@@ -171,7 +259,12 @@ describe("options live quotes", () => {
|
|
|
171
259
|
};
|
|
172
260
|
|
|
173
261
|
expect(overlayOptionRowQuotes([original], entries, freshness)[0]!.call!.lastPrice).toBe(2.25);
|
|
174
|
-
|
|
262
|
+
const targets = buildOptionQuoteTargets([original], {
|
|
263
|
+
fallbackHeight: 14,
|
|
264
|
+
selectedIndex: 0,
|
|
265
|
+
visibleRange: { start: 0, end: 1 },
|
|
266
|
+
});
|
|
267
|
+
expect(resolveOptionQuoteCoverage(targets, entries, freshness).status).toBe("delayed");
|
|
175
268
|
});
|
|
176
269
|
|
|
177
270
|
test("overlays a valid polled quote without reporting a live stream", () => {
|
|
@@ -202,7 +295,12 @@ describe("options live quotes", () => {
|
|
|
202
295
|
};
|
|
203
296
|
|
|
204
297
|
expect(overlayOptionRowQuotes([original], entries, freshness)[0]!.call!.lastPrice).toBe(2.5);
|
|
205
|
-
|
|
298
|
+
const targets = buildOptionQuoteTargets([original], {
|
|
299
|
+
fallbackHeight: 14,
|
|
300
|
+
selectedIndex: 0,
|
|
301
|
+
visibleRange: { start: 0, end: 1 },
|
|
302
|
+
});
|
|
303
|
+
expect(resolveOptionQuoteCoverage(targets, entries, freshness).status).toBe("delayed");
|
|
206
304
|
});
|
|
207
305
|
|
|
208
306
|
test("rejects a freshly received quote marked stale by the server", () => {
|
|
@@ -233,6 +331,11 @@ describe("options live quotes", () => {
|
|
|
233
331
|
};
|
|
234
332
|
|
|
235
333
|
expect(overlayOptionRowQuotes([original], entries, freshness)[0]!.call!.lastPrice).toBe(1);
|
|
236
|
-
|
|
334
|
+
const targets = buildOptionQuoteTargets([original], {
|
|
335
|
+
fallbackHeight: 14,
|
|
336
|
+
selectedIndex: 0,
|
|
337
|
+
visibleRange: { start: 0, end: 1 },
|
|
338
|
+
});
|
|
339
|
+
expect(resolveOptionQuoteCoverage(targets, entries, freshness).status).toBe("delayed");
|
|
237
340
|
});
|
|
238
341
|
});
|
|
@@ -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,9 +25,9 @@ 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";
|
|
27
33
|
|
|
@@ -31,6 +37,10 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
|
|
|
31
37
|
const [strikeIdx, setStrikeIdx] = useState(0);
|
|
32
38
|
const [autoScrollVersion, setAutoScrollVersion] = useState(0);
|
|
33
39
|
const [scrollToIndexAlign, setScrollToIndexAlign] = useState<"nearest" | "center">("nearest");
|
|
40
|
+
const [visibleStrikeViewport, setVisibleStrikeViewport] = useState<{
|
|
41
|
+
key: string;
|
|
42
|
+
range: DataTableVisibleRange;
|
|
43
|
+
} | null>(null);
|
|
34
44
|
const [interactive, setInteractive] = useState(false);
|
|
35
45
|
const userSelectedStrikeRef = useRef(false);
|
|
36
46
|
const onCaptureRef = useRef(onCapture);
|
|
@@ -54,6 +64,7 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
|
|
|
54
64
|
const initialChainEntry = useOptionsQuery(baseRequest);
|
|
55
65
|
const initialChain = useResolvedEntryValue(initialChainEntry);
|
|
56
66
|
const selectedExpiration = initialChain?.expirationDates[expIdx];
|
|
67
|
+
const viewportKey = `${effectiveTicker}:${selectedExpiration ?? "initial"}`;
|
|
57
68
|
const expirationChainEntry = useOptionsQuery(
|
|
58
69
|
baseRequest && selectedExpiration != null
|
|
59
70
|
? { ...baseRequest, expirationDate: selectedExpiration }
|
|
@@ -131,28 +142,52 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
|
|
|
131
142
|
put: putsByStrike.get(strike),
|
|
132
143
|
isPositionStrike: !!parsed && Math.abs(strike - parsed.strike) < 0.01,
|
|
133
144
|
})), [callsByStrike, parsed, putsByStrike, strikes]);
|
|
145
|
+
const visibleStrikeRange = visibleStrikeViewport?.key === viewportKey
|
|
146
|
+
? visibleStrikeViewport.range
|
|
147
|
+
: null;
|
|
148
|
+
const handleVisibleStrikeRangeChange = useCallback((range: DataTableVisibleRange) => {
|
|
149
|
+
setVisibleStrikeViewport((current) => (
|
|
150
|
+
current?.key === viewportKey
|
|
151
|
+
&& current.range.start === range.start
|
|
152
|
+
&& current.range.end === range.end
|
|
153
|
+
? current
|
|
154
|
+
: { key: viewportKey, range }
|
|
155
|
+
));
|
|
156
|
+
}, [viewportKey]);
|
|
134
157
|
const optionQuoteTargets = useMemo(
|
|
135
|
-
() => buildOptionQuoteTargets(snapshotRows,
|
|
136
|
-
|
|
158
|
+
() => buildOptionQuoteTargets(snapshotRows, {
|
|
159
|
+
fallbackHeight: height,
|
|
160
|
+
selectedIndex: strikeIdx,
|
|
161
|
+
visibleRange: visibleStrikeRange,
|
|
162
|
+
}),
|
|
163
|
+
[height, snapshotRows, strikeIdx, visibleStrikeRange],
|
|
137
164
|
);
|
|
138
165
|
const {
|
|
139
166
|
entries: optionQuoteEntries,
|
|
140
167
|
freshnessNow,
|
|
141
168
|
subscriptionStartedAt,
|
|
142
|
-
} = useLiveQuoteEntries(optionQuoteTargets
|
|
169
|
+
} = useLiveQuoteEntries(optionQuoteTargets, {
|
|
170
|
+
freshnessScopeKey: viewportKey,
|
|
171
|
+
});
|
|
143
172
|
const optionQuoteFreshness = useMemo(
|
|
144
173
|
() => ({
|
|
145
|
-
chainAsOf: chain?.asOf,
|
|
146
|
-
chainDataSource: chain?.dataSource,
|
|
147
174
|
now: freshnessNow,
|
|
148
175
|
subscriptionStartedAt,
|
|
149
176
|
}),
|
|
150
|
-
[
|
|
177
|
+
[freshnessNow, subscriptionStartedAt],
|
|
151
178
|
);
|
|
152
179
|
const rows = useMemo(
|
|
153
180
|
() => overlayOptionRowQuotes(snapshotRows, optionQuoteEntries, optionQuoteFreshness),
|
|
154
181
|
[optionQuoteEntries, optionQuoteFreshness, snapshotRows],
|
|
155
182
|
);
|
|
183
|
+
const optionQuoteCoverage = useMemo(
|
|
184
|
+
() => resolveOptionQuoteCoverage(
|
|
185
|
+
optionQuoteTargets,
|
|
186
|
+
optionQuoteEntries,
|
|
187
|
+
optionQuoteFreshness,
|
|
188
|
+
),
|
|
189
|
+
[optionQuoteEntries, optionQuoteFreshness, optionQuoteTargets],
|
|
190
|
+
);
|
|
156
191
|
const optionColumns: OptionColumn[] = OPTION_COLUMNS.map((column) => ({
|
|
157
192
|
...column,
|
|
158
193
|
headerColor: optionColumnColor(column.id, colors.panel),
|
|
@@ -162,8 +197,8 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
|
|
|
162
197
|
chain,
|
|
163
198
|
error,
|
|
164
199
|
focused,
|
|
165
|
-
hasLiveQuote: hasLiveOptionQuote(optionQuoteEntries, optionQuoteFreshness),
|
|
166
200
|
loading,
|
|
201
|
+
quoteCoverage: optionQuoteCoverage,
|
|
167
202
|
});
|
|
168
203
|
|
|
169
204
|
useEffect(() => {
|
|
@@ -333,6 +368,8 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
|
|
|
333
368
|
sortDirection="asc"
|
|
334
369
|
onHeaderClick={() => {}}
|
|
335
370
|
onTableMouseDown={enterInteractive}
|
|
371
|
+
visibleRangeKey={viewportKey}
|
|
372
|
+
onVisibleRangeChange={handleVisibleStrikeRangeChange}
|
|
336
373
|
getItemKey={(row) => String(row.strike)}
|
|
337
374
|
renderCell={renderOptionCell}
|
|
338
375
|
emptyStateTitle="No strikes available."
|
|
@@ -15,7 +15,9 @@ import { measurePerf } from "../../../../utils/perf-marks";
|
|
|
15
15
|
import type {
|
|
16
16
|
DataTableColumn,
|
|
17
17
|
DataTableProps,
|
|
18
|
+
DataTableVisibleRange,
|
|
18
19
|
} from "../../../../components/ui/data-table";
|
|
20
|
+
import { resolveDataTableVisibleRange } from "../../../../components/ui/data-table/visible-range";
|
|
19
21
|
import {
|
|
20
22
|
buildTableGridTemplateColumns,
|
|
21
23
|
getTableWidth,
|
|
@@ -57,6 +59,8 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
57
59
|
onSelect,
|
|
58
60
|
onActivate,
|
|
59
61
|
onTableMouseDown,
|
|
62
|
+
visibleRangeKey,
|
|
63
|
+
onVisibleRangeChange,
|
|
60
64
|
onRowMouseDown,
|
|
61
65
|
onRowContextMenu,
|
|
62
66
|
rowContextMenuSurface = false,
|
|
@@ -80,6 +84,10 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
80
84
|
const dispatch = useAppDispatch();
|
|
81
85
|
const paneInstanceId = usePaneInstance()?.instanceId ?? null;
|
|
82
86
|
const bodyElementRef = useRef<HTMLDivElement | null>(null);
|
|
87
|
+
const lastVisibleRangeRef = useRef<{
|
|
88
|
+
key: string | number | undefined;
|
|
89
|
+
range: DataTableVisibleRange;
|
|
90
|
+
} | null>(null);
|
|
83
91
|
const headerHorizontal = useScrollbarState(false);
|
|
84
92
|
const headerVertical = useScrollbarState(false);
|
|
85
93
|
const bodyHorizontal = useScrollbarState(showHorizontalScrollbar);
|
|
@@ -106,10 +114,32 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
106
114
|
dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
|
|
107
115
|
}, [dispatch, paneInstanceId]);
|
|
108
116
|
|
|
117
|
+
const emitVisibleRange = useCallback(() => {
|
|
118
|
+
if (!onVisibleRangeChange) return;
|
|
119
|
+
const element = bodyElementRef.current;
|
|
120
|
+
if (!element) return;
|
|
121
|
+
const range = resolveDataTableVisibleRange({
|
|
122
|
+
itemCount: items.length,
|
|
123
|
+
rowSize: WEB_CELL_HEIGHT,
|
|
124
|
+
scrollOffset: element.scrollTop,
|
|
125
|
+
viewportSize: Math.max(0, element.clientHeight - WEB_CELL_HEIGHT),
|
|
126
|
+
});
|
|
127
|
+
const previous = lastVisibleRangeRef.current;
|
|
128
|
+
if (
|
|
129
|
+
previous !== null
|
|
130
|
+
&& previous.key === visibleRangeKey
|
|
131
|
+
&& previous.range.start === range.start
|
|
132
|
+
&& previous.range.end === range.end
|
|
133
|
+
) return;
|
|
134
|
+
lastVisibleRangeRef.current = { key: visibleRangeKey, range };
|
|
135
|
+
onVisibleRangeChange(range);
|
|
136
|
+
}, [items.length, onVisibleRangeChange, visibleRangeKey]);
|
|
109
137
|
const handleBodyScrollActivity = useCallback(() => {
|
|
110
138
|
onBodyScrollActivity();
|
|
111
|
-
|
|
139
|
+
emitVisibleRange();
|
|
140
|
+
}, [emitVisibleRange, onBodyScrollActivity]);
|
|
112
141
|
const scheduleBodyScrollActivity = useRafCallback(handleBodyScrollActivity);
|
|
142
|
+
const scheduleVisibleRangeMeasure = useRafCallback(emitVisibleRange);
|
|
113
143
|
|
|
114
144
|
const rowVirtualizer = useVirtualizer({
|
|
115
145
|
count: items.length,
|
|
@@ -154,12 +184,20 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
154
184
|
|
|
155
185
|
useEffect(() => {
|
|
156
186
|
measureViewportWidth();
|
|
187
|
+
scheduleVisibleRangeMeasure();
|
|
157
188
|
const element = bodyElementRef.current;
|
|
158
189
|
if (!element || typeof ResizeObserver === "undefined") return;
|
|
159
|
-
const observer = new ResizeObserver(
|
|
190
|
+
const observer = new ResizeObserver(() => {
|
|
191
|
+
scheduleViewportWidthMeasure();
|
|
192
|
+
scheduleVisibleRangeMeasure();
|
|
193
|
+
});
|
|
160
194
|
observer.observe(element);
|
|
161
195
|
return () => observer.disconnect();
|
|
162
|
-
}, [measureViewportWidth, scheduleViewportWidthMeasure]);
|
|
196
|
+
}, [measureViewportWidth, scheduleViewportWidthMeasure, scheduleVisibleRangeMeasure]);
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
scheduleVisibleRangeMeasure();
|
|
200
|
+
}, [items.length, scheduleVisibleRangeMeasure, visibleRangeKey]);
|
|
163
201
|
|
|
164
202
|
useEffect(() => {
|
|
165
203
|
if (scrollToIndex == null || items.length === 0) return;
|
|
@@ -168,6 +206,7 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
168
206
|
rowVirtualizer.scrollToIndex(targetIndex, {
|
|
169
207
|
align: scrollToIndexAlign === "center" ? "center" : "auto",
|
|
170
208
|
});
|
|
209
|
+
scheduleVisibleRangeMeasure();
|
|
171
210
|
return;
|
|
172
211
|
}
|
|
173
212
|
const element = bodyElementRef.current;
|
|
@@ -185,12 +224,14 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
185
224
|
if (nextTop !== currentTop) {
|
|
186
225
|
element.scrollTop = nextTop * WEB_CELL_HEIGHT;
|
|
187
226
|
}
|
|
227
|
+
scheduleVisibleRangeMeasure();
|
|
188
228
|
}, [
|
|
189
229
|
items.length,
|
|
190
230
|
rowVirtualizer,
|
|
191
231
|
scrollToIndex,
|
|
192
232
|
scrollToIndexAlign,
|
|
193
233
|
scrollToIndexVersion,
|
|
234
|
+
scheduleVisibleRangeMeasure,
|
|
194
235
|
virtualize,
|
|
195
236
|
]);
|
|
196
237
|
|