gloomberb 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +2 -2
  3. package/package.json +2 -1
  4. package/src/api-client/auth.ts +8 -0
  5. package/src/api-client/index.ts +70 -0
  6. package/src/api-client/types.ts +49 -0
  7. package/src/app/runtime/desktop-deeplink.test.ts +8 -0
  8. package/src/app/runtime/desktop-deeplink.ts +14 -1
  9. package/src/components/chart/composite/composite-chart.test.tsx +340 -17
  10. package/src/components/chart/composite/composite-chart.tsx +859 -68
  11. package/src/components/chart/composite/interactions.test.ts +78 -22
  12. package/src/components/chart/composite/interactions.ts +45 -17
  13. package/src/components/chart/composite/rasterizer.ts +8 -2
  14. package/src/components/chart/composite/renderers.test.ts +61 -1
  15. package/src/components/chart/composite/scene.ts +14 -4
  16. package/src/components/chart/composite/tools.test.ts +371 -0
  17. package/src/components/chart/composite/tools.ts +508 -0
  18. package/src/components/chart/composite/types.ts +4 -0
  19. package/src/components/chart/core/pointer.ts +2 -0
  20. package/src/components/chart/native/raster/primitives.ts +18 -0
  21. package/src/components/chart/price-axis-labels.tsx +42 -2
  22. package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
  23. package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
  24. package/src/components/command-bar/action-runtime.ts +3 -0
  25. package/src/components/command-bar/assist/inventory.test.ts +93 -0
  26. package/src/components/command-bar/assist/inventory.ts +92 -0
  27. package/src/components/command-bar/assist/model.test.ts +104 -0
  28. package/src/components/command-bar/assist/model.ts +169 -0
  29. package/src/components/command-bar/assist/runtime.ts +185 -0
  30. package/src/components/command-bar/commands/direct/index.ts +1 -1
  31. package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
  32. package/src/components/command-bar/list/model.ts +15 -2
  33. package/src/components/command-bar/list/navigation.ts +6 -0
  34. package/src/components/command-bar/list/view.tsx +16 -3
  35. package/src/components/command-bar/pane-templates/workflow.ts +1 -0
  36. package/src/components/command-bar/panel/body.tsx +2 -0
  37. package/src/components/command-bar/panel/index.tsx +3 -0
  38. package/src/components/command-bar/panel/runtime.ts +6 -0
  39. package/src/components/command-bar/panel/types.ts +1 -0
  40. package/src/components/command-bar/routes/root/results.ts +28 -2
  41. package/src/components/command-bar/routes/root/runtime.ts +66 -36
  42. package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
  43. package/src/components/command-bar/routing/navigation-state.ts +17 -0
  44. package/src/components/command-bar/selection-runtime.ts +26 -0
  45. package/src/components/command-bar/surface/assist.test.tsx +276 -0
  46. package/src/components/command-bar/surface/index.tsx +93 -2
  47. package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
  48. package/src/components/command-bar/view-model.ts +7 -1
  49. package/src/components/command-bar/workflow/broker.test.ts +22 -0
  50. package/src/components/command-bar/workflow/broker.ts +1 -0
  51. package/src/components/index.ts +1 -1
  52. package/src/components/layout/detached-pane-shell.tsx +40 -2
  53. package/src/components/layout/floating-pane.tsx +4 -1
  54. package/src/components/layout/pane/header.tsx +64 -6
  55. package/src/components/layout/pane/index.tsx +4 -1
  56. package/src/components/layout/shell/index.tsx +23 -0
  57. package/src/components/layout/shell/pane/layers.tsx +5 -0
  58. package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
  59. package/src/components/onboarding/account-step/form-panel.tsx +157 -0
  60. package/src/components/onboarding/account-step/index.tsx +58 -0
  61. package/src/components/onboarding/account-step/model.test.ts +67 -0
  62. package/src/components/onboarding/account-step/model.ts +126 -0
  63. package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
  64. package/src/components/onboarding/onboarding-steps.tsx +91 -55
  65. package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
  66. package/src/components/onboarding/onboarding-wizard.tsx +93 -9
  67. package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
  68. package/src/components/onboarding/wizard-account.ts +235 -0
  69. package/src/components/onboarding/wizard-keyboard.ts +67 -1
  70. package/src/components/onboarding/wizard-model.ts +2 -2
  71. package/src/components/ui/data-table/index.tsx +1 -0
  72. package/src/components/ui/data-table/opentui/index.tsx +44 -2
  73. package/src/components/ui/data-table/types.ts +10 -0
  74. package/src/components/ui/data-table/visible-range.test.ts +29 -0
  75. package/src/components/ui/data-table/visible-range.ts +21 -0
  76. package/src/components/ui/ui.test.tsx +23 -1
  77. package/src/i18n/es.ts +1093 -0
  78. package/src/i18n/index.test.ts +5 -2
  79. package/src/i18n/index.ts +3 -0
  80. package/src/i18n/ja.ts +66 -3
  81. package/src/i18n/ko.ts +66 -3
  82. package/src/i18n/languages.test.ts +4 -1
  83. package/src/i18n/languages.ts +12 -1
  84. package/src/i18n/zh-cn.ts +66 -3
  85. package/src/i18n/zh-tw.ts +66 -3
  86. package/src/market-data/coordinator/index.ts +1 -1
  87. package/src/market-data/coordinator/quotes.ts +64 -1
  88. package/src/market-data/coordinator/subscriptions.test.ts +72 -0
  89. package/src/market-data/quotes/cadence.ts +2 -0
  90. package/src/plugins/builtin/account-management/footer.ts +9 -3
  91. package/src/plugins/builtin/account-management/model.ts +53 -1
  92. package/src/plugins/builtin/account-management/pane.tsx +107 -24
  93. package/src/plugins/builtin/ai/index.tsx +7 -2
  94. package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
  95. package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
  96. package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
  97. package/src/plugins/builtin/chart-composer/index.tsx +10 -2
  98. package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
  99. package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
  100. package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
  101. package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
  102. package/src/plugins/builtin/cloud/plugin.tsx +9 -1
  103. package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
  104. package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
  105. package/src/plugins/builtin/help/index.tsx +38 -0
  106. package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
  107. package/src/plugins/builtin/options/footer.test.ts +20 -112
  108. package/src/plugins/builtin/options/footer.ts +35 -78
  109. package/src/plugins/builtin/options/index.tsx +6 -0
  110. package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
  111. package/src/plugins/builtin/options/live-quotes.ts +93 -39
  112. package/src/plugins/builtin/options/view.test.tsx +5 -2
  113. package/src/plugins/builtin/options/view.tsx +49 -9
  114. package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
  115. package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
  116. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
  117. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
  118. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
  119. package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
  120. package/src/plugins/builtin/shared/live-streaming.ts +60 -0
  121. package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
  122. package/src/plugins/builtin/shared/plan-access.ts +109 -0
  123. package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
  124. package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
  125. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
  126. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
  127. package/src/plugins/registry/index.test.ts +51 -0
  128. package/src/plugins/registry/index.ts +12 -0
  129. package/src/plugins/registry/pane-settings.ts +29 -0
  130. package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
  131. package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
  132. package/src/renderers/electrobun/view/host/box.tsx +3 -0
  133. package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
  134. package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
  135. package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
  136. package/src/renderers/electrobun/view/host/input.tsx +16 -1
  137. package/src/renderers/electrobun/view/host/mouse.ts +1 -0
  138. package/src/renderers/electrobun/view/host/style.ts +1 -1
  139. package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
  140. package/src/renderers/electrobun/view/main.tsx +2 -0
  141. package/src/renderers/opentui/chart-surface.tsx +2 -0
  142. package/src/renderers/opentui/host.tsx +3 -0
  143. package/src/sources/gloomberb-cloud/index.test.ts +8 -0
  144. package/src/sources/gloomberb-cloud/index.ts +4 -4
  145. package/src/sources/provider-router/history.test.ts +3 -3
  146. package/src/sources/provider-router/history.ts +15 -4
  147. package/src/sources/yahoo-finance/history.test.ts +47 -0
  148. package/src/sources/yahoo-finance/history.ts +5 -1
  149. package/src/state/hooks/quote-streaming.test.tsx +181 -1
  150. package/src/state/hooks/quote-streaming.ts +93 -7
  151. package/src/theme/colors.ts +20 -1
  152. package/src/theme/themes.test.ts +3 -0
  153. package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
  154. package/src/time-series/resolution.test.ts +51 -0
  155. package/src/time-series/resolution.ts +41 -0
  156. package/src/time-series/resolve.test.ts +207 -1
  157. package/src/time-series/resolve.ts +196 -29
  158. package/src/time-series/spec-studies.test.ts +4 -0
  159. package/src/time-series/spec.ts +15 -1
  160. package/src/time-series/types.ts +7 -0
  161. package/src/time-series/use-chart-resolution.test.tsx +37 -0
  162. package/src/time-series/use-chart-resolution.ts +17 -1
  163. package/src/types/plugin.ts +9 -0
  164. package/src/ui/host.tsx +17 -0
