gloomberb 0.3.7 → 0.4.0
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/README.md +19 -8
- package/package.json +1 -1
- package/src/app.tsx +29 -65
- package/src/cli/base-converter.ts +31 -0
- package/src/cli/commands/search.ts +11 -3
- package/src/cli/commands/ticker.ts +22 -11
- package/src/cli/context.ts +27 -32
- package/src/cli/helpers.ts +1 -4
- package/src/cli/index.ts +140 -123
- package/src/cli/registry.test.ts +226 -0
- package/src/cli/registry.ts +201 -0
- package/src/cli.test.ts +219 -0
- package/src/components/chart/chart-controller.ts +309 -0
- package/src/components/chart/chart-data.ts +5 -6
- package/src/components/chart/chart-pane-settings.ts +27 -0
- package/src/components/chart/chart-resolution.test.ts +56 -0
- package/src/components/chart/chart-resolution.ts +362 -0
- package/src/components/chart/chart-types.ts +12 -4
- package/src/components/chart/chart-viewport.test.ts +25 -0
- package/src/components/chart/chart-viewport.ts +28 -0
- package/src/components/chart/comparison-chart-data.ts +20 -27
- package/src/components/chart/comparison-stock-chart.tsx +374 -99
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
- package/src/components/chart/stock-chart.tsx +455 -106
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
- package/src/components/command-bar/command-bar.test.tsx +650 -5
- package/src/components/command-bar/command-bar.tsx +359 -63
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/command-bar/helpers.test.ts +23 -1
- package/src/components/command-bar/helpers.ts +14 -5
- package/src/components/command-bar/pane-template-display.ts +12 -0
- package/src/components/command-bar/root-shortcuts.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +4 -2
- package/src/components/detail-feed-view.test.tsx +124 -49
- package/src/components/detail-feed-view.tsx +396 -160
- package/src/components/layout/floating-pane.tsx +1 -1
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/shell.tsx +62 -50
- package/src/components/layout/status-bar.test.tsx +66 -0
- package/src/components/layout/status-bar.tsx +26 -5
- package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
- package/src/components/onboarding/onboarding-wizard.tsx +5 -6
- package/src/components/ticker-list-table.test.tsx +83 -0
- package/src/components/ticker-list-table.tsx +174 -97
- package/src/components/ui/data-table.tsx +27 -2
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/page-stack-view.tsx +60 -0
- package/src/components/use-double-click-activation.ts +31 -0
- package/src/index.tsx +10 -10
- package/src/market-data/coordinator.test.ts +92 -4
- package/src/market-data/coordinator.ts +32 -5
- package/src/market-data/hooks.test.tsx +120 -0
- package/src/market-data/hooks.tsx +53 -16
- package/src/market-data/request-types.ts +4 -2
- package/src/market-data/selectors.ts +24 -58
- package/src/plugins/builtin/chat-controller.test.ts +370 -3
- package/src/plugins/builtin/chat-controller.ts +310 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +175 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- package/src/plugins/builtin/news.tsx +1 -11
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
- package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
- package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
- package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
- package/src/plugins/builtin/sec.tsx +1 -11
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
- package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
- package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
- package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
- package/src/plugins/catalog.ts +65 -0
- package/src/plugins/ibkr/broker-adapter.ts +28 -0
- package/src/plugins/ibkr/gateway-service.test.ts +45 -1
- package/src/plugins/ibkr/gateway-service.ts +141 -7
- package/src/plugins/pane-manager.test.ts +1 -1
- package/src/plugins/plugin-runtime.tsx +27 -7
- package/src/plugins/prediction-markets/cli.ts +28 -0
- package/src/plugins/prediction-markets/controller-data.ts +11 -3
- package/src/plugins/prediction-markets/controller.ts +141 -121
- package/src/plugins/prediction-markets/index.tsx +2 -0
- package/src/plugins/prediction-markets/launch.ts +51 -16
- package/src/plugins/prediction-markets/metrics.ts +9 -5
- package/src/plugins/prediction-markets/pane.test.tsx +119 -57
- package/src/plugins/prediction-markets/pane.tsx +74 -153
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/types.ts +0 -1
- package/src/sources/gloomberb-cloud.test.ts +29 -0
- package/src/sources/gloomberb-cloud.ts +89 -1
- package/src/sources/provider-router.test.ts +265 -3
- package/src/sources/provider-router.ts +239 -11
- package/src/sources/yahoo-finance.test.ts +26 -0
- package/src/sources/yahoo-finance.ts +54 -3
- package/src/state/app-context-selector.test.tsx +99 -0
- package/src/state/app-context.test.ts +18 -0
- package/src/state/app-context.tsx +170 -65
- package/src/state/selectors.ts +25 -0
- package/src/theme/colors.ts +8 -8
- package/src/theme/themes.test.ts +2 -0
- package/src/theme/themes.ts +22 -10
- package/src/types/broker.ts +21 -0
- package/src/types/config.ts +17 -1
- package/src/types/data-provider.ts +18 -0
- package/src/types/financials.ts +31 -1
- package/src/types/plugin.ts +70 -0
- package/src/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +35 -12
- package/src/utils/back-navigation.ts +27 -0
- package/src/utils/format.test.ts +14 -0
- package/src/utils/format.ts +3 -3
- package/src/utils/market-status.ts +46 -1
- package/src/utils/quote-freshness.ts +127 -0
- package/src/utils/quote-resolution.test.ts +277 -0
- package/src/utils/quote-resolution.ts +641 -0
- package/src/utils/slugify.ts +3 -0
- package/src/utils/timestamp.ts +25 -0
- package/src/version.ts +1 -1
- package/src/cli/commands/portfolio.ts +0 -210
- package/src/cli/commands/watchlist.ts +0 -210
- package/src/plugins/builtin/data-providers.ts +0 -16
- package/src/sources/exchange-rates.ts +0 -18
- package/src/utils/ascii-chart.ts +0 -172
- package/src/utils/ticker-tokenizer.test.ts +0 -48
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Quote,
|
|
3
|
+
QuoteContribution,
|
|
4
|
+
QuoteContributionMap,
|
|
5
|
+
QuoteDataSource,
|
|
6
|
+
QuoteFieldProvenance,
|
|
7
|
+
QuoteProvenance,
|
|
8
|
+
SessionConfidence,
|
|
9
|
+
TickerFinancials,
|
|
10
|
+
} from "../types/financials";
|
|
11
|
+
import { hasLikelyQuoteUnitMismatch } from "./currency-units";
|
|
12
|
+
import { debugLog } from "./debug-log";
|
|
13
|
+
import { hasFreshQuoteForCurrentSession, isQuoteStaleForCurrentSession } from "./quote-freshness";
|
|
14
|
+
|
|
15
|
+
const quoteResolutionLog = debugLog.createLogger("quote-resolution");
|
|
16
|
+
|
|
17
|
+
const PRICE_FIELD_KEYS = [
|
|
18
|
+
"symbol",
|
|
19
|
+
"price",
|
|
20
|
+
"currency",
|
|
21
|
+
"change",
|
|
22
|
+
"changePercent",
|
|
23
|
+
"previousClose",
|
|
24
|
+
"bid",
|
|
25
|
+
"ask",
|
|
26
|
+
"bidSize",
|
|
27
|
+
"askSize",
|
|
28
|
+
"open",
|
|
29
|
+
"high",
|
|
30
|
+
"low",
|
|
31
|
+
"mark",
|
|
32
|
+
"lastUpdated",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
const SESSION_FIELD_KEYS = [
|
|
36
|
+
"marketState",
|
|
37
|
+
"sessionConfidence",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
const PRE_SESSION_FIELD_KEYS = [
|
|
41
|
+
"preMarketPrice",
|
|
42
|
+
"preMarketChange",
|
|
43
|
+
"preMarketChangePercent",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
const POST_SESSION_FIELD_KEYS = [
|
|
47
|
+
"postMarketPrice",
|
|
48
|
+
"postMarketChange",
|
|
49
|
+
"postMarketChangePercent",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
const DESCRIPTIVE_FIELD_KEYS = [
|
|
53
|
+
"high52w",
|
|
54
|
+
"low52w",
|
|
55
|
+
"marketCap",
|
|
56
|
+
"volume",
|
|
57
|
+
"name",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
function isBrokerProvider(providerId?: string): boolean {
|
|
61
|
+
return providerId === "ibkr";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function providerKindRank(providerId?: string): number {
|
|
65
|
+
switch (providerId) {
|
|
66
|
+
case "gloomberb-cloud":
|
|
67
|
+
return 0;
|
|
68
|
+
case "yahoo":
|
|
69
|
+
return 1;
|
|
70
|
+
case "ibkr":
|
|
71
|
+
return 2;
|
|
72
|
+
default:
|
|
73
|
+
return 3;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function priceRank(quote: QuoteContribution): number {
|
|
78
|
+
if (quote.providerId === "ibkr" && quote.dataSource === "live") return 0;
|
|
79
|
+
if (quote.providerId === "ibkr") return 1;
|
|
80
|
+
if (quote.providerId === "gloomberb-cloud") return 2;
|
|
81
|
+
if (quote.providerId === "yahoo") return 3;
|
|
82
|
+
return 4;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sessionConfidenceRank(confidence?: SessionConfidence): number {
|
|
86
|
+
switch (confidence) {
|
|
87
|
+
case "explicit":
|
|
88
|
+
return 0;
|
|
89
|
+
case "derived":
|
|
90
|
+
return 1;
|
|
91
|
+
case "unknown":
|
|
92
|
+
default:
|
|
93
|
+
return 2;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function inferQuoteProviderId(quote: Quote | QuoteContribution): string {
|
|
98
|
+
if (quote.providerId?.trim()) return quote.providerId;
|
|
99
|
+
if (quote.dataSource === "yahoo") return "yahoo";
|
|
100
|
+
return "quote";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function inferSessionConfidence(
|
|
104
|
+
quote: Quote | QuoteContribution,
|
|
105
|
+
providerId: string,
|
|
106
|
+
): SessionConfidence {
|
|
107
|
+
if (quote.sessionConfidence) return quote.sessionConfidence;
|
|
108
|
+
if (providerId === "ibkr") return "unknown";
|
|
109
|
+
if (quote.marketState) return providerId === "gloomberb-cloud" ? "derived" : "derived";
|
|
110
|
+
return "unknown";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeListingExchange(
|
|
114
|
+
quote: Quote | QuoteContribution,
|
|
115
|
+
providerId: string,
|
|
116
|
+
): { listingExchangeName?: string; listingExchangeFullName?: string } {
|
|
117
|
+
const listingExchangeName = quote.listingExchangeName ?? quote.exchangeName;
|
|
118
|
+
const listingExchangeFullName = quote.listingExchangeFullName ?? quote.fullExchangeName ?? listingExchangeName;
|
|
119
|
+
if (providerId === "ibkr" && listingExchangeName === "SMART") {
|
|
120
|
+
return {
|
|
121
|
+
listingExchangeName: undefined,
|
|
122
|
+
listingExchangeFullName: undefined,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return { listingExchangeName, listingExchangeFullName };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function shouldProjectSessionPrice(quote: Quote | QuoteContribution): boolean {
|
|
129
|
+
return quote.sessionConfidence === "explicit" || quote.dataSource === "live";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function finalizeSessionFields(
|
|
133
|
+
quote: QuoteContribution,
|
|
134
|
+
options: { allowPriceProjection?: boolean } = {},
|
|
135
|
+
): QuoteContribution {
|
|
136
|
+
const allowPriceProjection = options.allowPriceProjection !== false;
|
|
137
|
+
const canProjectSessionPrice = allowPriceProjection && shouldProjectSessionPrice(quote);
|
|
138
|
+
|
|
139
|
+
if (quote.marketState === "PRE") {
|
|
140
|
+
return {
|
|
141
|
+
...quote,
|
|
142
|
+
preMarketPrice: quote.preMarketPrice ?? (canProjectSessionPrice ? quote.price : undefined),
|
|
143
|
+
preMarketChange: quote.preMarketChange ?? (canProjectSessionPrice ? quote.change : undefined),
|
|
144
|
+
preMarketChangePercent: quote.preMarketChangePercent ?? (canProjectSessionPrice ? quote.changePercent : undefined),
|
|
145
|
+
postMarketPrice: undefined,
|
|
146
|
+
postMarketChange: undefined,
|
|
147
|
+
postMarketChangePercent: undefined,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (quote.marketState === "POST") {
|
|
152
|
+
return {
|
|
153
|
+
...quote,
|
|
154
|
+
preMarketPrice: undefined,
|
|
155
|
+
preMarketChange: undefined,
|
|
156
|
+
preMarketChangePercent: undefined,
|
|
157
|
+
postMarketPrice: quote.postMarketPrice ?? (canProjectSessionPrice ? quote.price : undefined),
|
|
158
|
+
postMarketChange: quote.postMarketChange ?? (canProjectSessionPrice ? quote.change : undefined),
|
|
159
|
+
postMarketChangePercent: quote.postMarketChangePercent ?? (canProjectSessionPrice ? quote.changePercent : undefined),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (quote.marketState != null) {
|
|
164
|
+
return {
|
|
165
|
+
...quote,
|
|
166
|
+
preMarketPrice: undefined,
|
|
167
|
+
preMarketChange: undefined,
|
|
168
|
+
preMarketChangePercent: undefined,
|
|
169
|
+
postMarketPrice: undefined,
|
|
170
|
+
postMarketChange: undefined,
|
|
171
|
+
postMarketChangePercent: undefined,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return quote;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function getQuoteContributionKey(quote: Quote | QuoteContribution): string {
|
|
179
|
+
return inferQuoteProviderId(quote);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function normalizeQuoteContribution(
|
|
183
|
+
quote: Quote | QuoteContribution | null | undefined,
|
|
184
|
+
): QuoteContribution | undefined {
|
|
185
|
+
if (!quote) return undefined;
|
|
186
|
+
const providerId = inferQuoteProviderId(quote);
|
|
187
|
+
const { listingExchangeName, listingExchangeFullName } = normalizeListingExchange(quote, providerId);
|
|
188
|
+
const routingExchangeName = quote.routingExchangeName;
|
|
189
|
+
const routingExchangeFullName = quote.routingExchangeFullName ?? routingExchangeName;
|
|
190
|
+
|
|
191
|
+
return finalizeSessionFields({
|
|
192
|
+
...quote,
|
|
193
|
+
providerId,
|
|
194
|
+
listingExchangeName,
|
|
195
|
+
listingExchangeFullName,
|
|
196
|
+
routingExchangeName,
|
|
197
|
+
routingExchangeFullName,
|
|
198
|
+
exchangeName: listingExchangeName ?? quote.exchangeName,
|
|
199
|
+
fullExchangeName: listingExchangeFullName ?? quote.fullExchangeName,
|
|
200
|
+
sessionConfidence: inferSessionConfidence(quote, providerId),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function mergeQuoteContribution(
|
|
205
|
+
current: QuoteContribution | undefined,
|
|
206
|
+
nextQuote: Quote | QuoteContribution,
|
|
207
|
+
): QuoteContribution {
|
|
208
|
+
const next = normalizeQuoteContribution(nextQuote);
|
|
209
|
+
if (!next) {
|
|
210
|
+
throw new Error("Cannot merge an empty quote contribution");
|
|
211
|
+
}
|
|
212
|
+
if (!current) return next;
|
|
213
|
+
|
|
214
|
+
const merged: QuoteContribution = {
|
|
215
|
+
...current,
|
|
216
|
+
...next,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
if (next.marketState == null && current.marketState != null) {
|
|
220
|
+
merged.marketState = current.marketState;
|
|
221
|
+
}
|
|
222
|
+
if (next.sessionConfidence == null && current.sessionConfidence != null) {
|
|
223
|
+
merged.sessionConfidence = current.sessionConfidence;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if ((current.marketState === "PRE" || current.marketState === "POST") && next.marketState == null) {
|
|
227
|
+
const canProjectNextSessionPrice = shouldProjectSessionPrice(next);
|
|
228
|
+
merged.marketState = current.marketState;
|
|
229
|
+
if (current.marketState === "PRE") {
|
|
230
|
+
merged.preMarketPrice = next.preMarketPrice ?? (canProjectNextSessionPrice ? next.price : current.preMarketPrice);
|
|
231
|
+
merged.preMarketChange = next.preMarketChange ?? (canProjectNextSessionPrice ? next.change : current.preMarketChange);
|
|
232
|
+
merged.preMarketChangePercent = next.preMarketChangePercent ?? (canProjectNextSessionPrice ? next.changePercent : current.preMarketChangePercent);
|
|
233
|
+
} else {
|
|
234
|
+
merged.postMarketPrice = next.postMarketPrice ?? (canProjectNextSessionPrice ? next.price : current.postMarketPrice);
|
|
235
|
+
merged.postMarketChange = next.postMarketChange ?? (canProjectNextSessionPrice ? next.change : current.postMarketChange);
|
|
236
|
+
merged.postMarketChangePercent = next.postMarketChangePercent ?? (canProjectNextSessionPrice ? next.changePercent : current.postMarketChangePercent);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return finalizeSessionFields(merged);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function mergeQuoteContributionMaps(
|
|
244
|
+
preferred: QuoteContributionMap | undefined,
|
|
245
|
+
fallback: QuoteContributionMap | undefined,
|
|
246
|
+
): QuoteContributionMap | undefined {
|
|
247
|
+
const entries = new Map<string, QuoteContribution>();
|
|
248
|
+
|
|
249
|
+
for (const source of [fallback, preferred]) {
|
|
250
|
+
if (!source) continue;
|
|
251
|
+
for (const [key, quote] of Object.entries(source)) {
|
|
252
|
+
const normalized = normalizeQuoteContribution(quote);
|
|
253
|
+
if (!normalized) continue;
|
|
254
|
+
const contributionKey = key || getQuoteContributionKey(normalized);
|
|
255
|
+
entries.set(
|
|
256
|
+
contributionKey,
|
|
257
|
+
mergeQuoteContribution(entries.get(contributionKey), normalized),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (entries.size === 0) return undefined;
|
|
263
|
+
return Object.fromEntries(entries.entries());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function cloneQuoteContributionMap(map: QuoteContributionMap | undefined): QuoteContributionMap | undefined {
|
|
267
|
+
return mergeQuoteContributionMaps(map, undefined);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function quoteContributionValues(map: QuoteContributionMap | undefined): QuoteContribution[] {
|
|
271
|
+
return Object.values(map ?? {});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function toProvenance(quote: QuoteContribution | undefined): QuoteFieldProvenance | undefined {
|
|
275
|
+
if (!quote?.providerId) return undefined;
|
|
276
|
+
return {
|
|
277
|
+
providerId: quote.providerId,
|
|
278
|
+
dataSource: quote.dataSource,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function assignField(
|
|
283
|
+
target: Partial<Quote>,
|
|
284
|
+
provenance: QuoteProvenance,
|
|
285
|
+
field: string,
|
|
286
|
+
quote: QuoteContribution | undefined,
|
|
287
|
+
): void {
|
|
288
|
+
if (!quote) return;
|
|
289
|
+
const value = (quote as Record<string, unknown>)[field];
|
|
290
|
+
if (value === undefined) return;
|
|
291
|
+
(target as Record<string, unknown>)[field] = value;
|
|
292
|
+
provenance.fields ??= {};
|
|
293
|
+
provenance.fields[field] = toProvenance(quote)!;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function pickField(
|
|
297
|
+
target: Partial<Quote>,
|
|
298
|
+
provenance: QuoteProvenance,
|
|
299
|
+
field: string,
|
|
300
|
+
candidates: QuoteContribution[],
|
|
301
|
+
): QuoteContribution | undefined {
|
|
302
|
+
for (const candidate of candidates) {
|
|
303
|
+
if ((candidate as Record<string, unknown>)[field] === undefined) continue;
|
|
304
|
+
assignField(target, provenance, field, candidate);
|
|
305
|
+
return candidate;
|
|
306
|
+
}
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function buildAcceptedPriceCandidates(contributions: QuoteContribution[]): {
|
|
311
|
+
accepted: QuoteContribution[];
|
|
312
|
+
rejectedProviders: string[];
|
|
313
|
+
} {
|
|
314
|
+
const sorted = [...contributions]
|
|
315
|
+
.filter((quote) => Number.isFinite(quote.price))
|
|
316
|
+
.sort((left, right) => {
|
|
317
|
+
const rankDelta = priceRank(left) - priceRank(right);
|
|
318
|
+
if (rankDelta !== 0) return rankDelta;
|
|
319
|
+
return (right.lastUpdated ?? 0) - (left.lastUpdated ?? 0);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const accepted: QuoteContribution[] = [];
|
|
323
|
+
const rejectedProviders: string[] = [];
|
|
324
|
+
let referenceQuote: QuoteContribution | undefined;
|
|
325
|
+
|
|
326
|
+
for (const candidate of sorted) {
|
|
327
|
+
if (referenceQuote && hasLikelyQuoteUnitMismatch(referenceQuote, candidate)) {
|
|
328
|
+
rejectedProviders.push(candidate.providerId);
|
|
329
|
+
quoteResolutionLog.warn("Rejected quote contribution due to likely unit mismatch", {
|
|
330
|
+
acceptedProviderId: referenceQuote.providerId,
|
|
331
|
+
acceptedPrice: referenceQuote.price,
|
|
332
|
+
rejectedProviderId: candidate.providerId,
|
|
333
|
+
rejectedPrice: candidate.price,
|
|
334
|
+
symbol: candidate.symbol,
|
|
335
|
+
currency: candidate.currency,
|
|
336
|
+
});
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
accepted.push(candidate);
|
|
340
|
+
referenceQuote ??= candidate;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { accepted, rejectedProviders };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function filterFreshQuoteCandidates(
|
|
347
|
+
contributions: QuoteContribution[],
|
|
348
|
+
now: number,
|
|
349
|
+
): { accepted: QuoteContribution[]; rejectedProviders: string[] } {
|
|
350
|
+
if (!hasFreshQuoteForCurrentSession(contributions, now)) {
|
|
351
|
+
return { accepted: contributions, rejectedProviders: [] };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const accepted: QuoteContribution[] = [];
|
|
355
|
+
const rejectedProviders = new Set<string>();
|
|
356
|
+
|
|
357
|
+
for (const contribution of contributions) {
|
|
358
|
+
if (isQuoteStaleForCurrentSession(contribution, now)) {
|
|
359
|
+
rejectedProviders.add(contribution.providerId);
|
|
360
|
+
quoteResolutionLog.warn("Rejected stale quote contribution for the current session", {
|
|
361
|
+
providerId: contribution.providerId,
|
|
362
|
+
price: contribution.price,
|
|
363
|
+
marketState: contribution.marketState,
|
|
364
|
+
lastUpdated: contribution.lastUpdated,
|
|
365
|
+
exchange: contribution.listingExchangeName ?? contribution.exchangeName,
|
|
366
|
+
symbol: contribution.symbol,
|
|
367
|
+
});
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
accepted.push(contribution);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
accepted,
|
|
375
|
+
rejectedProviders: [...rejectedProviders],
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function buildSessionCandidates(contributions: QuoteContribution[]): QuoteContribution[] {
|
|
380
|
+
return [...contributions]
|
|
381
|
+
.filter((quote) => (
|
|
382
|
+
quote.marketState !== undefined
|
|
383
|
+
|| quote.sessionConfidence !== undefined
|
|
384
|
+
|| quote.preMarketPrice !== undefined
|
|
385
|
+
|| quote.postMarketPrice !== undefined
|
|
386
|
+
))
|
|
387
|
+
.sort((left, right) => {
|
|
388
|
+
const confidenceDelta = sessionConfidenceRank(left.sessionConfidence) - sessionConfidenceRank(right.sessionConfidence);
|
|
389
|
+
if (confidenceDelta !== 0) return confidenceDelta;
|
|
390
|
+
const providerDelta = providerKindRank(left.providerId) - providerKindRank(right.providerId);
|
|
391
|
+
if (providerDelta !== 0) return providerDelta;
|
|
392
|
+
return (right.lastUpdated ?? 0) - (left.lastUpdated ?? 0);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function matchesPreMarketState(state?: QuoteContribution["marketState"]): boolean {
|
|
397
|
+
return state === "PRE" || state === "PREPRE";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function matchesPostMarketState(state?: QuoteContribution["marketState"]): boolean {
|
|
401
|
+
return state === "POST" || state === "POSTPOST";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function buildListingCandidates(contributions: QuoteContribution[]): QuoteContribution[] {
|
|
405
|
+
return [...contributions]
|
|
406
|
+
.filter((quote) => quote.listingExchangeName || quote.listingExchangeFullName)
|
|
407
|
+
.sort((left, right) => {
|
|
408
|
+
const providerDelta = providerKindRank(left.providerId) - providerKindRank(right.providerId);
|
|
409
|
+
if (providerDelta !== 0) return providerDelta;
|
|
410
|
+
return (right.lastUpdated ?? 0) - (left.lastUpdated ?? 0);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function buildRoutingCandidates(contributions: QuoteContribution[]): QuoteContribution[] {
|
|
415
|
+
return [...contributions]
|
|
416
|
+
.filter((quote) => isBrokerProvider(quote.providerId) && (quote.routingExchangeName || quote.routingExchangeFullName))
|
|
417
|
+
.sort((left, right) => (right.lastUpdated ?? 0) - (left.lastUpdated ?? 0));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function buildDescriptiveCandidates(contributions: QuoteContribution[]): QuoteContribution[] {
|
|
421
|
+
return [...contributions]
|
|
422
|
+
.sort((left, right) => {
|
|
423
|
+
const providerDelta = providerKindRank(left.providerId) - providerKindRank(right.providerId);
|
|
424
|
+
if (providerDelta !== 0) return providerDelta;
|
|
425
|
+
return (right.lastUpdated ?? 0) - (left.lastUpdated ?? 0);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function resolveCanonicalQuote(
|
|
430
|
+
quoteContributions: QuoteContributionMap | undefined,
|
|
431
|
+
now = Date.now(),
|
|
432
|
+
): { quote?: Quote; provenance?: QuoteProvenance } {
|
|
433
|
+
const contributions = quoteContributionValues(quoteContributions);
|
|
434
|
+
if (contributions.length === 0) return {};
|
|
435
|
+
|
|
436
|
+
const { accepted: freshQuoteCandidates } = filterFreshQuoteCandidates(contributions, now);
|
|
437
|
+
const effectiveQuoteCandidates = freshQuoteCandidates.length > 0 ? freshQuoteCandidates : contributions;
|
|
438
|
+
const { accepted: acceptedPriceCandidates, rejectedProviders } = buildAcceptedPriceCandidates(effectiveQuoteCandidates);
|
|
439
|
+
if (acceptedPriceCandidates.length === 0) return {};
|
|
440
|
+
|
|
441
|
+
const sessionCandidates = buildSessionCandidates(effectiveQuoteCandidates);
|
|
442
|
+
const listingCandidates = buildListingCandidates(contributions);
|
|
443
|
+
const routingCandidates = buildRoutingCandidates(contributions);
|
|
444
|
+
const descriptiveCandidates = buildDescriptiveCandidates(contributions);
|
|
445
|
+
|
|
446
|
+
const resolved: Partial<Quote> = {};
|
|
447
|
+
const provenance: QuoteProvenance = {
|
|
448
|
+
rejectedPriceProviders: rejectedProviders.length > 0 ? rejectedProviders : undefined,
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const priceProvider = pickField(resolved, provenance, "price", acceptedPriceCandidates);
|
|
452
|
+
for (const field of PRICE_FIELD_KEYS) {
|
|
453
|
+
if (field === "price") continue;
|
|
454
|
+
pickField(resolved, provenance, field, acceptedPriceCandidates);
|
|
455
|
+
}
|
|
456
|
+
provenance.price = toProvenance(priceProvider);
|
|
457
|
+
|
|
458
|
+
const sessionProvider = sessionCandidates[0];
|
|
459
|
+
if (sessionProvider) {
|
|
460
|
+
for (const field of SESSION_FIELD_KEYS) {
|
|
461
|
+
assignField(resolved, provenance, field, sessionProvider);
|
|
462
|
+
}
|
|
463
|
+
provenance.session = toProvenance(sessionProvider);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const preSessionCandidates = sessionCandidates.filter((quote) => matchesPreMarketState(quote.marketState));
|
|
467
|
+
for (const field of PRE_SESSION_FIELD_KEYS) {
|
|
468
|
+
pickField(resolved, provenance, field, preSessionCandidates);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const postSessionCandidates = sessionCandidates.filter((quote) => matchesPostMarketState(quote.marketState));
|
|
472
|
+
for (const field of POST_SESSION_FIELD_KEYS) {
|
|
473
|
+
pickField(resolved, provenance, field, postSessionCandidates);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const listingProvider = listingCandidates[0];
|
|
477
|
+
if (listingProvider) {
|
|
478
|
+
assignField(resolved, provenance, "listingExchangeName", listingProvider);
|
|
479
|
+
assignField(resolved, provenance, "listingExchangeFullName", listingProvider);
|
|
480
|
+
resolved.exchangeName = resolved.listingExchangeName;
|
|
481
|
+
resolved.fullExchangeName = resolved.listingExchangeFullName;
|
|
482
|
+
provenance.fields ??= {};
|
|
483
|
+
if (resolved.exchangeName !== undefined) {
|
|
484
|
+
provenance.fields.exchangeName = toProvenance(listingProvider)!;
|
|
485
|
+
}
|
|
486
|
+
if (resolved.fullExchangeName !== undefined) {
|
|
487
|
+
provenance.fields.fullExchangeName = toProvenance(listingProvider)!;
|
|
488
|
+
}
|
|
489
|
+
provenance.listing = toProvenance(listingProvider);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const routingProvider = routingCandidates[0];
|
|
493
|
+
if (routingProvider) {
|
|
494
|
+
assignField(resolved, provenance, "routingExchangeName", routingProvider);
|
|
495
|
+
assignField(resolved, provenance, "routingExchangeFullName", routingProvider);
|
|
496
|
+
provenance.routing = toProvenance(routingProvider);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
let descriptiveProvider: QuoteContribution | undefined;
|
|
500
|
+
for (const field of DESCRIPTIVE_FIELD_KEYS) {
|
|
501
|
+
const provider = pickField(resolved, provenance, field, descriptiveCandidates);
|
|
502
|
+
descriptiveProvider ??= provider;
|
|
503
|
+
}
|
|
504
|
+
provenance.descriptive = toProvenance(descriptiveProvider);
|
|
505
|
+
|
|
506
|
+
const canonical = finalizeSessionFields({
|
|
507
|
+
symbol: String(resolved.symbol ?? priceProvider?.symbol ?? contributions[0]!.symbol ?? ""),
|
|
508
|
+
providerId: priceProvider?.providerId ?? contributions[0]!.providerId,
|
|
509
|
+
price: Number(resolved.price ?? priceProvider?.price ?? 0),
|
|
510
|
+
currency: String(resolved.currency ?? priceProvider?.currency ?? contributions[0]!.currency ?? ""),
|
|
511
|
+
change: Number(resolved.change ?? priceProvider?.change ?? 0),
|
|
512
|
+
changePercent: Number(resolved.changePercent ?? priceProvider?.changePercent ?? 0),
|
|
513
|
+
previousClose: resolved.previousClose as Quote["previousClose"],
|
|
514
|
+
high52w: resolved.high52w as Quote["high52w"],
|
|
515
|
+
low52w: resolved.low52w as Quote["low52w"],
|
|
516
|
+
marketCap: resolved.marketCap as Quote["marketCap"],
|
|
517
|
+
volume: resolved.volume as Quote["volume"],
|
|
518
|
+
name: resolved.name as Quote["name"],
|
|
519
|
+
lastUpdated: Number(resolved.lastUpdated ?? priceProvider?.lastUpdated ?? sessionProvider?.lastUpdated ?? now),
|
|
520
|
+
exchangeName: resolved.exchangeName as Quote["exchangeName"],
|
|
521
|
+
fullExchangeName: resolved.fullExchangeName as Quote["fullExchangeName"],
|
|
522
|
+
listingExchangeName: resolved.listingExchangeName as Quote["listingExchangeName"],
|
|
523
|
+
listingExchangeFullName: resolved.listingExchangeFullName as Quote["listingExchangeFullName"],
|
|
524
|
+
routingExchangeName: resolved.routingExchangeName as Quote["routingExchangeName"],
|
|
525
|
+
routingExchangeFullName: resolved.routingExchangeFullName as Quote["routingExchangeFullName"],
|
|
526
|
+
marketState: resolved.marketState as Quote["marketState"],
|
|
527
|
+
sessionConfidence: resolved.sessionConfidence as Quote["sessionConfidence"],
|
|
528
|
+
preMarketPrice: resolved.preMarketPrice as Quote["preMarketPrice"],
|
|
529
|
+
preMarketChange: resolved.preMarketChange as Quote["preMarketChange"],
|
|
530
|
+
preMarketChangePercent: resolved.preMarketChangePercent as Quote["preMarketChangePercent"],
|
|
531
|
+
postMarketPrice: resolved.postMarketPrice as Quote["postMarketPrice"],
|
|
532
|
+
postMarketChange: resolved.postMarketChange as Quote["postMarketChange"],
|
|
533
|
+
postMarketChangePercent: resolved.postMarketChangePercent as Quote["postMarketChangePercent"],
|
|
534
|
+
bid: resolved.bid as Quote["bid"],
|
|
535
|
+
ask: resolved.ask as Quote["ask"],
|
|
536
|
+
bidSize: resolved.bidSize as Quote["bidSize"],
|
|
537
|
+
askSize: resolved.askSize as Quote["askSize"],
|
|
538
|
+
open: resolved.open as Quote["open"],
|
|
539
|
+
high: resolved.high as Quote["high"],
|
|
540
|
+
low: resolved.low as Quote["low"],
|
|
541
|
+
mark: resolved.mark as Quote["mark"],
|
|
542
|
+
dataSource: (resolved.dataSource as QuoteDataSource | undefined) ?? priceProvider?.dataSource,
|
|
543
|
+
provenance,
|
|
544
|
+
}, {
|
|
545
|
+
allowPriceProjection: false,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
quote: canonical,
|
|
550
|
+
provenance,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function seedQuoteContributions(financials: TickerFinancials | null | undefined): QuoteContributionMap | undefined {
|
|
555
|
+
if (!financials) return undefined;
|
|
556
|
+
if (financials.quoteContributions && Object.keys(financials.quoteContributions).length > 0) {
|
|
557
|
+
return cloneQuoteContributionMap(financials.quoteContributions);
|
|
558
|
+
}
|
|
559
|
+
const normalizedQuote = normalizeQuoteContribution(financials.quote);
|
|
560
|
+
if (!normalizedQuote) return undefined;
|
|
561
|
+
const key = getQuoteContributionKey(normalizedQuote);
|
|
562
|
+
return { [key]: normalizedQuote };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function upsertQuoteContributionMap(
|
|
566
|
+
current: QuoteContributionMap | undefined,
|
|
567
|
+
quote: Quote | QuoteContribution,
|
|
568
|
+
options: { rejectUnitMismatch?: boolean; now?: number } = {},
|
|
569
|
+
): QuoteContributionMap {
|
|
570
|
+
const normalized = normalizeQuoteContribution(quote);
|
|
571
|
+
if (!normalized) return current ?? {};
|
|
572
|
+
const now = options.now ?? Date.now();
|
|
573
|
+
if (isQuoteStaleForCurrentSession(normalized, now) && hasFreshQuoteForCurrentSession(quoteContributionValues(current), now)) {
|
|
574
|
+
quoteResolutionLog.warn("Rejected incoming stale quote contribution for the current session", {
|
|
575
|
+
incomingProviderId: normalized.providerId,
|
|
576
|
+
incomingPrice: normalized.price,
|
|
577
|
+
lastUpdated: normalized.lastUpdated,
|
|
578
|
+
marketState: normalized.marketState,
|
|
579
|
+
exchange: normalized.listingExchangeName ?? normalized.exchangeName,
|
|
580
|
+
symbol: normalized.symbol,
|
|
581
|
+
});
|
|
582
|
+
return current ?? {};
|
|
583
|
+
}
|
|
584
|
+
if (options.rejectUnitMismatch) {
|
|
585
|
+
const acceptedQuote = resolveCanonicalQuote(current).quote;
|
|
586
|
+
if (acceptedQuote && hasLikelyQuoteUnitMismatch(acceptedQuote, normalized)) {
|
|
587
|
+
quoteResolutionLog.warn("Rejected incoming quote contribution due to likely unit mismatch", {
|
|
588
|
+
acceptedProviderId: acceptedQuote.providerId,
|
|
589
|
+
acceptedPrice: acceptedQuote.price,
|
|
590
|
+
incomingProviderId: normalized.providerId,
|
|
591
|
+
incomingPrice: normalized.price,
|
|
592
|
+
symbol: normalized.symbol,
|
|
593
|
+
currency: normalized.currency,
|
|
594
|
+
});
|
|
595
|
+
return current ?? {};
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const key = getQuoteContributionKey(normalized);
|
|
599
|
+
const nextMap = { ...(current ?? {}) };
|
|
600
|
+
let baseContribution = nextMap[key];
|
|
601
|
+
|
|
602
|
+
if (!baseContribution && key !== "quote") {
|
|
603
|
+
const genericContribution = nextMap.quote;
|
|
604
|
+
if (genericContribution?.providerId === "quote") {
|
|
605
|
+
baseContribution = genericContribution;
|
|
606
|
+
delete nextMap.quote;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
...nextMap,
|
|
612
|
+
[key]: mergeQuoteContribution(baseContribution, normalized),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function resolveTickerFinancialsQuoteState(
|
|
617
|
+
financials: TickerFinancials | null | undefined,
|
|
618
|
+
incomingQuote?: Quote | QuoteContribution | null,
|
|
619
|
+
): TickerFinancials | null {
|
|
620
|
+
if (!financials && !incomingQuote) return null;
|
|
621
|
+
|
|
622
|
+
const baseFinancials: TickerFinancials = financials ?? {
|
|
623
|
+
annualStatements: [],
|
|
624
|
+
quarterlyStatements: [],
|
|
625
|
+
priceHistory: [],
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
let quoteContributions = seedQuoteContributions(baseFinancials);
|
|
629
|
+
if (incomingQuote) {
|
|
630
|
+
quoteContributions = upsertQuoteContributionMap(quoteContributions, incomingQuote, {
|
|
631
|
+
rejectUnitMismatch: true,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const { quote } = resolveCanonicalQuote(quoteContributions);
|
|
636
|
+
return {
|
|
637
|
+
...baseFinancials,
|
|
638
|
+
quote,
|
|
639
|
+
quoteContributions,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const ISO_TIMEZONE_SUFFIX = /(Z|[+-]\d{2}:?\d{2})$/i;
|
|
2
|
+
const DATE_TIME_SPACE = /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)$/i;
|
|
3
|
+
|
|
4
|
+
function normalizeTimestampInput(value: string): string {
|
|
5
|
+
const trimmed = value.trim();
|
|
6
|
+
if (!trimmed) return trimmed;
|
|
7
|
+
const withSeparator = DATE_TIME_SPACE.test(trimmed)
|
|
8
|
+
? trimmed.replace(DATE_TIME_SPACE, "$1T$2")
|
|
9
|
+
: trimmed;
|
|
10
|
+
return ISO_TIMEZONE_SUFFIX.test(withSeparator) ? withSeparator : `${withSeparator}Z`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeTimestamp(value: Date | string): string {
|
|
14
|
+
if (value instanceof Date) return value.toISOString();
|
|
15
|
+
|
|
16
|
+
const normalized = normalizeTimestampInput(value);
|
|
17
|
+
if (!normalized) return normalized;
|
|
18
|
+
|
|
19
|
+
const parsed = new Date(normalized);
|
|
20
|
+
return Number.isNaN(parsed.getTime()) ? normalized : parsed.toISOString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function toTimestampMillis(value: Date | string): number {
|
|
24
|
+
return new Date(normalizeTimestamp(value)).getTime();
|
|
25
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.4.0";
|