gloomberb 0.9.7 → 0.9.8

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 (126) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.ts +48 -0
  4. package/src/api-client/types.ts +45 -0
  5. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  6. package/src/app/runtime/desktop-deeplink.ts +496 -0
  7. package/src/app.tsx +34 -0
  8. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  9. package/src/components/chart/bar-chart-renderer.ts +111 -55
  10. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  11. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  12. package/src/components/chart/stock-chart/footer.ts +68 -13
  13. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  14. package/src/components/command-bar/pane-templates/items.ts +11 -3
  15. package/src/components/command-bar/routes/root/results.ts +1 -1
  16. package/src/components/command-bar/surface/index.test.tsx +52 -0
  17. package/src/components/command-bar/workflow/ops.ts +81 -20
  18. package/src/components/index.ts +1 -0
  19. package/src/components/toggle-list.tsx +29 -73
  20. package/src/components/ui/button.tsx +4 -1
  21. package/src/components/ui/checkbox.tsx +76 -0
  22. package/src/components/ui/choice-dialog.tsx +15 -3
  23. package/src/components/ui/frame.tsx +1 -1
  24. package/src/components/ui/index.ts +2 -0
  25. package/src/components/ui/message-composer.tsx +4 -0
  26. package/src/components/ui/native-select.tsx +4 -2
  27. package/src/market-data/coordinator/events.test.ts +49 -0
  28. package/src/market-data/coordinator/events.ts +25 -4
  29. package/src/market-data/coordinator/index.test.ts +223 -0
  30. package/src/market-data/coordinator/index.ts +40 -3
  31. package/src/market-data/coordinator/quotes.ts +13 -5
  32. package/src/market-data/hooks.tsx +17 -13
  33. package/src/market-data/market/freshness.ts +58 -1
  34. package/src/market-data/quotes/freshness.test.ts +49 -1
  35. package/src/market-data/quotes/freshness.ts +8 -0
  36. package/src/market-data/quotes/resolution.test.ts +185 -0
  37. package/src/market-data/quotes/resolution.ts +94 -8
  38. package/src/market-data/request-types.test.ts +61 -1
  39. package/src/market-data/request-types.ts +42 -3
  40. package/src/plugins/builtin/account-management/footer.ts +2 -5
  41. package/src/plugins/builtin/account-management/form-components.tsx +260 -24
  42. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  43. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  44. package/src/plugins/builtin/account-management/model.ts +263 -3
  45. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  46. package/src/plugins/builtin/chat/chat.test.tsx +125 -0
  47. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  48. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  49. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  50. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  51. package/src/plugins/builtin/chat/content/index.tsx +154 -16
  52. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  53. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  54. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  55. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  56. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  57. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  58. package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
  59. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  60. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  61. package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
  62. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  63. package/src/plugins/builtin/chat/test-harness.tsx +21 -11
  64. package/src/plugins/builtin/cloud/plugin.tsx +7 -0
  65. package/src/plugins/builtin/correlation/index.tsx +4 -1
  66. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  67. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  68. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  69. package/src/plugins/builtin/portfolio-list/index.test.ts +51 -1
  70. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  71. package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
  72. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
  73. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
  74. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
  75. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
  76. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  77. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  78. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  79. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  80. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  81. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  82. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  83. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  84. package/src/plugins/registry/context.ts +15 -0
  85. package/src/plugins/registry/index.ts +31 -0
  86. package/src/renderers/electrobun/bun/index.ts +42 -0
  87. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  88. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  89. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  90. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  91. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  92. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  93. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  94. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  95. package/src/renderers/electrobun/view/main.tsx +3 -0
  96. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  97. package/src/sources/provider-router/batches.ts +11 -3
  98. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  99. package/src/sources/provider-router/financial-routes.ts +55 -5
  100. package/src/sources/provider-router/financials.test.ts +49 -0
  101. package/src/sources/provider-router/financials.ts +40 -3
  102. package/src/sources/provider-router/index.test.ts +147 -0
  103. package/src/sources/provider-router/primary.ts +17 -3
  104. package/src/sources/provider-router/streaming.ts +6 -1
  105. package/src/sources/sec-edgar.test.ts +42 -0
  106. package/src/sources/sec-edgar.ts +1 -2
  107. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  108. package/src/sources/yahoo-finance/mappers.ts +2 -1
  109. package/src/sources/yahoo-finance/requests.ts +2 -1
  110. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  111. package/src/sources/yahoo-finance.ts +6 -2
  112. package/src/state/hooks/quote-streaming.test.ts +14 -1
  113. package/src/state/hooks/quote-streaming.ts +5 -4
  114. package/src/sync/controller.ts +296 -0
  115. package/src/sync/core-contributors.test.ts +251 -0
  116. package/src/sync/core-contributors.ts +400 -0
  117. package/src/sync/profile-analytics.ts +39 -0
  118. package/src/sync/react.ts +53 -0
  119. package/src/sync/types.ts +77 -0
  120. package/src/types/desktop-deeplink.ts +7 -0
  121. package/src/types/plugin.ts +3 -0
  122. package/src/ui/host.tsx +14 -1
  123. package/src/ui/index.tsx +1 -0
  124. package/src/utils/exchanges.test.ts +11 -1
  125. package/src/utils/exchanges.ts +5 -1
  126. package/src/version.ts +1 -1
