gloomberb 0.9.6 → 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/pane-settings.test.ts +1 -0
- 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 +102 -1
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
- package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
- 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/config.ts +1 -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
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { scheduleConfigSave } from "../state/config-save-scheduler";
|
|
2
|
+
import type { AppConfig, BrokerInstanceConfig } from "../types/config";
|
|
3
|
+
import type { PricePoint, TickerFinancials } from "../types/financials";
|
|
4
|
+
import type { Portfolio, TickerMetadata, TickerPosition, TickerRecord, Watchlist } from "../types/ticker";
|
|
5
|
+
import { hydrateTickerMetadata } from "../tickers/metadata";
|
|
6
|
+
import type { SyncContributor } from "./types";
|
|
7
|
+
import { convertCurrency } from "../utils/format";
|
|
8
|
+
import {
|
|
9
|
+
computeDatedBeta,
|
|
10
|
+
computeDatedReturns,
|
|
11
|
+
computeWeightedPortfolioReturns,
|
|
12
|
+
type WeightedReturnSeries,
|
|
13
|
+
} from "../plugins/builtin/analytics/metrics";
|
|
14
|
+
import { getSyncedProfileAnalytics } from "./profile-analytics";
|
|
15
|
+
|
|
16
|
+
const SENSITIVE_KEY_PATTERN = /(token|secret|password|credential|private|api[_-]?key|access[_-]?key|refresh[_-]?key|session|cookie|dataDir|path|directory|localPath)/i;
|
|
17
|
+
|
|
18
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sanitizeUnknown(value: unknown): unknown {
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
return value.map(sanitizeUnknown).filter((entry) => entry !== undefined);
|
|
25
|
+
}
|
|
26
|
+
if (!isPlainObject(value)) return value;
|
|
27
|
+
|
|
28
|
+
const output: Record<string, unknown> = {};
|
|
29
|
+
for (const [key, child] of Object.entries(value)) {
|
|
30
|
+
if (SENSITIVE_KEY_PATTERN.test(key)) continue;
|
|
31
|
+
const sanitized = sanitizeUnknown(child);
|
|
32
|
+
if (sanitized !== undefined) output[key] = sanitized;
|
|
33
|
+
}
|
|
34
|
+
return output;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sanitizePortfolio(portfolio: Portfolio): Portfolio {
|
|
38
|
+
return {
|
|
39
|
+
id: portfolio.id,
|
|
40
|
+
name: portfolio.name,
|
|
41
|
+
description: portfolio.description,
|
|
42
|
+
currency: portfolio.currency,
|
|
43
|
+
brokerId: portfolio.brokerId,
|
|
44
|
+
lastSyncedAt: portfolio.lastSyncedAt,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sanitizeWatchlist(watchlist: Watchlist): Watchlist {
|
|
49
|
+
return {
|
|
50
|
+
id: watchlist.id,
|
|
51
|
+
name: watchlist.name,
|
|
52
|
+
description: watchlist.description,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sanitizeBrokerInstance(instance: BrokerInstanceConfig): Omit<BrokerInstanceConfig, "config"> {
|
|
57
|
+
return {
|
|
58
|
+
id: instance.id,
|
|
59
|
+
brokerType: instance.brokerType,
|
|
60
|
+
label: instance.label,
|
|
61
|
+
connectionMode: instance.connectionMode,
|
|
62
|
+
enabled: instance.enabled,
|
|
63
|
+
lastSyncedAt: instance.lastSyncedAt,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sanitizePosition(position: TickerPosition): TickerPosition {
|
|
68
|
+
return {
|
|
69
|
+
portfolio: position.portfolio,
|
|
70
|
+
shares: position.shares,
|
|
71
|
+
avgCost: position.avgCost,
|
|
72
|
+
currency: position.currency,
|
|
73
|
+
dateAcquired: position.dateAcquired,
|
|
74
|
+
broker: position.broker,
|
|
75
|
+
side: position.side,
|
|
76
|
+
marketValue: position.marketValue,
|
|
77
|
+
unrealizedPnl: position.unrealizedPnl,
|
|
78
|
+
multiplier: position.multiplier,
|
|
79
|
+
markPrice: position.markPrice,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pricePointTime(point: { date: Date | string | number }): number {
|
|
84
|
+
const value = point.date;
|
|
85
|
+
if (value instanceof Date) return value.getTime();
|
|
86
|
+
return new Date(value).getTime();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function weeklyQuoteMove(financials: TickerFinancials | null | undefined) {
|
|
90
|
+
const quote = financials?.quote;
|
|
91
|
+
if (!quote || !Number.isFinite(quote.price) || quote.price <= 0) return undefined;
|
|
92
|
+
const history = (financials?.priceHistory ?? [])
|
|
93
|
+
.filter((point) => Number.isFinite(point.close) && point.close > 0 && Number.isFinite(pricePointTime(point)))
|
|
94
|
+
.sort((left, right) => pricePointTime(left) - pricePointTime(right));
|
|
95
|
+
if (history.length === 0) return undefined;
|
|
96
|
+
const latestTime = Number.isFinite(quote.lastUpdated)
|
|
97
|
+
? quote.lastUpdated
|
|
98
|
+
: pricePointTime(history[history.length - 1]!);
|
|
99
|
+
const cutoff = latestTime - 7 * 24 * 60 * 60 * 1000;
|
|
100
|
+
const reference = history.find((point) => pricePointTime(point) >= cutoff) ?? history[Math.max(0, history.length - 7)];
|
|
101
|
+
if (!reference || !Number.isFinite(reference.close) || reference.close <= 0) return undefined;
|
|
102
|
+
const weekChange = quote.price - reference.close;
|
|
103
|
+
return {
|
|
104
|
+
weekReferencePrice: reference.close,
|
|
105
|
+
weekChange,
|
|
106
|
+
weekChangePercent: (weekChange / reference.close) * 100,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sanitizeQuote(financials: TickerFinancials | null | undefined) {
|
|
111
|
+
const quote = financials?.quote;
|
|
112
|
+
if (!quote) return undefined;
|
|
113
|
+
const week = weeklyQuoteMove(financials);
|
|
114
|
+
return {
|
|
115
|
+
price: Number.isFinite(quote.price) ? quote.price : undefined,
|
|
116
|
+
currency: quote.currency,
|
|
117
|
+
changePercent: Number.isFinite(quote.changePercent) ? quote.changePercent : undefined,
|
|
118
|
+
previousClose: Number.isFinite(quote.previousClose) ? quote.previousClose : undefined,
|
|
119
|
+
weekReferencePrice: week?.weekReferencePrice,
|
|
120
|
+
weekChange: week?.weekChange,
|
|
121
|
+
weekChangePercent: week?.weekChangePercent,
|
|
122
|
+
lastUpdated: quote.lastUpdated,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sanitizeTickerMetadata(metadata: TickerMetadata, financials?: TickerFinancials | null): Omit<TickerMetadata, "broker_contracts"> & { quote?: ReturnType<typeof sanitizeQuote> } {
|
|
127
|
+
return {
|
|
128
|
+
ticker: metadata.ticker,
|
|
129
|
+
exchange: metadata.exchange,
|
|
130
|
+
currency: metadata.currency,
|
|
131
|
+
name: metadata.name,
|
|
132
|
+
sector: metadata.sector,
|
|
133
|
+
industry: metadata.industry,
|
|
134
|
+
assetCategory: metadata.assetCategory,
|
|
135
|
+
isin: metadata.isin,
|
|
136
|
+
cusip: metadata.cusip,
|
|
137
|
+
portfolios: [...metadata.portfolios],
|
|
138
|
+
watchlists: [...metadata.watchlists],
|
|
139
|
+
positions: metadata.positions.map(sanitizePosition),
|
|
140
|
+
custom: (sanitizeUnknown(metadata.custom) as Record<string, unknown>) ?? {},
|
|
141
|
+
tags: [...metadata.tags],
|
|
142
|
+
quote: sanitizeQuote(financials),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function collectCoreConfigPayload(config: AppConfig) {
|
|
147
|
+
return sanitizeUnknown({
|
|
148
|
+
configVersion: config.configVersion,
|
|
149
|
+
baseCurrency: config.baseCurrency,
|
|
150
|
+
refreshIntervalMinutes: config.refreshIntervalMinutes,
|
|
151
|
+
portfolios: config.portfolios.map(sanitizePortfolio),
|
|
152
|
+
watchlists: config.watchlists.map(sanitizeWatchlist),
|
|
153
|
+
layout: config.layout,
|
|
154
|
+
layouts: config.layouts,
|
|
155
|
+
activeLayoutIndex: config.activeLayoutIndex,
|
|
156
|
+
brokerInstances: config.brokerInstances.map(sanitizeBrokerInstance),
|
|
157
|
+
disabledPlugins: config.disabledPlugins,
|
|
158
|
+
disabledSources: config.disabledSources,
|
|
159
|
+
pluginConfig: config.pluginConfig,
|
|
160
|
+
theme: config.theme,
|
|
161
|
+
chartPreferences: config.chartPreferences,
|
|
162
|
+
valueFlashingEnabled: config.valueFlashingEnabled,
|
|
163
|
+
recentTickers: config.recentTickers,
|
|
164
|
+
onboardingComplete: config.onboardingComplete,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function positionValueBase(
|
|
169
|
+
position: TickerPosition,
|
|
170
|
+
financials: TickerFinancials | null | undefined,
|
|
171
|
+
baseCurrency: string,
|
|
172
|
+
exchangeRates: Map<string, number>,
|
|
173
|
+
): number | null {
|
|
174
|
+
const quoteCurrency = financials?.quote?.currency || position.currency || baseCurrency;
|
|
175
|
+
const positionCurrency = position.currency || quoteCurrency;
|
|
176
|
+
if (typeof position.marketValue === "number" && Number.isFinite(position.marketValue)) {
|
|
177
|
+
return convertCurrency(Math.abs(position.marketValue), positionCurrency, baseCurrency, exchangeRates);
|
|
178
|
+
}
|
|
179
|
+
const hasMarkPrice = typeof position.markPrice === "number" && Number.isFinite(position.markPrice);
|
|
180
|
+
const hasQuotePrice = typeof financials?.quote?.price === "number" && Number.isFinite(financials.quote.price);
|
|
181
|
+
const price = hasMarkPrice ? position.markPrice : hasQuotePrice ? financials!.quote!.price : position.avgCost;
|
|
182
|
+
if (!Number.isFinite(position.shares) || typeof price !== "number" || !Number.isFinite(price)) return null;
|
|
183
|
+
return convertCurrency(
|
|
184
|
+
Math.abs(position.shares * price * (position.multiplier ?? 1)),
|
|
185
|
+
hasQuotePrice && !hasMarkPrice ? quoteCurrency : positionCurrency,
|
|
186
|
+
baseCurrency,
|
|
187
|
+
exchangeRates,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function pricePointTimeOrNull(point: PricePoint): number | null {
|
|
192
|
+
const value = point.date;
|
|
193
|
+
if (value == null) return null;
|
|
194
|
+
const time = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
195
|
+
return Number.isFinite(time) ? time : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function recentPriceHistory(history: PricePoint[], days: number): PricePoint[] {
|
|
199
|
+
let latestTime = 0;
|
|
200
|
+
for (const point of history) {
|
|
201
|
+
const time = pricePointTimeOrNull(point);
|
|
202
|
+
if (time != null && time > latestTime) latestTime = time;
|
|
203
|
+
}
|
|
204
|
+
if (latestTime <= 0) return [];
|
|
205
|
+
const cutoff = latestTime - days * 24 * 60 * 60 * 1000;
|
|
206
|
+
return history.filter((point) => {
|
|
207
|
+
const time = pricePointTimeOrNull(point);
|
|
208
|
+
return time != null && time >= cutoff;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function oneYearReturnFromSeries(series: WeightedReturnSeries[]): number | null {
|
|
213
|
+
const returns = computeWeightedPortfolioReturns(series);
|
|
214
|
+
if (returns.length === 0) return null;
|
|
215
|
+
const cumulative = returns.reduce((acc, point) => (
|
|
216
|
+
Number.isFinite(point.value) ? acc * (1 + point.value) : acc
|
|
217
|
+
), 1) - 1;
|
|
218
|
+
return Number.isFinite(cumulative) ? cumulative : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function collectAnalyticsByPortfolio(
|
|
222
|
+
config: AppConfig,
|
|
223
|
+
tickers: Map<string, TickerRecord>,
|
|
224
|
+
financials: Map<string, TickerFinancials>,
|
|
225
|
+
exchangeRates: Map<string, number>,
|
|
226
|
+
) {
|
|
227
|
+
const output: Record<string, Record<string, unknown>> = {};
|
|
228
|
+
const spyReturns = computeDatedReturns(recentPriceHistory(financials.get("SPY")?.priceHistory ?? [], 366));
|
|
229
|
+
for (const portfolio of config.portfolios) {
|
|
230
|
+
let returnWeight = 0;
|
|
231
|
+
let weightedReturn = 0;
|
|
232
|
+
const datedReturnSeries: WeightedReturnSeries[] = [];
|
|
233
|
+
for (const ticker of tickers.values()) {
|
|
234
|
+
const tickerFinancials = financials.get(ticker.metadata.ticker);
|
|
235
|
+
const portfolioPositions = ticker.metadata.positions.filter((position) => position.portfolio === portfolio.id);
|
|
236
|
+
if (portfolioPositions.length === 0 && !ticker.metadata.portfolios.includes(portfolio.id)) continue;
|
|
237
|
+
for (const position of portfolioPositions) {
|
|
238
|
+
const value = positionValueBase(position, tickerFinancials, config.baseCurrency, exchangeRates);
|
|
239
|
+
if (value != null) {
|
|
240
|
+
const return1Y = tickerFinancials?.fundamentals?.return1Y;
|
|
241
|
+
if (typeof return1Y === "number" && Number.isFinite(return1Y)) {
|
|
242
|
+
returnWeight += value;
|
|
243
|
+
weightedReturn += return1Y * value;
|
|
244
|
+
}
|
|
245
|
+
const returns = computeDatedReturns(recentPriceHistory(tickerFinancials?.priceHistory ?? [], 366));
|
|
246
|
+
if (returns.length >= 10) datedReturnSeries.push({ weight: value, returns });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const portfolioReturns = computeWeightedPortfolioReturns(datedReturnSeries);
|
|
251
|
+
const previewAnalytics = getSyncedProfileAnalytics(portfolio.id);
|
|
252
|
+
output[portfolio.id] = {
|
|
253
|
+
oneYearReturn: previewAnalytics?.oneYearReturn
|
|
254
|
+
?? (returnWeight > 0 ? weightedReturn / returnWeight : oneYearReturnFromSeries(datedReturnSeries)),
|
|
255
|
+
spyBeta: previewAnalytics?.spyBeta
|
|
256
|
+
?? (
|
|
257
|
+
portfolioReturns.length > 0 && spyReturns.length > 0
|
|
258
|
+
? computeDatedBeta(portfolioReturns, spyReturns)
|
|
259
|
+
: null
|
|
260
|
+
),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return output;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function collectCoreCollectionsPayload(
|
|
267
|
+
config: AppConfig,
|
|
268
|
+
tickers: Map<string, TickerRecord>,
|
|
269
|
+
financials: Map<string, TickerFinancials>,
|
|
270
|
+
exchangeRates: Map<string, number>,
|
|
271
|
+
) {
|
|
272
|
+
const records = [...tickers.values()]
|
|
273
|
+
.map((ticker) => sanitizeTickerMetadata(ticker.metadata, financials.get(ticker.metadata.ticker)))
|
|
274
|
+
.filter((metadata) => (
|
|
275
|
+
metadata.portfolios.length > 0 ||
|
|
276
|
+
metadata.watchlists.length > 0 ||
|
|
277
|
+
metadata.positions.length > 0
|
|
278
|
+
));
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
baseCurrency: config.baseCurrency,
|
|
282
|
+
exchangeRates: Object.fromEntries(
|
|
283
|
+
[...exchangeRates.entries()]
|
|
284
|
+
.filter(([currency, rate]) => (
|
|
285
|
+
typeof currency === "string" &&
|
|
286
|
+
Number.isFinite(rate) &&
|
|
287
|
+
rate > 0
|
|
288
|
+
))
|
|
289
|
+
.map(([currency, rate]) => [currency.trim().toUpperCase(), rate]),
|
|
290
|
+
),
|
|
291
|
+
portfolios: config.portfolios.map(sanitizePortfolio),
|
|
292
|
+
watchlists: config.watchlists.map(sanitizeWatchlist),
|
|
293
|
+
analyticsByPortfolio: collectAnalyticsByPortfolio(config, tickers, financials, exchangeRates),
|
|
294
|
+
tickers: records,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function mergeConfigPayload(config: AppConfig, payload: unknown): AppConfig | null {
|
|
299
|
+
if (!isPlainObject(payload)) return null;
|
|
300
|
+
const next: AppConfig = { ...config };
|
|
301
|
+
const assign = <K extends keyof AppConfig>(key: K) => {
|
|
302
|
+
if (key in payload) {
|
|
303
|
+
next[key] = payload[key as string] as AppConfig[K];
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
assign("baseCurrency");
|
|
308
|
+
assign("refreshIntervalMinutes");
|
|
309
|
+
assign("layout");
|
|
310
|
+
assign("layouts");
|
|
311
|
+
assign("activeLayoutIndex");
|
|
312
|
+
assign("disabledPlugins");
|
|
313
|
+
assign("disabledSources");
|
|
314
|
+
assign("pluginConfig");
|
|
315
|
+
assign("theme");
|
|
316
|
+
assign("chartPreferences");
|
|
317
|
+
assign("valueFlashingEnabled");
|
|
318
|
+
assign("recentTickers");
|
|
319
|
+
assign("onboardingComplete");
|
|
320
|
+
|
|
321
|
+
if (Array.isArray(payload.portfolios)) next.portfolios = payload.portfolios as Portfolio[];
|
|
322
|
+
if (Array.isArray(payload.watchlists)) next.watchlists = payload.watchlists as Watchlist[];
|
|
323
|
+
if (Array.isArray(payload.brokerInstances)) {
|
|
324
|
+
const incoming = payload.brokerInstances as Array<Partial<BrokerInstanceConfig>>;
|
|
325
|
+
const existingById = new Map(config.brokerInstances.map((instance) => [instance.id, instance]));
|
|
326
|
+
next.brokerInstances = incoming.map((instance) => {
|
|
327
|
+
const current = instance.id ? existingById.get(instance.id) : undefined;
|
|
328
|
+
return {
|
|
329
|
+
id: instance.id ?? current?.id ?? crypto.randomUUID(),
|
|
330
|
+
brokerType: instance.brokerType ?? current?.brokerType ?? "",
|
|
331
|
+
label: instance.label ?? current?.label ?? "",
|
|
332
|
+
connectionMode: instance.connectionMode ?? current?.connectionMode,
|
|
333
|
+
enabled: instance.enabled ?? current?.enabled,
|
|
334
|
+
lastSyncedAt: instance.lastSyncedAt ?? current?.lastSyncedAt,
|
|
335
|
+
config: current?.config ?? {},
|
|
336
|
+
};
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return next;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export const coreConfigSyncContributor: SyncContributor = {
|
|
343
|
+
id: "core.config",
|
|
344
|
+
schemaVersion: 1,
|
|
345
|
+
collect: ({ state }) => collectCoreConfigPayload(state.config),
|
|
346
|
+
apply: (payload, { state, dispatch }) => {
|
|
347
|
+
const nextConfig = mergeConfigPayload(state.config, payload);
|
|
348
|
+
if (!nextConfig) return;
|
|
349
|
+
dispatch({ type: "SET_CONFIG", config: nextConfig });
|
|
350
|
+
scheduleConfigSave(nextConfig);
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
export const coreCollectionsSyncContributor: SyncContributor = {
|
|
355
|
+
id: "core.collections",
|
|
356
|
+
schemaVersion: 1,
|
|
357
|
+
collect: ({ state }) => collectCoreCollectionsPayload(
|
|
358
|
+
state.config,
|
|
359
|
+
state.tickers,
|
|
360
|
+
state.financials,
|
|
361
|
+
state.exchangeRates,
|
|
362
|
+
),
|
|
363
|
+
apply: async (payload, { state, dispatch, tickerRepository }) => {
|
|
364
|
+
if (!isPlainObject(payload)) return;
|
|
365
|
+
const nextConfig = { ...state.config };
|
|
366
|
+
if (Array.isArray(payload.portfolios)) nextConfig.portfolios = payload.portfolios as Portfolio[];
|
|
367
|
+
if (Array.isArray(payload.watchlists)) nextConfig.watchlists = payload.watchlists as Watchlist[];
|
|
368
|
+
|
|
369
|
+
const nextTickers = new Map(state.tickers);
|
|
370
|
+
const rawTickers = Array.isArray(payload.tickers) ? payload.tickers : [];
|
|
371
|
+
for (const rawTicker of rawTickers) {
|
|
372
|
+
if (!isPlainObject(rawTicker)) continue;
|
|
373
|
+
const current = typeof rawTicker.ticker === "string"
|
|
374
|
+
? nextTickers.get(rawTicker.ticker)
|
|
375
|
+
: null;
|
|
376
|
+
const metadata = hydrateTickerMetadata({
|
|
377
|
+
...current?.metadata,
|
|
378
|
+
...rawTicker,
|
|
379
|
+
broker_contracts: current?.metadata.broker_contracts ?? [],
|
|
380
|
+
});
|
|
381
|
+
const record: TickerRecord = { metadata };
|
|
382
|
+
await tickerRepository.saveTicker(record);
|
|
383
|
+
nextTickers.set(metadata.ticker, record);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
dispatch({ type: "SET_CONFIG", config: nextConfig });
|
|
387
|
+
dispatch({ type: "SET_TICKERS", tickers: nextTickers });
|
|
388
|
+
scheduleConfigSave(nextConfig);
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
export function createCoreSyncContributors(): SyncContributor[] {
|
|
393
|
+
return [coreConfigSyncContributor, coreCollectionsSyncContributor];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export const __syncContributorInternalsForTests = {
|
|
397
|
+
sanitizeUnknown,
|
|
398
|
+
collectCoreConfigPayload,
|
|
399
|
+
collectCoreCollectionsPayload,
|
|
400
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PublicPortfolioAnalytics } from "../api-client";
|
|
2
|
+
|
|
3
|
+
const analyticsByPortfolio = new Map<string, PublicPortfolioAnalytics>();
|
|
4
|
+
|
|
5
|
+
function finiteMetric(value: number | null | undefined): number | null {
|
|
6
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeAnalytics(analytics: PublicPortfolioAnalytics | null | undefined): PublicPortfolioAnalytics | null {
|
|
10
|
+
if (!analytics) return null;
|
|
11
|
+
const normalized = {
|
|
12
|
+
oneYearReturn: finiteMetric(analytics.oneYearReturn),
|
|
13
|
+
spyBeta: finiteMetric(analytics.spyBeta),
|
|
14
|
+
};
|
|
15
|
+
return normalized.oneYearReturn != null || normalized.spyBeta != null ? normalized : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sameAnalytics(left: PublicPortfolioAnalytics | null, right: PublicPortfolioAnalytics | null): boolean {
|
|
19
|
+
return left?.oneYearReturn === right?.oneYearReturn && left?.spyBeta === right?.spyBeta;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function setSyncedProfileAnalytics(
|
|
23
|
+
portfolioId: string,
|
|
24
|
+
analytics: PublicPortfolioAnalytics | null | undefined,
|
|
25
|
+
): boolean {
|
|
26
|
+
const normalized = normalizeAnalytics(analytics);
|
|
27
|
+
const current = analyticsByPortfolio.get(portfolioId) ?? null;
|
|
28
|
+
if (sameAnalytics(current, normalized)) return false;
|
|
29
|
+
if (normalized) {
|
|
30
|
+
analyticsByPortfolio.set(portfolioId, normalized);
|
|
31
|
+
} else {
|
|
32
|
+
analyticsByPortfolio.delete(portfolioId);
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getSyncedProfileAnalytics(portfolioId: string): PublicPortfolioAnalytics | null {
|
|
38
|
+
return analyticsByPortfolio.get(portfolioId) ?? null;
|
|
39
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore, type Dispatch } from "react";
|
|
2
|
+
import type { AppAction, AppState } from "../core/state/app/state";
|
|
3
|
+
import type { TickerRepository } from "../data/ticker-repository";
|
|
4
|
+
import type { PluginRegistry } from "../plugins/registry";
|
|
5
|
+
import { cloudSyncController } from "./controller";
|
|
6
|
+
|
|
7
|
+
interface CloudSyncRuntimeOptions {
|
|
8
|
+
state: AppState;
|
|
9
|
+
dispatch: Dispatch<AppAction>;
|
|
10
|
+
tickerRepository: TickerRepository;
|
|
11
|
+
pluginRegistry: PluginRegistry;
|
|
12
|
+
initialized: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useCloudSyncRuntime({
|
|
16
|
+
state,
|
|
17
|
+
dispatch,
|
|
18
|
+
tickerRepository,
|
|
19
|
+
pluginRegistry,
|
|
20
|
+
initialized,
|
|
21
|
+
}: CloudSyncRuntimeOptions): void {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
cloudSyncController.setRuntime({
|
|
24
|
+
state,
|
|
25
|
+
dispatch,
|
|
26
|
+
tickerRepository,
|
|
27
|
+
getContributors: () => pluginRegistry.getEnabledSyncContributors(),
|
|
28
|
+
getTransport: () => pluginRegistry.getActiveSyncTransport(),
|
|
29
|
+
});
|
|
30
|
+
}, [dispatch, pluginRegistry, state, tickerRepository]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => () => {
|
|
33
|
+
cloudSyncController.clearRuntime();
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!initialized) return;
|
|
38
|
+
void cloudSyncController.pullLatest();
|
|
39
|
+
}, [initialized, pluginRegistry]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!initialized) return;
|
|
43
|
+
cloudSyncController.schedulePush("state-change");
|
|
44
|
+
}, [initialized, state.config, state.tickers]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useCloudSyncStatus() {
|
|
48
|
+
return useSyncExternalStore(
|
|
49
|
+
(listener) => cloudSyncController.subscribe(listener),
|
|
50
|
+
() => cloudSyncController.getStatus(),
|
|
51
|
+
() => cloudSyncController.getStatus(),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Dispatch } from "react";
|
|
2
|
+
import type { AppAction, AppState } from "../core/state/app/state";
|
|
3
|
+
import type { TickerRepository } from "../data/ticker-repository";
|
|
4
|
+
|
|
5
|
+
export const SYNC_SNAPSHOT_SCHEMA_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
export interface SyncContributorPayload {
|
|
8
|
+
schemaVersion: number;
|
|
9
|
+
updatedAt: string;
|
|
10
|
+
payload: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SyncSnapshot {
|
|
14
|
+
schemaVersion: typeof SYNC_SNAPSHOT_SCHEMA_VERSION;
|
|
15
|
+
appId: "gloomberb";
|
|
16
|
+
clientId: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
appVersion?: string;
|
|
19
|
+
contributors: Record<string, SyncContributorPayload>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SyncCollectContext {
|
|
23
|
+
state: AppState;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SyncApplyContext {
|
|
27
|
+
snapshot: SyncSnapshot;
|
|
28
|
+
state: AppState;
|
|
29
|
+
dispatch: Dispatch<AppAction>;
|
|
30
|
+
tickerRepository: TickerRepository;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SyncContributor {
|
|
34
|
+
id: string;
|
|
35
|
+
schemaVersion: number;
|
|
36
|
+
collect(context: SyncCollectContext): unknown | Promise<unknown>;
|
|
37
|
+
apply?(payload: unknown, context: SyncApplyContext): void | Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SyncSnapshotResponse {
|
|
41
|
+
snapshot: SyncSnapshot | null;
|
|
42
|
+
revision: number | null;
|
|
43
|
+
updatedAt: string | null;
|
|
44
|
+
settings?: SyncSettings;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SyncPushResult {
|
|
48
|
+
revision: number;
|
|
49
|
+
updatedAt: string;
|
|
50
|
+
settings?: SyncSettings;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SyncTransport {
|
|
54
|
+
id: string;
|
|
55
|
+
isAvailable(): boolean;
|
|
56
|
+
pullSnapshot(): Promise<SyncSnapshotResponse>;
|
|
57
|
+
pushSnapshot(snapshot: SyncSnapshot, options?: { baseRevision?: number | null }): Promise<SyncPushResult>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SyncSettings {
|
|
61
|
+
syncEnabled: boolean;
|
|
62
|
+
weeklyRoundupEnabled: boolean;
|
|
63
|
+
positionAlertsEnabled: boolean;
|
|
64
|
+
selectedSharedPortfolioId?: string | null;
|
|
65
|
+
lastSyncAt?: string | null;
|
|
66
|
+
lastRoundupEmailAt?: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RegisteredSyncContributor {
|
|
70
|
+
pluginId: string;
|
|
71
|
+
contributor: SyncContributor;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RegisteredSyncTransport {
|
|
75
|
+
pluginId: string;
|
|
76
|
+
transport: SyncTransport;
|
|
77
|
+
}
|
package/src/types/config.ts
CHANGED
package/src/types/plugin.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { TickerFinancials } from "./financials";
|
|
|
20
20
|
import type { CachePolicy, PersistedResourceValue } from "./persistence";
|
|
21
21
|
import type { TickerRecord } from "./ticker";
|
|
22
22
|
import type { InstrumentSearchResult } from "./instrument";
|
|
23
|
+
import type { SyncContributor, SyncTransport } from "../sync/types";
|
|
23
24
|
|
|
24
25
|
export interface GloomSlots {
|
|
25
26
|
"ticker-research:tab": { ticker: TickerRecord; financials: TickerFinancials | null };
|
|
@@ -467,6 +468,8 @@ export interface GloomPluginContext {
|
|
|
467
468
|
registerShortcut(shortcut: KeyboardShortcut): void;
|
|
468
469
|
registerTickerAction(action: TickerAction): void;
|
|
469
470
|
registerContextMenuProvider(provider: ContextMenuProviderDef): void;
|
|
471
|
+
registerSyncContributor(contributor: SyncContributor): () => void;
|
|
472
|
+
registerSyncTransport(transport: SyncTransport): () => void;
|
|
470
473
|
watchNewsQuery?(
|
|
471
474
|
query: import("./news-source").NewsQuery,
|
|
472
475
|
listener: (state: import("./news-source").NewsQueryState) => void,
|
package/src/ui/host.tsx
CHANGED
|
@@ -85,7 +85,7 @@ export interface SyntaxStyleLike {
|
|
|
85
85
|
export interface BoxRenderable {
|
|
86
86
|
x?: number;
|
|
87
87
|
y?: number;
|
|
88
|
-
width?: number;
|
|
88
|
+
width?: number | string;
|
|
89
89
|
height?: number;
|
|
90
90
|
absoluteX?: number;
|
|
91
91
|
absoluteY?: number;
|
|
@@ -264,6 +264,18 @@ export interface HostTabsProps {
|
|
|
264
264
|
palette: HostTabsPalette;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
export interface HostCheckboxProps {
|
|
268
|
+
label: string;
|
|
269
|
+
displayLabel?: string;
|
|
270
|
+
checked: boolean;
|
|
271
|
+
onChange?: (checked: boolean) => void;
|
|
272
|
+
disabled?: boolean;
|
|
273
|
+
active?: boolean;
|
|
274
|
+
description?: string;
|
|
275
|
+
width?: number;
|
|
276
|
+
variant?: "default" | "desktop";
|
|
277
|
+
}
|
|
278
|
+
|
|
267
279
|
export interface UiHost {
|
|
268
280
|
kind?: "opentui" | "desktop-web";
|
|
269
281
|
capabilities?: {
|
|
@@ -299,6 +311,7 @@ export interface UiHost {
|
|
|
299
311
|
DialogFrame?: ComponentType<any>;
|
|
300
312
|
PageStackView?: ComponentType<any>;
|
|
301
313
|
Tabs?: ComponentType<HostTabsProps>;
|
|
314
|
+
Checkbox?: ComponentType<HostCheckboxProps>;
|
|
302
315
|
DataTable?: ComponentType<any>;
|
|
303
316
|
createSyntaxStyle?(): SyntaxStyleLike;
|
|
304
317
|
colorFromHex?(hex: string): unknown;
|
package/src/ui/index.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { CANONICAL_EXCHANGE_ALIASES, EXCHANGE_TIME_ZONES } from "./exchanges";
|
|
2
|
+
import { canonicalExchange, CANONICAL_EXCHANGE_ALIASES, EXCHANGE_TIME_ZONES, resolveExchangeTimeZone } from "./exchanges";
|
|
3
3
|
|
|
4
4
|
describe("exchange metadata", () => {
|
|
5
5
|
test("has a valid timezone for every canonical exchange", () => {
|
|
@@ -13,4 +13,14 @@ describe("exchange metadata", () => {
|
|
|
13
13
|
})).not.toThrow();
|
|
14
14
|
}
|
|
15
15
|
});
|
|
16
|
+
|
|
17
|
+
test("normalizes broker venue aliases used by portfolio rows", () => {
|
|
18
|
+
expect(canonicalExchange("LSEETF")).toBe("LSE");
|
|
19
|
+
expect(canonicalExchange("TSE")).toBe("TSX");
|
|
20
|
+
expect(canonicalExchange("AEB")).toBe("AMS");
|
|
21
|
+
expect(canonicalExchange("EURONEXT")).toBe("AMS");
|
|
22
|
+
expect(resolveExchangeTimeZone("LSEETF")).toBe("Europe/London");
|
|
23
|
+
expect(resolveExchangeTimeZone("TSE")).toBe("America/Toronto");
|
|
24
|
+
expect(resolveExchangeTimeZone("AEB")).toBe("Europe/Amsterdam");
|
|
25
|
+
});
|
|
16
26
|
});
|