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.
@@ -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("bounds selected-expiry subscriptions around the current strike", () => {
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, 50, 14);
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(16);
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" && target.visible === true,
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 cached quotes from before this subscription or the current chain", () => {
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
- expect(hasLiveOptionQuote(entries, beforeSubscription)).toBe(false);
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 olderThanChain = {
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, olderThanChain)[0]!.call!.lastPrice).toBe(1);
143
- expect(hasLiveOptionQuote(entries, olderThanChain)).toBe(false);
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
- expect(hasLiveOptionQuote(entries, freshness)).toBe(false);
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
- expect(hasLiveOptionQuote(entries, freshness)).toBe(false);
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
- expect(hasLiveOptionQuote(entries, freshness)).toBe(false);
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
- 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,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, strikeIdx, height),
136
- [height, snapshotRows, strikeIdx],
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
- [chain?.asOf, chain?.dataSource, freshnessNow, subscriptionStartedAt],
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
- }, [onBodyScrollActivity]);
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(scheduleViewportWidthMeasure);
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