@@ -1,7 +1,9 @@
1
- import type { PaneTemplateCreateOptions } from "../../../../../types/plugin";
2
- import { formatTickerListInput, parseTickerListInput } from "../../../../../tickers/list";
1
+ import type { PaneSettingsDef, PaneTemplateCreateOptions } from "../../../../../types/plugin";
2
+ import { formatTickerListInput, MAX_TICKER_LIST_SIZE, parseTickerListInput } from "../../../../../tickers/list";
3
3
  import type { GraphKind } from "./types";
4
4
 
5
+ export const FUNDAMENTAL_GRAPH_PANE_ID = "fundamental-graph";
6
+
5
7
  export function symbolsFromPaneSettings(settings: Record<string, unknown> | undefined, fallbackSymbol: string | null): string[] {
6
8
  const symbols = settings?.symbols;
7
9
  if (Array.isArray(symbols)) {
@@ -26,6 +28,10 @@ export function graphKindFromSettings(settings: Record<string, unknown> | undefi
26
28
  return settings?.chartKind === "valuation" ? "valuation" : fallback;
27
29
  }
28
30
 
31
+ export function graphShortcutForKind(kind: GraphKind): "GF" | "GE" {
32
+ return kind === "valuation" ? "GE" : "GF";
33
+ }
34
+
29
35
  export function graphTemplateSymbols(
30
36
  activeTicker: string | null,
31
37
  options: Pick<PaneTemplateCreateOptions, "arg" | "values" | "symbols"> | undefined,
@@ -42,3 +48,18 @@ export function graphTemplateSymbols(
42
48
  export function graphTemplateTitle(shortcut: "GF" | "GE", symbols: string[]): string {
43
49
  return `${shortcut} ${formatTickerListInput(symbols)}`;
44
50
  }
51
+
52
+ export function buildGraphPaneSettingsDef(): PaneSettingsDef {
53
+ return {
54
+ title: "Graph Pane Settings",
55
+ fields: [
56
+ {
57
+ key: "symbolsText",
58
+ label: "Tickers",
59
+ description: `Enter up to ${MAX_TICKER_LIST_SIZE} tickers separated by commas.`,
60
+ type: "text",
61
+ placeholder: "AMD, NVDA",
62
+ },
63
+ ],
64
+ };
65
+ }
@@ -1,13 +1,17 @@
1
1
  import type { DataTableColumn } from "../../../../../components";
2
- import type { TickerFinancials } from "../../../../../types/financials";
2
+ import type { FinancialStatement, TickerFinancials } from "../../../../../types/financials";
3
3
 
4
4
  export type FundamentalMetricKey =
5
5
  | "totalRevenue"
6
6
  | "grossProfit"
7
+ | "grossMargin"
7
8
  | "operatingIncome"
9
+ | "operatingMargin"
8
10
  | "netIncome"
11
+ | "netMargin"
9
12
  | "operatingCashFlow"
10
13
  | "freeCashFlow"
14
+ | "freeCashFlowMargin"
11
15
  | "totalAssets"
12
16
  | "totalDebt"
13
17
  | "totalEquity"
@@ -42,6 +46,9 @@ export type MetricDefinition<Key extends GraphMetricKey = GraphMetricKey> = {
42
46
  key: Key;
43
47
  label: string;
44
48
  format: (value: number) => string;
49
+ value?: Key extends FundamentalMetricKey
50
+ ? (statement: FinancialStatement) => number | null | undefined
51
+ : never;
45
52
  };
46
53
 
47
54
  export type SymbolFinancials = {
@@ -12,6 +12,7 @@ import {
12
12
  isMetricForKind,
13
13
  } from "./fundamental-graph/model";
14
14
  import {
15
+ FUNDAMENTAL_GRAPH_PANE_ID,
15
16
  graphKindFromSettings,
16
17
  graphTemplateSymbols,
17
18
  graphTemplateTitle,
@@ -20,6 +21,7 @@ import {
20
21
  import type { FundamentalPeriod, GraphKind, GraphMetricKey } from "./fundamental-graph/types";
21
22
 
22
23
  export { buildFundamentalGraphRows, buildValuationGraphRows } from "./fundamental-graph/model";
24
+ export { buildGraphPaneSettingsDef, FUNDAMENTAL_GRAPH_PANE_ID } from "./fundamental-graph/settings";
23
25
 
24
26
  export function FundamentalGraphPane({ focused, width, height }: PaneProps) {
25
27
  const pane = usePaneInstance();
@@ -99,7 +101,7 @@ export function createGraphPaneTemplate({
99
101
  }): PaneTemplateDef {
100
102
  return {
101
103
  id,
102
- paneId: "fundamental-graph",
104
+ paneId: FUNDAMENTAL_GRAPH_PANE_ID,
103
105
  label,
104
106
  description,
105
107
  keywords: chartKind === "valuation"
@@ -1,9 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { buildRelationshipAnalysis, createRelationshipPaneTemplate } from "../correlation/relationship/pane";
3
3
  import { buildFundamentalGraphRows, buildValuationGraphRows } from "./data-panes/fundamental-graph";
4
+ import { allMetricDefs, buildGraphBarSeries, graphRowsForFinancials } from "./data-panes/fundamental-graph/model";
4
5
  import { buildHistoricalPriceRows } from "./data-panes/historical-prices";
5
6
  import { createDefaultConfig } from "../../../types/config";
6
- import type { FinancialStatement, PricePoint } from "../../../types/financials";
7
+ import type { FinancialStatement, PricePoint, TickerFinancials } from "../../../types/financials";
7
8
 
8
9
  describe("ticker data panes", () => {
9
10
  test("builds historical price rows with close-to-close changes", () => {
@@ -65,6 +66,55 @@ describe("ticker data panes", () => {
65
66
  ]);
66
67
  });
67
68
 
69
+ test("orders multi-symbol chart categories by statement date", () => {
70
+ const series = buildGraphBarSeries([
71
+ { key: "NVDA:2025-01-31", symbol: "NVDA", date: "2025-01-31", category: "2025 Q1", value: 1, growth: null, barWidth: 1 },
72
+ { key: "NVDA:2026-04-30", symbol: "NVDA", date: "2026-04-30", category: "2026 Q2", value: 2, growth: null, barWidth: 1 },
73
+ { key: "AMD:2024-12-31", symbol: "AMD", date: "2024-12-31", category: "2024 Q4", value: 3, growth: null, barWidth: 1 },
74
+ ]);
75
+
76
+ expect(series[0]?.points.map((point) => point.category)).toEqual(["2024 Q4", "2025 Q1", "2026 Q2"]);
77
+ expect(series[1]?.points.map((point) => point.category)).toEqual(["2024 Q4", "2025 Q1", "2026 Q2"]);
78
+ });
79
+
80
+ test("derives free cash flow and margins for graph metrics", () => {
81
+ const fcfRows = buildFundamentalGraphRows([
82
+ { date: "2025-03-31", operatingCashFlow: 100, capitalExpenditure: -25 },
83
+ { date: "2025-06-30", freeCashFlow: 90, operatingCashFlow: 120, capitalExpenditure: -30 },
84
+ ] satisfies FinancialStatement[], "freeCashFlow", "NVDA", "quarterly");
85
+ const marginRows = buildFundamentalGraphRows([
86
+ { date: "2025-03-31", totalRevenue: 200, grossProfit: 100 },
87
+ ] satisfies FinancialStatement[], "grossMargin", "AMD", "quarterly");
88
+
89
+ expect(fcfRows.map((row) => row.value)).toEqual([75, 90]);
90
+ expect(marginRows[0]?.value).toBe(0.5);
91
+ expect(allMetricDefs().map(({ definition }) => definition.key)).toContain("freeCashFlowMargin");
92
+ });
93
+
94
+ test("fills missing Q4 margin rows from annual totals and preceding quarters", () => {
95
+ const financials = {
96
+ annualStatements: [
97
+ { date: "2025-12-31", totalRevenue: 1_000, grossProfit: 500 },
98
+ ],
99
+ quarterlyStatements: [
100
+ { date: "2025-03-31", totalRevenue: 100, grossProfit: 40 },
101
+ { date: "2025-06-30", totalRevenue: 200, grossProfit: 90 },
102
+ { date: "2025-09-30", totalRevenue: 300, grossProfit: 150 },
103
+ { date: "2025-12-31" },
104
+ ],
105
+ priceHistory: [],
106
+ } satisfies TickerFinancials;
107
+
108
+ const rows = graphRowsForFinancials(financials, "fundamental", "grossMargin", "quarterly", "AMD");
109
+
110
+ expect(rows.map((row) => [row.category, row.value])).toEqual([
111
+ ["2025 Q1", 0.4],
112
+ ["2025 Q2", 0.45],
113
+ ["2025 Q3", 0.5],
114
+ ["2025 Q4", 220 / 400],
115
+ ]);
116
+ });
117
+
68
118
  test("builds valuation graph rows from available multiples", () => {
69
119
  const rows = buildValuationGraphRows({
70
120
  quote: { symbol: "AMD", price: 100, currency: "USD", change: 0, changePercent: 0, lastUpdated: 1, marketCap: 1_000 },
@@ -83,6 +133,50 @@ describe("ticker data panes", () => {
83
133
  ]);
84
134
  });
85
135
 
136
+ test("builds historical trailing and forward P/E rows from prices and EPS", () => {
137
+ const financials = {
138
+ quote: { symbol: "META", price: 320, currency: "USD", change: 0, changePercent: 0, lastUpdated: 1 },
139
+ fundamentals: { forwardPE: 16 },
140
+ annualStatements: [
141
+ { date: "2024-12-31", eps: 5 },
142
+ { date: "2025-12-31", eps: 10 },
143
+ { date: "2026-12-31", eps: 20 },
144
+ ],
145
+ quarterlyStatements: [
146
+ { date: "2025-03-31", eps: 1 },
147
+ { date: "2025-06-30", eps: 2 },
148
+ { date: "2025-09-30", eps: 3 },
149
+ { date: "2025-12-31", eps: 4 },
150
+ { date: "2026-03-31", eps: 5 },
151
+ ],
152
+ priceHistory: [
153
+ { date: new Date("2024-12-31T00:00:00Z"), close: 100 },
154
+ { date: new Date("2025-12-31T00:00:00Z"), close: 150 },
155
+ { date: new Date("2026-03-31T00:00:00Z"), close: 180 },
156
+ { date: new Date("2026-12-31T00:00:00Z"), close: 300 },
157
+ ],
158
+ } satisfies TickerFinancials;
159
+
160
+ const trailingAnnual = buildValuationGraphRows(financials, "trailingPE", "META", "annual");
161
+ const forwardAnnual = buildValuationGraphRows(financials, "forwardPE", "META", "annual");
162
+ const trailingQuarterly = buildValuationGraphRows(financials, "trailingPE", "META", "quarterly");
163
+
164
+ expect(trailingAnnual.map((row) => [row.category, row.value])).toEqual([
165
+ ["FY2024", 20],
166
+ ["FY2025", 15],
167
+ ["FY2026", 15],
168
+ ]);
169
+ expect(forwardAnnual.map((row) => [row.category, row.value])).toEqual([
170
+ ["FY2024", 10],
171
+ ["FY2025", 7.5],
172
+ ["Current", 16],
173
+ ]);
174
+ expect(trailingQuarterly.map((row) => [row.category, row.value])).toEqual([
175
+ ["2025 Q4", 15],
176
+ ["2026 Q1", 180 / 14],
177
+ ]);
178
+ });
179
+
86
180
  test("builds relationship analysis from overlapping price history", () => {
87
181
  const dates = ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-05", "2026-01-06"];
88
182
  const makeHistory = (closes: number[]) => dates.map((date, index) => ({
@@ -5,6 +5,7 @@ import { normalizeTickerInput } from "../../../tickers/search";
5
5
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
6
6
  import { TickerChartPane, ChartResearchTab } from "./chart-pane";
7
7
  import {
8
+ buildGraphPaneSettingsDef,
8
9
  createGraphPaneTemplate,
9
10
  FundamentalGraphPane,
10
11
  FundamentalGraphsResearchTab,
@@ -128,6 +129,7 @@ export const tickerDetailPlugin: GloomPlugin = {
128
129
  defaultPosition: "right",
129
130
  defaultMode: "floating",
130
131
  defaultFloatingSize: { width: 82, height: 22 },
132
+ settings: buildGraphPaneSettingsDef(),
131
133
  },
132
134
  {
133
135
  id: "provider-search-results",
@@ -6,7 +6,7 @@ import {
6
6
  } from "@stoqey/ib";
7
7
  import type { QuoteSubscriptionTarget } from "../../../types/data-provider";
8
8
  import type { Quote } from "../../../types/financials";
9
- import { canonicalExchange, normalizeSymbol } from "../../../utils/exchanges";
9
+ import { normalizeSymbol } from "../../../utils/exchanges";
10
10
  import type { IbkrGatewayConfig } from "./types";
11
11
  import { getIbkrPriceDivisor, normalizeIbkrPriceValue } from "./price-normalization";
12
12
 
@@ -35,7 +35,7 @@ export function normalizeQuoteStreamTarget(target: QuoteSubscriptionTarget): Quo
35
35
  return {
36
36
  ...target,
37
37
  symbol,
38
- exchange: canonicalExchange(target.exchange),
38
+ exchange: target.exchange?.trim().toUpperCase() ?? "",
39
39
  };
40
40
  }
41
41
 
@@ -136,15 +136,22 @@ export function applyTickByTickAllLastToQuote(
136
136
  const nextPrice = normalizeIbkrPriceValue(tick.price, priceDivisor);
137
137
  if (nextPrice == null || nextPrice <= 0) return null;
138
138
 
139
- const previousClose = current?.previousClose ?? current?.price ?? nextPrice;
140
- const change = nextPrice - previousClose;
139
+ const canInferPreviousClose = current?.price != null
140
+ && current.change != null
141
+ && (current.change !== 0 || current.changePercent !== 0);
142
+ const inferredPreviousClose = current?.previousClose
143
+ ?? (canInferPreviousClose ? current.price - current.change : undefined);
144
+ const previousClose = inferredPreviousClose != null && Number.isFinite(inferredPreviousClose) && inferredPreviousClose > 0
145
+ ? inferredPreviousClose
146
+ : undefined;
147
+ const change = previousClose != null ? nextPrice - previousClose : current?.change ?? 0;
141
148
  return {
142
149
  symbol: current?.symbol ?? contract.localSymbol ?? contract.symbol ?? "",
143
150
  providerId: "ibkr",
144
151
  price: nextPrice,
145
152
  currency: current?.currency ?? contract.currency ?? "USD",
146
153
  change,
147
- changePercent: previousClose ? (change / previousClose) * 100 : 0,
154
+ changePercent: previousClose ? (change / previousClose) * 100 : current?.changePercent ?? 0,
148
155
  previousClose,
149
156
  high52w: current?.high52w,
150
157
  low52w: current?.low52w,
@@ -312,6 +312,37 @@ describe("tick-by-tick quote updates", () => {
312
312
  expect(next?.lastUpdated).toBe(1_700_000_000_000);
313
313
  });
314
314
 
315
+ test("does not use the previous tick as the daily close when no close is known", () => {
316
+ const current = {
317
+ symbol: "AAPL",
318
+ providerId: "ibkr" as const,
319
+ price: 249,
320
+ currency: "USD",
321
+ change: 0,
322
+ changePercent: 0,
323
+ name: "Apple Inc.",
324
+ lastUpdated: 1000,
325
+ dataSource: "live" as const,
326
+ };
327
+ const tick: TickByTickAllLast = {
328
+ time: 1_700_000_000,
329
+ price: 250.5,
330
+ size: 100,
331
+ tickType: 1,
332
+ tickAttribLast: {},
333
+ exchange: "NASDAQ",
334
+ specialConditions: "",
335
+ contract: contract as any,
336
+ };
337
+
338
+ const next = applyTickByTickAllLastToQuote(current, contract as any, details, tick, 1, "live");
339
+
340
+ expect(next?.price).toBe(250.5);
341
+ expect(next?.previousClose).toBeUndefined();
342
+ expect(next?.change).toBe(0);
343
+ expect(next?.changePercent).toBe(0);
344
+ });
345
+
315
346
  test("applies bid/ask ticks without disturbing the current trade price", () => {
316
347
  const current = {
317
348
  symbol: "AAPL",
@@ -14,6 +14,7 @@ import type {
14
14
  } from "../../types/plugin";
15
15
  import type { PaneRuntimeState } from "../../core/state/app/state";
16
16
  import type { NewsQuery, NewsQueryState } from "../../types/news-source";
17
+ import type { SyncContributor, SyncTransport } from "../../sync/types";
17
18
  import { debugLog } from "../../utils/debug-log";
18
19
  import { createPluginPersistence } from "../plugin-persistence";
19
20
  import type { PluginEvents } from "../event-bus";
@@ -35,6 +36,8 @@ export interface RegistryPluginContextOptions {
35
36
  updateLayout: (layout: LayoutConfig) => void;
36
37
  resolvePaneTarget: (paneId: string) => string | undefined;
37
38
  registerCapabilityForPlugin: (pluginId: string, capability: PluginCapability, items: PluginItems) => void;
39
+ registerSyncContributorForPlugin: (pluginId: string, contributor: SyncContributor) => () => void;
40
+ registerSyncTransportForPlugin: (pluginId: string, transport: SyncTransport) => () => void;
38
41
  watchNewsQuery: (query: NewsQuery, listener: (state: NewsQueryState) => void) => () => void;
39
42
  getData: (ticker: string) => TickerFinancials | null;
40
43
  getTicker: (symbol: string) => TickerRecord | null;
@@ -82,6 +85,8 @@ export function createRegistryPluginContext({
82
85
  updateLayout,
83
86
  resolvePaneTarget,
84
87
  registerCapabilityForPlugin,
88
+ registerSyncContributorForPlugin,
89
+ registerSyncTransportForPlugin,
85
90
  watchNewsQuery,
86
91
  getData,
87
92
  getTicker,
@@ -143,6 +148,16 @@ export function createRegistryPluginContext({
143
148
  registerShortcut: (shortcut) => contributions.registerShortcut(pluginId, shortcut, items),
144
149
  registerTickerAction: (action) => contributions.registerTickerAction(pluginId, action, items),
145
150
  registerContextMenuProvider: (provider) => contributions.registerContextMenuProvider(pluginId, provider, items),
151
+ registerSyncContributor: (contributor) => {
152
+ const dispose = registerSyncContributorForPlugin(pluginId, contributor);
153
+ items.eventDisposers.push(dispose);
154
+ return dispose;
155
+ },
156
+ registerSyncTransport: (transport) => {
157
+ const dispose = registerSyncTransportForPlugin(pluginId, transport);
158
+ items.eventDisposers.push(dispose);
159
+ return dispose;
160
+ },
146
161
  watchNewsQuery: (query, listener) => {
147
162
  const dispose = watchNewsQuery(query, listener);
148
163
  items.newsQueryWatchDisposers.push(dispose);
@@ -27,6 +27,12 @@ import type {
27
27
  } from "../../types/plugin";
28
28
  import type { ContextMenuContext, ContextMenuItem } from "../../types/context-menu";
29
29
  import type { TickerRecord } from "../../types/ticker";
30
+ import type {
31
+ RegisteredSyncContributor,
32
+ RegisteredSyncTransport,
33
+ SyncContributor,
34
+ SyncTransport,
35
+ } from "../../sync/types";
30
36
  import { EventBus } from "../event-bus";
31
37
  import { resolvePaneInstance } from "../../types/config";
32
38
  import { debugLog } from "../../utils/debug-log";
@@ -49,6 +55,7 @@ import {
49
55
  releaseSharedRegistry,
50
56
  } from "./shared";
51
57
  import { RegistryResumeStateListeners } from "./plugin-state";
58
+ import { cloudSyncController } from "../../sync/controller";
52
59
 
53
60
  interface PluginRegistryOptions {
54
61
  enableCapabilityHandlers?: boolean;
@@ -215,6 +222,28 @@ export class PluginRegistry implements PluginRuntimeAccess {
215
222
  get tickerActions(): ReadonlyMap<string, TickerAction> { return this.contributions.tickerActionsMap; }
216
223
  get allPlugins(): ReadonlyMap<string, GloomPlugin> { return this.plugins; }
217
224
 
225
+ registerSyncContributorForPlugin(pluginId: string, contributor: SyncContributor): () => void {
226
+ return cloudSyncController.registerContributor(pluginId, contributor);
227
+ }
228
+
229
+ registerSyncTransportForPlugin(pluginId: string, transport: SyncTransport): () => void {
230
+ return cloudSyncController.registerTransport(pluginId, transport);
231
+ }
232
+
233
+ getEnabledSyncContributors(): RegisteredSyncContributor[] {
234
+ const disabledPlugins = new Set(this.getConfigFn().disabledPlugins ?? []);
235
+ return cloudSyncController
236
+ .getRegisteredContributors()
237
+ .filter((entry) => !disabledPlugins.has(entry.pluginId));
238
+ }
239
+
240
+ getActiveSyncTransport(): RegisteredSyncTransport | null {
241
+ const disabledPlugins = new Set(this.getConfigFn().disabledPlugins ?? []);
242
+ return cloudSyncController
243
+ .getRegisteredTransports()
244
+ .find((entry) => !disabledPlugins.has(entry.pluginId) && entry.transport.isAvailable()) ?? null;
245
+ }
246
+
218
247
  getContextMenuItems(context: ContextMenuContext): ContextMenuItem[] {
219
248
  return resolveRegistryContextMenuItems({
220
249
  context,
@@ -373,6 +402,8 @@ export class PluginRegistry implements PluginRuntimeAccess {
373
402
  updateLayout: (layout) => this.updateLayoutFn(layout),
374
403
  resolvePaneTarget: (paneId) => this.resolvePaneTarget(paneId),
375
404
  registerCapabilityForPlugin: (targetPluginId, capability, pluginItems) => this.registerCapabilityForPlugin(targetPluginId, capability, pluginItems),
405
+ registerSyncContributorForPlugin: (targetPluginId, contributor) => this.registerSyncContributorForPlugin(targetPluginId, contributor),
406
+ registerSyncTransportForPlugin: (targetPluginId, transport) => this.registerSyncTransportForPlugin(targetPluginId, transport),
376
407
  watchNewsQuery: (query, listener) => this.watchNewsQueryFn(query, listener),
377
408
  getData: (ticker) => this.getDataFn(ticker),
378
409
  getTicker: (symbol) => this.getTickerFn(symbol),
@@ -103,6 +103,41 @@ function requireDesktopWorkspace(): DesktopWorkspace {
103
103
  return desktopWorkspace;
104
104
  }
105
105
 
106
+ const pendingDesktopDeepLinks: string[] = [];
107
+
108
+ function isGloomberbDeepLink(rawUrl: string): boolean {
109
+ try {
110
+ return new URL(rawUrl).protocol === "gloomberb:";
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ function readOpenUrlEvent(event: unknown): string | null {
117
+ const data = event && typeof event === "object" ? (event as { data?: unknown }).data : null;
118
+ if (!data || typeof data !== "object") return null;
119
+ const url = (data as { url?: unknown }).url;
120
+ return typeof url === "string" && url ? url : null;
121
+ }
122
+
123
+ function sendDesktopDeepLink(rawUrl: string): void {
124
+ if (!isGloomberbDeepLink(rawUrl)) return;
125
+ const rpc = getWindowRpc(MAIN_WINDOW_RPC_KEY);
126
+ if (!rpc || !isWindowRpcReady(MAIN_WINDOW_RPC_KEY)) {
127
+ pendingDesktopDeepLinks.push(rawUrl);
128
+ if (pendingDesktopDeepLinks.length > 20) pendingDesktopDeepLinks.shift();
129
+ return;
130
+ }
131
+ detachedWindowManager.focusWindowForRpcKey(MAIN_WINDOW_RPC_KEY);
132
+ rpc.send["desktop.deepLink"]({ url: rawUrl });
133
+ }
134
+
135
+ function flushPendingDesktopDeepLinks(): void {
136
+ if (pendingDesktopDeepLinks.length === 0) return;
137
+ const urls = pendingDesktopDeepLinks.splice(0);
138
+ for (const url of urls) sendDesktopDeepLink(url);
139
+ }
140
+
106
141
  function remoteFailure(code: string, message: string): RemoteControlResponse {
107
142
  return { ok: false, error: { code, message } };
108
143
  }
@@ -377,6 +412,7 @@ async function initialize(
377
412
  syncConfigAccessors,
378
413
  });
379
414
  if (init.windowKind === "main") {
415
+ flushPendingDesktopDeepLinks();
380
416
  void ensureDesktopRemoteControlServer().catch((error) => {
381
417
  console.error("[remote] desktop control endpoint failed", summarizeError(error));
382
418
  });
@@ -502,6 +538,12 @@ Electrobun.events.on("context-menu-clicked", (event: unknown) => {
502
538
  });
503
539
  });
504
540
 
541
+ Electrobun.events.on("open-url", (event: unknown) => {
542
+ const url = readOpenUrlEvent(event);
543
+ if (!url) return;
544
+ sendDesktopDeepLink(url);
545
+ });
546
+
505
547
  ApplicationMenu.on("application-menu-clicked", (event: unknown) => {
506
548
  const command = applicationMenuCommand(event);
507
549
  if (!command) return;
@@ -60,6 +60,10 @@ export interface DesktopRestartMessage {
60
60
  source?: string;
61
61
  }
62
62
 
63
+ export interface DesktopDeepLinkMessage {
64
+ url: string;
65
+ }
66
+
63
67
  export interface RemoteControlRequestMessage {
64
68
  request: RemoteControlRequest;
65
69
  }
@@ -89,6 +93,7 @@ export interface ElectrobunDesktopRpcSchema {
89
93
  "desktop.state": DesktopStateMessage;
90
94
  "desktop.dockPreview": DesktopDockPreviewMessage;
91
95
  "desktop.themePreview": DesktopThemePreviewMessage;
96
+ "desktop.deepLink": DesktopDeepLinkMessage;
92
97
  "update.progress": UpdateProgressMessage;
93
98
  "capability.event": CapabilityEventMessage;
94
99
  };
@@ -4,6 +4,7 @@ import {
4
4
  type ApplicationMenuSelectMessage,
5
5
  type CapabilityEventMessage,
6
6
  type ContextMenuSelectMessage,
7
+ type DesktopDeepLinkMessage,
7
8
  type DesktopDockPreviewMessage,
8
9
  type DesktopRestartMessage,
9
10
  type DesktopStateMessage,
@@ -18,6 +19,7 @@ import type { RemoteControlRequest, RemoteControlResponse } from "../../../remot
18
19
 
19
20
  type ContextMenuSelectListener = (message: ContextMenuSelectMessage) => void;
20
21
  type ApplicationMenuSelectListener = (message: ApplicationMenuSelectMessage) => void;
22
+ type DesktopDeepLinkListener = (message: DesktopDeepLinkMessage) => void;
21
23
  type DesktopStateListener = (message: DesktopStateMessage) => void;
22
24
  type DesktopDockPreviewListener = (message: DesktopDockPreviewMessage) => void;
23
25
  type DesktopThemePreviewListener = (message: DesktopThemePreviewMessage) => void;
@@ -29,6 +31,8 @@ let initSnapshot: ElectrobunBackendInit | null = null;
29
31
  let remoteControlRequestHandler: RemoteControlRequestHandler | null = null;
30
32
  const contextMenuSelectListeners = new Map<string, Set<ContextMenuSelectListener>>();
31
33
  const applicationMenuSelectListeners = new Set<ApplicationMenuSelectListener>();
34
+ const desktopDeepLinkListeners = new Set<DesktopDeepLinkListener>();
35
+ const pendingDesktopDeepLinks: DesktopDeepLinkMessage[] = [];
32
36
  const desktopStateListeners = new Set<DesktopStateListener>();
33
37
  const desktopDockPreviewListeners = new Set<DesktopDockPreviewListener>();
34
38
  const desktopThemePreviewListeners = new Set<DesktopThemePreviewListener>();
@@ -64,6 +68,17 @@ function subscribe<T>(
64
68
  };
65
69
  }
66
70
 
71
+ function dispatchDesktopDeepLink(message: DesktopDeepLinkMessage): void {
72
+ if (desktopDeepLinkListeners.size === 0) {
73
+ pendingDesktopDeepLinks.push(message);
74
+ if (pendingDesktopDeepLinks.length > 20) pendingDesktopDeepLinks.shift();
75
+ return;
76
+ }
77
+ for (const listener of desktopDeepLinkListeners) {
78
+ listener(message);
79
+ }
80
+ }
81
+
67
82
  const rpc = Electroview.defineRPC<ElectrobunDesktopRpcSchema>({
68
83
  maxRequestTime: 120_000,
69
84
  handlers: {
@@ -92,6 +107,10 @@ const rpc = Electroview.defineRPC<ElectrobunDesktopRpcSchema>({
92
107
  listener(decoded);
93
108
  }
94
109
  },
110
+ "desktop.deepLink": (message) => {
111
+ if (typeof message.url !== "string" || !message.url) return;
112
+ dispatchDesktopDeepLink({ url: message.url });
113
+ },
95
114
  "desktop.state": (message) => {
96
115
  for (const listener of desktopStateListeners) {
97
116
  listener({ snapshot: decodeRpcValue(message.snapshot) });
@@ -188,6 +207,16 @@ export function onApplicationMenuSelect(listener: ApplicationMenuSelectListener)
188
207
  };
189
208
  }
190
209
 
210
+ export function onDesktopDeepLink(listener: DesktopDeepLinkListener): () => void {
211
+ desktopDeepLinkListeners.add(listener);
212
+ for (const message of pendingDesktopDeepLinks.splice(0)) {
213
+ listener(message);
214
+ }
215
+ return () => {
216
+ desktopDeepLinkListeners.delete(listener);
217
+ };
218
+ }
219
+
191
220
  export function onDesktopState(listener: DesktopStateListener): () => void {
192
221
  desktopStateListeners.add(listener);
193
222
  return () => {