@@ -1,10 +1,10 @@
1
- import { useCallback, useMemo } from "react";
1
+ import { useMemo } from "react";
2
2
  import type { PaneFooterSegment } from "../../../../../components";
3
- import { useShortcut } from "../../../../../react/input";
4
- import { useRendererHost } from "../../../../../ui";
5
- import { apiClient } from "../../../../../api-client";
3
+ import { t, tf } from "../../../../../i18n";
4
+ import { useAppLanguage } from "../../../../../i18n/react";
5
+ import { useCloudAccessFooter } from "../../../shared/cloud-upgrade";
6
+ import { CLOUD_NEWS_DELAY_HOURS } from "../../../shared/plan-access";
6
7
  import { usePaneStatusLinkFooter } from "../../../shared/pane-footer";
7
- import { CLOUD_UPGRADE_URL } from "../../../shared/cloud-upgrade";
8
8
 
9
9
  interface NewsFooterArticle {
10
10
  source?: string | null;
@@ -20,11 +20,6 @@ interface UseNewsArticleFooterOptions {
20
20
  error?: string | null;
21
21
  }
22
22
 
23
- function hasRealtimeNewsAccess(): boolean {
24
- const user = apiClient.getCurrentUser();
25
- return user?.emailVerified === true && user.plan === "pro";
26
- }
27
-
28
23
  export function useNewsArticleFooter({
29
24
  registrationId,
30
25
  focused,
@@ -33,35 +28,20 @@ export function useNewsArticleFooter({
33
28
  loading = false,
34
29
  error,
35
30
  }: UseNewsArticleFooterOptions) {
36
- const rendererHost = useRendererHost();
37
- const hasRealtimeAccess = hasRealtimeNewsAccess();
38
- const showAccessFooter = !article;
39
- const openUpgrade = useCallback(() => {
40
- void rendererHost.openExternal(CLOUD_UPGRADE_URL);
41
- }, [rendererHost]);
42
-
43
- useShortcut((event) => {
44
- const key = (event.name ?? event.key ?? "").toLowerCase();
45
- if (!focused || !showAccessFooter || hasRealtimeAccess || key !== "u") return;
46
- event.stopPropagation();
47
- event.preventDefault();
48
- openUpgrade();
49
- }, { scope: `${registrationId}:news-upgrade` });
31
+ const language = useAppLanguage();
32
+ const { access, segment } = useCloudAccessFooter({
33
+ delayLabel: tf("{count}h", { count: CLOUD_NEWS_DELAY_HOURS }),
34
+ focused,
35
+ segmentId: "news-access",
36
+ shortcutScope: `${registrationId}:news-upgrade`,
37
+ });
50
38
 
51
- const accessInfo = useMemo<PaneFooterSegment[]>(() => (
52
- !showAccessFooter
53
- ? []
54
- : hasRealtimeAccess
55
- ? [{
56
- id: "news-access",
57
- parts: [{ text: "realtime news", tone: "positive" }],
58
- }]
59
- : [{
60
- id: "news-access",
61
- onPress: openUpgrade,
62
- parts: [{ text: "delayed 12h, upgrade for realtime", tone: "warning" }],
63
- }]
64
- ), [hasRealtimeAccess, openUpgrade, showAccessFooter]);
39
+ const accessInfo = useMemo<PaneFooterSegment[]>(() => {
40
+ if (access.isPayingPro) {
41
+ return [{ id: "news-access", parts: [{ text: t("real-time news"), tone: "positive" }] }];
42
+ }
43
+ return segment ? [segment] : [];
44
+ }, [access.isPayingPro, language, segment]);
65
45
  const footerInfo = useMemo(() => [...accessInfo, ...(info ?? [])], [accessInfo, info]);
66
46
 
67
47
  usePaneStatusLinkFooter({
@@ -1,115 +1,23 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import type { OptionsChain } from "../../../types/financials";
3
- import { buildOptionsAccessFooterSegment, resolveOptionsAccessFooterState } from "./footer";
4
-
5
- function chain(overrides: Partial<OptionsChain> = {}): OptionsChain {
6
- return {
7
- underlyingSymbol: "AAPL",
8
- expirationDates: [],
9
- calls: [],
10
- puts: [],
11
- ...overrides,
12
- };
13
- }
14
-
15
- describe("options access footer", () => {
16
- test("offers free users the exact delayed upgrade state with a clickable segment", () => {
17
- const state = resolveOptionsAccessFooterState({
18
- chain: chain({
19
- dataSource: "delayed",
20
- delayMinutes: 15,
21
- realtimeEligible: false,
22
- }),
23
- clientPlan: "free",
24
- hasLiveQuote: false,
25
- });
26
- let upgrades = 0;
27
- const segment = buildOptionsAccessFooterSegment(state, () => {
28
- upgrades += 1;
29
- });
30
-
31
- expect(state).toEqual({
32
- canUpgrade: true,
33
- text: "15-minute delayed options, upgrade for real-time",
34
- tone: "warning",
35
- });
36
- segment.onPress?.();
37
- expect(upgrades).toBe(1);
38
- });
39
-
40
- test("claims real-time only for a Pro user with a fresh live stream", () => {
41
- expect(
42
- resolveOptionsAccessFooterState({
43
- chain: chain({
44
- dataSource: "delayed",
45
- delayMinutes: 15,
46
- realtimeEligible: true,
47
- }),
48
- clientPlan: "pro",
49
- hasLiveQuote: false,
50
- }),
51
- ).toMatchObject({
52
- canUpgrade: false,
53
- text: "options delayed fallback",
54
- tone: "warning",
55
- });
56
-
57
- expect(
58
- resolveOptionsAccessFooterState({
59
- chain: chain({
60
- dataSource: "live",
61
- delayMinutes: 0,
62
- realtimeEligible: true,
63
- }),
64
- clientPlan: "pro",
65
- hasLiveQuote: false,
66
- }),
67
- ).toMatchObject({
68
- canUpgrade: false,
69
- text: "options delayed fallback",
70
- tone: "warning",
71
- });
72
-
73
- expect(
74
- resolveOptionsAccessFooterState({
75
- chain: chain({ realtimeEligible: false }),
76
- clientPlan: "pro",
77
- hasLiveQuote: true,
78
- }).text,
79
- ).toBe("real-time options");
80
- });
81
-
82
- test("makes the current plan authoritative over cached chain metadata", () => {
83
- expect(
84
- resolveOptionsAccessFooterState({
85
- chain: chain({
86
- dataSource: "live",
87
- delayMinutes: 0,
88
- realtimeEligible: true,
89
- }),
90
- clientPlan: "free",
91
- hasLiveQuote: true,
92
- }),
93
- ).toMatchObject({
94
- canUpgrade: true,
95
- text: "15-minute delayed options, upgrade for real-time",
96
- tone: "warning",
97
- });
98
-
99
- expect(
100
- resolveOptionsAccessFooterState({
101
- chain: chain({
102
- dataSource: "delayed",
103
- delayMinutes: 15,
104
- realtimeEligible: false,
105
- }),
106
- clientPlan: "pro",
107
- hasLiveQuote: true,
108
- }),
109
- ).toMatchObject({
110
- canUpgrade: false,
111
- text: "real-time options",
112
- tone: "positive",
113
- });
2
+ import { resolveOptionsDelayLabel } from "./footer";
3
+
4
+ describe("options delay label", () => {
5
+ test("prefers the chain delay and falls back to the cloud default", () => {
6
+ expect(resolveOptionsDelayLabel({
7
+ underlyingSymbol: "AAPL",
8
+ expirationDates: [],
9
+ calls: [],
10
+ puts: [],
11
+ delayMinutes: 20,
12
+ })).toBe("20m");
13
+ // A live chain reports 0, and a cached chain may omit the field entirely.
14
+ expect(resolveOptionsDelayLabel({
15
+ underlyingSymbol: "AAPL",
16
+ expirationDates: [],
17
+ calls: [],
18
+ puts: [],
19
+ delayMinutes: 0,
20
+ })).toBe("15m");
21
+ expect(resolveOptionsDelayLabel(null)).toBe("15m");
114
22
  });
115
23
  });
@@ -1,110 +1,67 @@
1
- import { useCallback, useMemo } from "react";
1
+ import { useMemo } from "react";
2
2
  import type { PaneFooterSegment } from "../../../components";
3
- import { apiClient } from "../../../api-client";
4
3
  import { t, tf } from "../../../i18n";
5
- import { useShortcut } from "../../../react/input";
6
4
  import type { OptionsChain } from "../../../types/financials";
7
- import { useRendererHost } from "../../../ui";
8
- import { CLOUD_UPGRADE_URL } from "../shared/cloud-upgrade";
5
+ import { useCloudAccessFooter } from "../shared/cloud-upgrade";
9
6
  import { usePaneStatusFooter } from "../shared/pane-footer";
7
+ import { CLOUD_QUOTE_DELAY_MINUTES } from "../shared/plan-access";
8
+ import type { OptionQuoteCoverage, OptionQuoteCoverageStatus } from "./live-quotes";
10
9
 
11
- export interface OptionsAccessFooterState {
12
- canUpgrade: boolean;
10
+ export interface OptionsCoverageState {
13
11
  text: string;
14
- tone: "positive" | "warning";
12
+ tone: "muted" | "positive" | "warning";
15
13
  }
16
14
 
17
- export function resolveOptionsAccessFooterState({
18
- chain,
19
- clientPlan,
20
- hasLiveQuote,
21
- }: {
22
- chain: OptionsChain | null | undefined;
23
- clientPlan: "free" | "pro" | null;
24
- hasLiveQuote: boolean;
25
- }): OptionsAccessFooterState {
26
- if (clientPlan !== "pro") {
27
- const delayMinutes = chain?.delayMinutes && chain.delayMinutes > 0 ? chain.delayMinutes : 15;
28
- return {
29
- canUpgrade: true,
30
- text: tf("{count}-minute delayed options, upgrade for real-time", {
31
- count: delayMinutes,
32
- }),
33
- tone: "warning",
34
- };
35
- }
36
-
37
- if (hasLiveQuote) {
38
- return {
39
- canUpgrade: false,
40
- text: t("real-time options"),
41
- tone: "positive",
42
- };
43
- }
44
-
45
- return {
46
- canUpgrade: false,
47
- text: t("options delayed fallback"),
48
- tone: "warning",
49
- };
15
+ /** Stream coverage the pane reports once the account is entitled to real-time options. */
16
+ export function resolveOptionsCoverageState(status: OptionQuoteCoverageStatus): OptionsCoverageState {
17
+ if (status === "live") return { text: t("real-time options"), tone: "positive" };
18
+ if (status === "mixed") return { text: t("mixed real-time and delayed options"), tone: "warning" };
19
+ if (status === "connecting") return { text: t("connecting real-time options"), tone: "muted" };
20
+ return { text: t("options delayed fallback"), tone: "warning" };
50
21
  }
51
22
 
52
- export function buildOptionsAccessFooterSegment(
53
- state: OptionsAccessFooterState,
54
- openUpgrade: () => void,
55
- ): PaneFooterSegment {
56
- return {
57
- id: "options-access",
58
- ...(state.canUpgrade ? { onPress: openUpgrade } : {}),
59
- parts: [{ text: state.text, tone: state.tone }],
60
- };
23
+ export function resolveOptionsDelayLabel(chain: OptionsChain | null | undefined): string {
24
+ const delayMinutes = chain?.delayMinutes && chain.delayMinutes > 0
25
+ ? chain.delayMinutes
26
+ : CLOUD_QUOTE_DELAY_MINUTES;
27
+ return tf("{count}m", { count: delayMinutes });
61
28
  }
62
29
 
63
30
  export function useOptionsAccessFooter({
64
31
  chain,
65
32
  error,
66
33
  focused,
67
- hasLiveQuote,
68
34
  loading,
35
+ quoteCoverage,
69
36
  }: {
70
37
  chain: OptionsChain | null | undefined;
71
38
  error?: string | null;
72
39
  focused: boolean;
73
- hasLiveQuote: boolean;
74
40
  loading?: boolean;
75
- }): OptionsAccessFooterState {
76
- const rendererHost = useRendererHost();
77
- const user = apiClient.getCurrentUser();
78
- const clientPlan = user?.emailVerified === true && user.plan === "pro" ? "pro" : "free";
79
- const state = resolveOptionsAccessFooterState({
80
- chain,
81
- clientPlan,
82
- hasLiveQuote,
41
+ quoteCoverage: Pick<OptionQuoteCoverage, "status">;
42
+ }): void {
43
+ const { access, segment } = useCloudAccessFooter({
44
+ delayLabel: resolveOptionsDelayLabel(chain),
45
+ focused,
46
+ segmentId: "options-access",
47
+ shortcutScope: "options:upgrade",
83
48
  });
84
- const openUpgrade = useCallback(() => {
85
- void rendererHost.openExternal(CLOUD_UPGRADE_URL);
86
- }, [rendererHost]);
49
+ // Trial accounts stream real-time too, but the countdown is the status worth the row.
50
+ const coverage = access.hasProAccess && !access.isTrialActive
51
+ ? resolveOptionsCoverageState(quoteCoverage.status)
52
+ : null;
87
53
 
88
- useShortcut(
89
- (event) => {
90
- const key = (event.name ?? event.key ?? "").toLowerCase();
91
- if (!focused || !state.canUpgrade || key !== "u") return;
92
- event.stopPropagation();
93
- event.preventDefault();
94
- openUpgrade();
95
- },
96
- { scope: "options:upgrade" },
97
- );
54
+ const info = useMemo<PaneFooterSegment[]>(() => {
55
+ if (coverage) {
56
+ return [{ id: "options-access", parts: [{ text: coverage.text, tone: coverage.tone }] }];
57
+ }
58
+ return segment ? [segment] : [];
59
+ }, [coverage?.text, coverage?.tone, segment]);
98
60
 
99
- const info = useMemo(
100
- () => [buildOptionsAccessFooterSegment(state, openUpgrade)],
101
- [openUpgrade, state.canUpgrade, state.text, state.tone],
102
- );
103
61
  usePaneStatusFooter({
104
62
  registrationId: "options",
105
63
  loading,
106
64
  error,
107
65
  info,
108
66
  });
109
- return state;
110
67
  }
@@ -1,6 +1,10 @@
1
1
  import type { PluginModule } from "../plugin-module";
2
2
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
3
3
  import { OptionsView } from "./view";
4
+ import {
5
+ LIVE_STREAMING_QUICK_SETTING,
6
+ withLiveStreamingSetting,
7
+ } from "../shared/live-streaming";
4
8
 
5
9
  export const optionsModule: PluginModule = {
6
10
  panes: [
@@ -12,6 +16,8 @@ export const optionsModule: PluginModule = {
12
16
  defaultPosition: "right",
13
17
  defaultMode: "floating",
14
18
  defaultFloatingSize: { width: 112, height: 28 },
19
+ quickSettings: [LIVE_STREAMING_QUICK_SETTING],
20
+ settings: (context) => withLiveStreamingSetting({ fields: [] }, context.settings),
15
21
  },
16
22
  ],
17
23
 
@@ -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
  });