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.
- package/package.json +1 -1
- package/src/api-client/auth.ts +20 -0
- package/src/api-client/index.ts +48 -0
- package/src/api-client/types.ts +45 -0
- package/src/app/runtime/desktop-deeplink.test.ts +87 -0
- package/src/app/runtime/desktop-deeplink.ts +496 -0
- package/src/app.tsx +34 -0
- package/src/components/chart/bar-chart-renderer.test.ts +87 -12
- package/src/components/chart/bar-chart-renderer.ts +111 -55
- package/src/components/chart/static/bar-chart-surface.tsx +104 -20
- package/src/components/chart/stock-chart/footer.test.ts +65 -0
- package/src/components/chart/stock-chart/footer.ts +68 -13
- package/src/components/chart/stock-chart/resolved.tsx +18 -4
- package/src/components/command-bar/pane-templates/items.ts +11 -3
- package/src/components/command-bar/routes/root/results.ts +1 -1
- package/src/components/command-bar/surface/index.test.tsx +52 -0
- package/src/components/command-bar/workflow/ops.ts +81 -20
- package/src/components/index.ts +1 -0
- package/src/components/toggle-list.tsx +29 -73
- package/src/components/ui/button.tsx +4 -1
- package/src/components/ui/checkbox.tsx +76 -0
- package/src/components/ui/choice-dialog.tsx +15 -3
- package/src/components/ui/frame.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/message-composer.tsx +4 -0
- package/src/components/ui/native-select.tsx +4 -2
- package/src/market-data/coordinator/events.test.ts +49 -0
- package/src/market-data/coordinator/events.ts +25 -4
- package/src/market-data/coordinator/index.test.ts +223 -0
- package/src/market-data/coordinator/index.ts +40 -3
- package/src/market-data/coordinator/quotes.ts +13 -5
- package/src/market-data/hooks.tsx +17 -13
- package/src/market-data/market/freshness.ts +58 -1
- package/src/market-data/quotes/freshness.test.ts +49 -1
- package/src/market-data/quotes/freshness.ts +8 -0
- package/src/market-data/quotes/resolution.test.ts +185 -0
- package/src/market-data/quotes/resolution.ts +94 -8
- package/src/market-data/request-types.test.ts +61 -1
- package/src/market-data/request-types.ts +42 -3
- package/src/plugins/builtin/account-management/footer.ts +2 -5
- package/src/plugins/builtin/account-management/form-components.tsx +260 -24
- package/src/plugins/builtin/account-management/keyboard.ts +32 -2
- package/src/plugins/builtin/account-management/model.test.ts +99 -4
- package/src/plugins/builtin/account-management/model.ts +263 -3
- package/src/plugins/builtin/account-management/pane.tsx +784 -155
- package/src/plugins/builtin/chat/chat.test.tsx +125 -0
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
- package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
- package/src/plugins/builtin/chat/content/composer.tsx +84 -1
- package/src/plugins/builtin/chat/content/index.tsx +154 -16
- package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
- package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
- package/src/plugins/builtin/chat/content/mentions.ts +88 -0
- package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
- package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
- package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
- package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
- package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
- package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
- package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
- package/src/plugins/builtin/chat/sidebar.tsx +26 -2
- package/src/plugins/builtin/chat/test-harness.tsx +21 -11
- package/src/plugins/builtin/cloud/plugin.tsx +7 -0
- package/src/plugins/builtin/correlation/index.tsx +4 -1
- package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
- package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
- package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
- package/src/plugins/builtin/portfolio-list/index.test.ts +51 -1
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
- package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/ibkr/gateway/market-data.ts +12 -5
- package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
- package/src/plugins/registry/context.ts +15 -0
- package/src/plugins/registry/index.ts +31 -0
- package/src/renderers/electrobun/bun/index.ts +42 -0
- package/src/renderers/electrobun/shared/protocol.ts +5 -0
- package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
- package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
- package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
- package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
- package/src/renderers/electrobun/view/host/box.tsx +0 -2
- package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
- package/src/renderers/electrobun/view/host/text.tsx +1 -2
- package/src/renderers/electrobun/view/main.tsx +3 -0
- package/src/renderers/electrobun/view/ui-host.tsx +2 -0
- package/src/sources/provider-router/batches.ts +11 -3
- package/src/sources/provider-router/cached-financials.test.ts +123 -0
- package/src/sources/provider-router/financial-routes.ts +55 -5
- package/src/sources/provider-router/financials.test.ts +49 -0
- package/src/sources/provider-router/financials.ts +40 -3
- package/src/sources/provider-router/index.test.ts +147 -0
- package/src/sources/provider-router/primary.ts +17 -3
- package/src/sources/provider-router/streaming.ts +6 -1
- package/src/sources/sec-edgar.test.ts +42 -0
- package/src/sources/sec-edgar.ts +1 -2
- package/src/sources/yahoo-finance/mappers.test.ts +29 -0
- package/src/sources/yahoo-finance/mappers.ts +2 -1
- package/src/sources/yahoo-finance/requests.ts +2 -1
- package/src/sources/yahoo-finance/snapshots.ts +13 -2
- package/src/sources/yahoo-finance.ts +6 -2
- package/src/state/hooks/quote-streaming.test.ts +14 -1
- package/src/state/hooks/quote-streaming.ts +5 -4
- package/src/sync/controller.ts +296 -0
- package/src/sync/core-contributors.test.ts +251 -0
- package/src/sync/core-contributors.ts +400 -0
- package/src/sync/profile-analytics.ts +39 -0
- package/src/sync/react.ts +53 -0
- package/src/sync/types.ts +77 -0
- package/src/types/desktop-deeplink.ts +7 -0
- package/src/types/plugin.ts +3 -0
- package/src/ui/host.tsx +14 -1
- package/src/ui/index.tsx +1 -0
- package/src/utils/exchanges.test.ts +11 -1
- package/src/utils/exchanges.ts +5 -1
- 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:
|
|
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 {
|
|
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:
|
|
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
|
|
140
|
-
|
|
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 () => {
|