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
|
@@ -16,9 +16,22 @@ import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../types
|
|
|
16
16
|
import type { BrokerContractRef, InstrumentSearchResult } from "../types/instrument";
|
|
17
17
|
import type { CachePolicy, CachePolicyMap } from "../types/persistence";
|
|
18
18
|
import type { TimeRange } from "../components/chart/chart-types";
|
|
19
|
+
import {
|
|
20
|
+
isIntradayResolution,
|
|
21
|
+
normalizeChartResolutionSupport,
|
|
22
|
+
type ChartResolutionSupport,
|
|
23
|
+
type ManualChartResolution,
|
|
24
|
+
} from "../components/chart/chart-resolution";
|
|
19
25
|
import { hasLikelyQuoteUnitMismatch } from "../utils/currency-units";
|
|
20
26
|
import { debugLog } from "../utils/debug-log";
|
|
21
27
|
import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
|
|
28
|
+
import { isQuoteStaleForCurrentSession } from "../utils/quote-freshness";
|
|
29
|
+
import {
|
|
30
|
+
mergeQuoteContributionMaps,
|
|
31
|
+
resolveCanonicalQuote,
|
|
32
|
+
resolveTickerFinancialsQuoteState,
|
|
33
|
+
seedQuoteContributions,
|
|
34
|
+
} from "../utils/quote-resolution";
|
|
22
35
|
import { isProviderMiss } from "./provider-errors";
|
|
23
36
|
|
|
24
37
|
const providerLog = debugLog.createLogger("provider-router");
|
|
@@ -65,6 +78,15 @@ function normalizeExchange(exchange?: string): string {
|
|
|
65
78
|
return (exchange ?? "").trim().toUpperCase();
|
|
66
79
|
}
|
|
67
80
|
|
|
81
|
+
function sanitizeCachedFinancials(financials: TickerFinancials): TickerFinancials {
|
|
82
|
+
if (!isQuoteStaleForCurrentSession(financials.quote)) return financials;
|
|
83
|
+
return {
|
|
84
|
+
...financials,
|
|
85
|
+
quote: undefined,
|
|
86
|
+
quoteContributions: undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
function compactUrl(url: string): string {
|
|
69
91
|
return url.trim();
|
|
70
92
|
}
|
|
@@ -106,7 +128,7 @@ function buildVariantKey(parts: Array<[string, string | number | undefined | nul
|
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
function isIntradayRange(range: TimeRange): boolean {
|
|
109
|
-
return range === "1W" || range === "1M" || range === "3M";
|
|
131
|
+
return range === "1D" || range === "1W" || range === "1M" || range === "3M";
|
|
110
132
|
}
|
|
111
133
|
|
|
112
134
|
function hasMeaningfulFundamentals(data: TickerFinancials | null | undefined): boolean {
|
|
@@ -144,17 +166,23 @@ function mergeDefinedObject<T extends object>(preferred: T | null | undefined, f
|
|
|
144
166
|
function mergeFinancials(primary: TickerFinancials | null, fallback: TickerFinancials | null): TickerFinancials | null {
|
|
145
167
|
if (!primary || !fallback) {
|
|
146
168
|
const single = primary ?? fallback;
|
|
147
|
-
return single ? normalizeTickerFinancialsPriceHistory(single) : null;
|
|
169
|
+
return single ? resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(single)) : null;
|
|
148
170
|
}
|
|
149
171
|
|
|
150
172
|
const preferFallbackPriceData = hasLikelyPriceUnitMismatch(primary, fallback);
|
|
151
173
|
const dominant = preferFallbackPriceData ? fallback : primary;
|
|
152
174
|
const secondary = preferFallbackPriceData ? primary : fallback;
|
|
175
|
+
const quoteContributions = mergeQuoteContributionMaps(
|
|
176
|
+
seedQuoteContributions(primary),
|
|
177
|
+
seedQuoteContributions(fallback),
|
|
178
|
+
);
|
|
179
|
+
const resolvedQuote = resolveCanonicalQuote(quoteContributions).quote;
|
|
153
180
|
|
|
154
181
|
return {
|
|
155
182
|
...fallback,
|
|
156
183
|
...primary,
|
|
157
|
-
quote:
|
|
184
|
+
quote: resolvedQuote,
|
|
185
|
+
quoteContributions,
|
|
158
186
|
profile: mergeDefinedObject(primary.profile, fallback.profile),
|
|
159
187
|
fundamentals: mergeDefinedObject(primary.fundamentals, fallback.fundamentals),
|
|
160
188
|
priceHistory: normalizePriceHistory(dominant.priceHistory.length > 0 ? dominant.priceHistory : secondary.priceHistory),
|
|
@@ -174,7 +202,7 @@ function mergeCachedFinancialRecords(records: CachedResourceRecord<TickerFinanci
|
|
|
174
202
|
for (const record of records) {
|
|
175
203
|
if (seenSources.has(record.sourceKey)) continue;
|
|
176
204
|
seenSources.add(record.sourceKey);
|
|
177
|
-
merged = mergeFinancials(merged, record.value);
|
|
205
|
+
merged = mergeFinancials(merged, sanitizeCachedFinancials(record.value));
|
|
178
206
|
stale = stale || record.stale;
|
|
179
207
|
}
|
|
180
208
|
|
|
@@ -299,7 +327,10 @@ export class ProviderRouter implements DataProvider {
|
|
|
299
327
|
...this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate)),
|
|
300
328
|
...this.getProviderSourceKeys(),
|
|
301
329
|
];
|
|
302
|
-
const
|
|
330
|
+
const rawCached = this.selectCachedResource<Quote>("quote", entityKey, variantKeys, sourceKeys, false);
|
|
331
|
+
const cached = rawCached && !isQuoteStaleForCurrentSession(rawCached.value)
|
|
332
|
+
? rawCached
|
|
333
|
+
: null;
|
|
303
334
|
const forceRefresh = context?.cacheMode === "refresh";
|
|
304
335
|
if (cached && !forceRefresh && !cached.stale) {
|
|
305
336
|
return cached.value;
|
|
@@ -503,6 +534,65 @@ export class ProviderRouter implements DataProvider {
|
|
|
503
534
|
return providerHistory.value;
|
|
504
535
|
}
|
|
505
536
|
|
|
537
|
+
async getPriceHistoryForResolution(
|
|
538
|
+
ticker: string,
|
|
539
|
+
exchange: string,
|
|
540
|
+
bufferRange: TimeRange,
|
|
541
|
+
resolution: ManualChartResolution,
|
|
542
|
+
context?: MarketDataRequestContext,
|
|
543
|
+
): Promise<PricePoint[]> {
|
|
544
|
+
const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
|
|
545
|
+
const variantKeys = [
|
|
546
|
+
buildVariantKey([["exchange", normalizeExchange(exchange)], ["range", bufferRange], ["resolution", resolution]]),
|
|
547
|
+
buildVariantKey([["range", bufferRange], ["resolution", resolution]]),
|
|
548
|
+
];
|
|
549
|
+
const sourceKeys = [
|
|
550
|
+
...this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate)),
|
|
551
|
+
...this.getProviderSourceKeys(),
|
|
552
|
+
];
|
|
553
|
+
const cached = this.selectCachedArrayResource<PricePoint>("price-history", entityKey, variantKeys, sourceKeys, false);
|
|
554
|
+
const cachedValue = cached ? normalizePriceHistory(cached.value) : [];
|
|
555
|
+
const forceRefresh = context?.cacheMode === "refresh";
|
|
556
|
+
if (cachedValue.length > 0 && !forceRefresh && cached && !cached.stale) {
|
|
557
|
+
return cachedValue;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const brokerHistory = await withBrokerTimeout(this.fetchBrokerPriceHistoryForResolution(ticker, exchange, bufferRange, resolution, context));
|
|
561
|
+
if (brokerHistory && brokerHistory.value.length > 0) return brokerHistory.value;
|
|
562
|
+
|
|
563
|
+
const providerHistory = await this.fetchProviderPriceHistoryForResolution(ticker, exchange, bufferRange, resolution, context);
|
|
564
|
+
if (providerHistory && providerHistory.value.length > 0) {
|
|
565
|
+
return providerHistory.value;
|
|
566
|
+
}
|
|
567
|
+
if (cachedValue.length > 0) return cachedValue;
|
|
568
|
+
if (!providerHistory) {
|
|
569
|
+
throw new Error(`No resolution-aware history provider available for ${ticker}`);
|
|
570
|
+
}
|
|
571
|
+
return providerHistory.value;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async getChartResolutionSupport(
|
|
575
|
+
ticker: string,
|
|
576
|
+
exchange?: string,
|
|
577
|
+
context?: MarketDataRequestContext,
|
|
578
|
+
): Promise<ChartResolutionSupport[]> {
|
|
579
|
+
const brokerSupport = await withBrokerTimeout(this.fetchBrokerChartResolutionSupport(ticker, exchange, context));
|
|
580
|
+
if (brokerSupport && brokerSupport.value.length > 0) {
|
|
581
|
+
return brokerSupport.value;
|
|
582
|
+
}
|
|
583
|
+
const providerSupport = await this.fetchProviderChartResolutionSupport(ticker, exchange, context);
|
|
584
|
+
return providerSupport?.value ?? [];
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async getChartResolutionCapabilities(
|
|
588
|
+
ticker: string,
|
|
589
|
+
exchange?: string,
|
|
590
|
+
context?: MarketDataRequestContext,
|
|
591
|
+
): Promise<ManualChartResolution[]> {
|
|
592
|
+
const support = await this.getChartResolutionSupport(ticker, exchange, context);
|
|
593
|
+
return support.map((entry) => entry.resolution);
|
|
594
|
+
}
|
|
595
|
+
|
|
506
596
|
async getDetailedPriceHistory(
|
|
507
597
|
ticker: string,
|
|
508
598
|
exchange: string,
|
|
@@ -717,6 +807,9 @@ export class ProviderRouter implements DataProvider {
|
|
|
717
807
|
const brokerRecord = brokerSourceKeys.length > 0
|
|
718
808
|
? this.selectCachedResource<TickerFinancials>("financials", entityKey, variantKeys, brokerSourceKeys, allowExpired)
|
|
719
809
|
: null;
|
|
810
|
+
const sanitizedBrokerRecord = brokerRecord
|
|
811
|
+
? { ...brokerRecord, value: sanitizeCachedFinancials(brokerRecord.value) }
|
|
812
|
+
: null;
|
|
720
813
|
const providerSelection = mergeCachedFinancialRecords(
|
|
721
814
|
this.listCachedResources<TickerFinancials>(
|
|
722
815
|
"financials",
|
|
@@ -727,10 +820,10 @@ export class ProviderRouter implements DataProvider {
|
|
|
727
820
|
),
|
|
728
821
|
);
|
|
729
822
|
return {
|
|
730
|
-
brokerRecord,
|
|
823
|
+
brokerRecord: sanitizedBrokerRecord,
|
|
731
824
|
providerValue: providerSelection.value,
|
|
732
|
-
value: mergeFinancials(
|
|
733
|
-
stale: (
|
|
825
|
+
value: mergeFinancials(sanitizedBrokerRecord?.value ?? null, providerSelection.value),
|
|
826
|
+
stale: (sanitizedBrokerRecord?.stale ?? false) || providerSelection.stale,
|
|
734
827
|
};
|
|
735
828
|
}
|
|
736
829
|
|
|
@@ -880,12 +973,14 @@ export class ProviderRouter implements DataProvider {
|
|
|
880
973
|
for (const candidate of this.getBrokerCandidatesForContext(context, false)) {
|
|
881
974
|
if (!candidate.broker.getTickerFinancials) continue;
|
|
882
975
|
try {
|
|
883
|
-
const
|
|
976
|
+
const rawResult = await candidate.broker.getTickerFinancials(
|
|
884
977
|
ticker,
|
|
885
978
|
candidate.instance,
|
|
886
979
|
exchange,
|
|
887
980
|
context?.instrument ?? null,
|
|
888
|
-
)
|
|
981
|
+
);
|
|
982
|
+
const result = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(rawResult));
|
|
983
|
+
if (!result) continue;
|
|
889
984
|
this.cacheResource(
|
|
890
985
|
"financials",
|
|
891
986
|
entityKey,
|
|
@@ -914,7 +1009,9 @@ export class ProviderRouter implements DataProvider {
|
|
|
914
1009
|
|
|
915
1010
|
for (const provider of this.providersInPriorityOrder()) {
|
|
916
1011
|
try {
|
|
917
|
-
const
|
|
1012
|
+
const rawValue = await provider.getTickerFinancials(ticker, exchange, context);
|
|
1013
|
+
const value = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(rawValue));
|
|
1014
|
+
if (!value) continue;
|
|
918
1015
|
const sourceKey = this.providerSourceKey(provider);
|
|
919
1016
|
this.cacheResource(
|
|
920
1017
|
"financials",
|
|
@@ -1020,6 +1117,42 @@ export class ProviderRouter implements DataProvider {
|
|
|
1020
1117
|
return null;
|
|
1021
1118
|
}
|
|
1022
1119
|
|
|
1120
|
+
private async fetchBrokerPriceHistoryForResolution(
|
|
1121
|
+
ticker: string,
|
|
1122
|
+
exchange: string,
|
|
1123
|
+
bufferRange: TimeRange,
|
|
1124
|
+
resolution: ManualChartResolution,
|
|
1125
|
+
context?: MarketDataRequestContext,
|
|
1126
|
+
): Promise<SourceResult<PricePoint[]> | null> {
|
|
1127
|
+
const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
|
|
1128
|
+
const variantKey = buildVariantKey([["exchange", normalizeExchange(exchange)], ["range", bufferRange], ["resolution", resolution]]);
|
|
1129
|
+
for (const candidate of this.getBrokerCandidatesForContext(context, false)) {
|
|
1130
|
+
if (!candidate.broker.getPriceHistoryForResolution) continue;
|
|
1131
|
+
try {
|
|
1132
|
+
const result = normalizePriceHistory(await candidate.broker.getPriceHistoryForResolution(
|
|
1133
|
+
ticker,
|
|
1134
|
+
candidate.instance,
|
|
1135
|
+
exchange,
|
|
1136
|
+
bufferRange,
|
|
1137
|
+
resolution,
|
|
1138
|
+
context?.instrument ?? null,
|
|
1139
|
+
));
|
|
1140
|
+
this.cacheResource(
|
|
1141
|
+
"price-history",
|
|
1142
|
+
entityKey,
|
|
1143
|
+
variantKey,
|
|
1144
|
+
this.brokerSourceKey(candidate),
|
|
1145
|
+
result,
|
|
1146
|
+
this.resolveBrokerPolicy(isIntradayResolution(resolution) ? "priceHistoryIntraday" : "priceHistoryDaily", candidate.broker),
|
|
1147
|
+
);
|
|
1148
|
+
return { sourceKey: this.brokerSourceKey(candidate), value: result };
|
|
1149
|
+
} catch {
|
|
1150
|
+
// continue
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return null;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1023
1156
|
private async fetchProviderPriceHistory(
|
|
1024
1157
|
ticker: string,
|
|
1025
1158
|
exchange: string,
|
|
@@ -1056,6 +1189,101 @@ export class ProviderRouter implements DataProvider {
|
|
|
1056
1189
|
return firstEmptyResult;
|
|
1057
1190
|
}
|
|
1058
1191
|
|
|
1192
|
+
private async fetchProviderPriceHistoryForResolution(
|
|
1193
|
+
ticker: string,
|
|
1194
|
+
exchange: string,
|
|
1195
|
+
bufferRange: TimeRange,
|
|
1196
|
+
resolution: ManualChartResolution,
|
|
1197
|
+
context?: MarketDataRequestContext,
|
|
1198
|
+
): Promise<SourceResult<PricePoint[]> | null> {
|
|
1199
|
+
const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
|
|
1200
|
+
const variantKey = buildVariantKey([["exchange", normalizeExchange(exchange)], ["range", bufferRange], ["resolution", resolution]]);
|
|
1201
|
+
let firstEmptyResult: SourceResult<PricePoint[]> | null = null;
|
|
1202
|
+
|
|
1203
|
+
for (const provider of this.providersInPriorityOrder()) {
|
|
1204
|
+
if (!provider.getPriceHistoryForResolution) continue;
|
|
1205
|
+
try {
|
|
1206
|
+
const value = normalizePriceHistory(await provider.getPriceHistoryForResolution(ticker, exchange, bufferRange, resolution, context));
|
|
1207
|
+
const sourceKey = this.providerSourceKey(provider);
|
|
1208
|
+
this.cacheResource(
|
|
1209
|
+
"price-history",
|
|
1210
|
+
entityKey,
|
|
1211
|
+
variantKey,
|
|
1212
|
+
sourceKey,
|
|
1213
|
+
value,
|
|
1214
|
+
this.resolveProviderPolicy(isIntradayResolution(resolution) ? "priceHistoryIntraday" : "priceHistoryDaily", provider),
|
|
1215
|
+
);
|
|
1216
|
+
if (value.length > 0) {
|
|
1217
|
+
return { sourceKey, value };
|
|
1218
|
+
}
|
|
1219
|
+
firstEmptyResult ??= { sourceKey, value };
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
if (shouldLogProviderError(error)) {
|
|
1222
|
+
providerLog.error(`${provider.id} failed: ${error}`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
return firstEmptyResult;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
private async fetchBrokerChartResolutionSupport(
|
|
1231
|
+
ticker: string,
|
|
1232
|
+
exchange?: string,
|
|
1233
|
+
context?: MarketDataRequestContext,
|
|
1234
|
+
): Promise<SourceResult<ChartResolutionSupport[]> | null> {
|
|
1235
|
+
for (const candidate of this.getBrokerCandidatesForContext(context, false)) {
|
|
1236
|
+
if (!candidate.broker.getChartResolutionSupport && !candidate.broker.getChartResolutionCapabilities) continue;
|
|
1237
|
+
try {
|
|
1238
|
+
const result = candidate.broker.getChartResolutionSupport
|
|
1239
|
+
? normalizeChartResolutionSupport(await candidate.broker.getChartResolutionSupport(
|
|
1240
|
+
ticker,
|
|
1241
|
+
candidate.instance,
|
|
1242
|
+
exchange,
|
|
1243
|
+
context?.instrument ?? null,
|
|
1244
|
+
))
|
|
1245
|
+
: normalizeChartResolutionSupport(
|
|
1246
|
+
(await candidate.broker.getChartResolutionCapabilities!(
|
|
1247
|
+
ticker,
|
|
1248
|
+
candidate.instance,
|
|
1249
|
+
exchange,
|
|
1250
|
+
context?.instrument ?? null,
|
|
1251
|
+
)).map((resolution) => ({ resolution, maxRange: "ALL" })),
|
|
1252
|
+
);
|
|
1253
|
+
if (result.length === 0) continue;
|
|
1254
|
+
return { sourceKey: this.brokerSourceKey(candidate), value: result };
|
|
1255
|
+
} catch {
|
|
1256
|
+
// continue
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
private async fetchProviderChartResolutionSupport(
|
|
1263
|
+
ticker: string,
|
|
1264
|
+
exchange?: string,
|
|
1265
|
+
context?: MarketDataRequestContext,
|
|
1266
|
+
): Promise<SourceResult<ChartResolutionSupport[]> | null> {
|
|
1267
|
+
for (const provider of this.providersInPriorityOrder()) {
|
|
1268
|
+
if (!provider.getChartResolutionSupport && !provider.getChartResolutionCapabilities) continue;
|
|
1269
|
+
try {
|
|
1270
|
+
const result = provider.getChartResolutionSupport
|
|
1271
|
+
? normalizeChartResolutionSupport(await provider.getChartResolutionSupport(ticker, exchange, context))
|
|
1272
|
+
: normalizeChartResolutionSupport(
|
|
1273
|
+
(await provider.getChartResolutionCapabilities!(ticker, exchange, context)).map((resolution) => ({ resolution, maxRange: "ALL" })),
|
|
1274
|
+
);
|
|
1275
|
+
if (result.length === 0) continue;
|
|
1276
|
+
return { sourceKey: this.providerSourceKey(provider), value: result };
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
if (shouldLogProviderError(error)) {
|
|
1279
|
+
providerLog.error(`${provider.id} failed: ${error}`);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
return null;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1059
1287
|
private async fetchBrokerDetailedPriceHistory(
|
|
1060
1288
|
ticker: string,
|
|
1061
1289
|
exchange: string,
|
|
@@ -11,4 +11,30 @@ describe("YahooFinanceClient exchange aliases", () => {
|
|
|
11
11
|
const provider = new YahooFinanceClient() as any;
|
|
12
12
|
expect(provider.getSymbolsToTry("HY9H", "FWB2")).toEqual(["HY9H.F", "HY9H.DE"]);
|
|
13
13
|
});
|
|
14
|
+
|
|
15
|
+
test("reports its manual chart resolution capabilities", () => {
|
|
16
|
+
const provider = new YahooFinanceClient();
|
|
17
|
+
expect(provider.getChartResolutionCapabilities()).toEqual(["5m", "15m", "1h", "1d", "1wk", "1mo"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("maps manual resolution requests to yahoo chart range plus interval", async () => {
|
|
21
|
+
const provider = new YahooFinanceClient() as any;
|
|
22
|
+
let request: { symbol: string; range: string; interval: string } | null = null;
|
|
23
|
+
provider.fetchChart = async (symbol: string, range: string, interval: string) => {
|
|
24
|
+
request = { symbol, range, interval };
|
|
25
|
+
return {
|
|
26
|
+
meta: { currency: "USD" },
|
|
27
|
+
history: [{ date: new Date("2026-03-30T00:00:00Z"), close: 200 }],
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const history = await provider.getPriceHistoryForResolution("AAPL", "NASDAQ", "1Y", "1wk");
|
|
32
|
+
|
|
33
|
+
expect(request).toEqual({
|
|
34
|
+
symbol: "AAPL",
|
|
35
|
+
range: "1y",
|
|
36
|
+
interval: "1wk",
|
|
37
|
+
});
|
|
38
|
+
expect(history[0]?.close).toBe(200);
|
|
39
|
+
});
|
|
14
40
|
});
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { Quote, Fundamentals, FinancialStatement, PricePoint, TickerFinancials, MarketState, OptionContract, OptionsChain, CompanyProfile } from "../types/financials";
|
|
2
2
|
import type { DataProvider, MarketDataRequestContext, NewsItem, SecFilingItem } from "../types/data-provider";
|
|
3
3
|
import type { TimeRange } from "../components/chart/chart-types";
|
|
4
|
+
import {
|
|
5
|
+
normalizeChartResolutionSupport,
|
|
6
|
+
type ChartResolutionSupport,
|
|
7
|
+
type ManualChartResolution,
|
|
8
|
+
} from "../components/chart/chart-resolution";
|
|
4
9
|
import type { InstrumentSearchResult } from "../types/instrument";
|
|
5
10
|
import { SecEdgarClient } from "./sec-edgar";
|
|
6
11
|
|
|
@@ -272,6 +277,7 @@ const INTRADAY_HISTORY_TTL = 5 * 60_000; // 5 min for intraday ranges
|
|
|
272
277
|
|
|
273
278
|
// Maps TimeRange → Yahoo API { range, interval } for optimal granularity
|
|
274
279
|
const RANGE_PARAMS: Record<TimeRange, { range: string; interval: string; ttl: number }> = {
|
|
280
|
+
"1D": { range: "1d", interval: "5m", ttl: INTRADAY_HISTORY_TTL },
|
|
275
281
|
"1W": { range: "5d", interval: "5m", ttl: INTRADAY_HISTORY_TTL },
|
|
276
282
|
"1M": { range: "1mo", interval: "15m", ttl: INTRADAY_HISTORY_TTL },
|
|
277
283
|
"3M": { range: "3mo", interval: "1h", ttl: QUOTE_TTL },
|
|
@@ -281,6 +287,15 @@ const RANGE_PARAMS: Record<TimeRange, { range: string; interval: string; ttl: nu
|
|
|
281
287
|
"ALL": { range: "max", interval: "1wk", ttl: HISTORY_TTL },
|
|
282
288
|
};
|
|
283
289
|
|
|
290
|
+
const YAHOO_RESOLUTION_SUPPORT = normalizeChartResolutionSupport([
|
|
291
|
+
{ resolution: "5m", maxRange: "1W" },
|
|
292
|
+
{ resolution: "15m", maxRange: "1M" },
|
|
293
|
+
{ resolution: "1h", maxRange: "3M" },
|
|
294
|
+
{ resolution: "1d", maxRange: "5Y" },
|
|
295
|
+
{ resolution: "1wk", maxRange: "ALL" },
|
|
296
|
+
{ resolution: "1mo", maxRange: "ALL" },
|
|
297
|
+
]);
|
|
298
|
+
|
|
284
299
|
export class YahooFinanceClient implements DataProvider {
|
|
285
300
|
readonly id = "yahoo";
|
|
286
301
|
readonly name = "Yahoo Finance";
|
|
@@ -660,6 +675,7 @@ export class YahooFinanceClient implements DataProvider {
|
|
|
660
675
|
|
|
661
676
|
const quote: Quote = {
|
|
662
677
|
symbol,
|
|
678
|
+
providerId: this.id,
|
|
663
679
|
price: currentPrice,
|
|
664
680
|
currency: normalizedCurrency,
|
|
665
681
|
change,
|
|
@@ -671,7 +687,10 @@ export class YahooFinanceClient implements DataProvider {
|
|
|
671
687
|
lastUpdated: Date.now(),
|
|
672
688
|
exchangeName: meta.exchangeName,
|
|
673
689
|
fullExchangeName: meta.fullExchangeName,
|
|
690
|
+
listingExchangeName: meta.exchangeName,
|
|
691
|
+
listingExchangeFullName: meta.fullExchangeName,
|
|
674
692
|
marketState,
|
|
693
|
+
sessionConfidence: "derived",
|
|
675
694
|
dataSource: "yahoo",
|
|
676
695
|
...quoteSupplement,
|
|
677
696
|
...extHours,
|
|
@@ -799,6 +818,7 @@ export class YahooFinanceClient implements DataProvider {
|
|
|
799
818
|
|
|
800
819
|
const quote: Quote = {
|
|
801
820
|
symbol,
|
|
821
|
+
providerId: this.id,
|
|
802
822
|
price,
|
|
803
823
|
currency: normalizedCurrency,
|
|
804
824
|
change,
|
|
@@ -809,7 +829,10 @@ export class YahooFinanceClient implements DataProvider {
|
|
|
809
829
|
lastUpdated: Date.now(),
|
|
810
830
|
exchangeName: meta.exchangeName,
|
|
811
831
|
fullExchangeName: meta.fullExchangeName,
|
|
832
|
+
listingExchangeName: meta.exchangeName,
|
|
833
|
+
listingExchangeFullName: meta.fullExchangeName,
|
|
812
834
|
marketState,
|
|
835
|
+
sessionConfidence: "derived",
|
|
813
836
|
dataSource: "yahoo",
|
|
814
837
|
...quoteSupplement,
|
|
815
838
|
...extHours,
|
|
@@ -925,14 +948,26 @@ export class YahooFinanceClient implements DataProvider {
|
|
|
925
948
|
}
|
|
926
949
|
|
|
927
950
|
/** Fetch price history with appropriate granularity for the given time range */
|
|
928
|
-
|
|
929
|
-
|
|
951
|
+
getChartResolutionSupport(): ChartResolutionSupport[] {
|
|
952
|
+
return YAHOO_RESOLUTION_SUPPORT;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
getChartResolutionCapabilities(): ManualChartResolution[] {
|
|
956
|
+
return YAHOO_RESOLUTION_SUPPORT.map((entry) => entry.resolution);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
private async loadPriceHistory(
|
|
960
|
+
ticker: string,
|
|
961
|
+
exchange: string,
|
|
962
|
+
range: string,
|
|
963
|
+
interval: ManualChartResolution,
|
|
964
|
+
): Promise<PricePoint[]> {
|
|
930
965
|
const symbolsToTry = this.getSymbolsToTry(ticker, exchange);
|
|
931
966
|
let lastError: any;
|
|
932
967
|
|
|
933
968
|
for (const symbol of symbolsToTry) {
|
|
934
969
|
try {
|
|
935
|
-
const { meta, history } = await this.fetchChart(symbol,
|
|
970
|
+
const { meta, history } = await this.fetchChart(symbol, range, interval);
|
|
936
971
|
|
|
937
972
|
// Normalize sub-unit currencies in price history
|
|
938
973
|
const { divisor } = normalizeSubUnitCurrency(meta.currency || "USD");
|
|
@@ -953,6 +988,22 @@ export class YahooFinanceClient implements DataProvider {
|
|
|
953
988
|
throw lastError || new Error(`No history for ${ticker}`);
|
|
954
989
|
}
|
|
955
990
|
|
|
991
|
+
async getPriceHistory(ticker: string, exchange = "", range: TimeRange, _context?: MarketDataRequestContext): Promise<PricePoint[]> {
|
|
992
|
+
const params = RANGE_PARAMS[range];
|
|
993
|
+
return this.loadPriceHistory(ticker, exchange, params.range, params.interval as ManualChartResolution);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async getPriceHistoryForResolution(
|
|
997
|
+
ticker: string,
|
|
998
|
+
exchange = "",
|
|
999
|
+
bufferRange: TimeRange,
|
|
1000
|
+
resolution: ManualChartResolution,
|
|
1001
|
+
_context?: MarketDataRequestContext,
|
|
1002
|
+
): Promise<PricePoint[]> {
|
|
1003
|
+
const params = RANGE_PARAMS[bufferRange];
|
|
1004
|
+
return this.loadPriceHistory(ticker, exchange, params.range, resolution);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
956
1007
|
// ── Options Chain ──────────────────────────────────────────────────
|
|
957
1008
|
|
|
958
1009
|
private mapContract(raw: Record<string, any>): OptionContract {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, useRef, type Dispatch } from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { AppProvider, PaneInstanceProvider, useAppDispatch, useAppSelector, usePaneTicker, type AppAction } from "./app-context";
|
|
5
|
+
import { cloneLayout, createDefaultConfig, type AppConfig } from "../types/config";
|
|
6
|
+
import { getCurrentThemeId } from "../theme/colors";
|
|
7
|
+
|
|
8
|
+
const TEST_PANE_ID = "ticker-detail:test";
|
|
9
|
+
|
|
10
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
11
|
+
let capturedDispatch: Dispatch<AppAction> | null = null;
|
|
12
|
+
|
|
13
|
+
function createTickerDetailConfig(symbol: string): AppConfig {
|
|
14
|
+
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
15
|
+
const layout = {
|
|
16
|
+
dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
|
|
17
|
+
instances: [{
|
|
18
|
+
instanceId: TEST_PANE_ID,
|
|
19
|
+
paneId: "ticker-detail",
|
|
20
|
+
binding: { kind: "fixed" as const, symbol },
|
|
21
|
+
}],
|
|
22
|
+
floating: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
...config,
|
|
27
|
+
layout,
|
|
28
|
+
layouts: [{ name: "Default", layout: cloneLayout(layout) }],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function DispatchCapture() {
|
|
33
|
+
capturedDispatch = useAppDispatch();
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function PaneTickerHarness() {
|
|
38
|
+
const renderCountRef = useRef(0);
|
|
39
|
+
renderCountRef.current += 1;
|
|
40
|
+
const { symbol } = usePaneTicker();
|
|
41
|
+
return <text>{`${symbol ?? "none"}:${renderCountRef.current}`}</text>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ThemeSelectorHarness() {
|
|
45
|
+
const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
|
|
46
|
+
return <text>{`${focusedPaneId ?? "none"}:${getCurrentThemeId()}`}</text>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("pane selectors", () => {
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
testSetup?.renderer.destroy();
|
|
52
|
+
testSetup = undefined;
|
|
53
|
+
capturedDispatch = null;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("does not rerender usePaneTicker consumers for unrelated app state updates", async () => {
|
|
57
|
+
testSetup = await testRender(
|
|
58
|
+
<AppProvider config={createTickerDetailConfig("AAPL")}>
|
|
59
|
+
<PaneInstanceProvider paneId={TEST_PANE_ID}>
|
|
60
|
+
<DispatchCapture />
|
|
61
|
+
<PaneTickerHarness />
|
|
62
|
+
</PaneInstanceProvider>
|
|
63
|
+
</AppProvider>,
|
|
64
|
+
{ width: 24, height: 4 },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
await testSetup.renderOnce();
|
|
68
|
+
expect(testSetup.captureCharFrame()).toContain("AAPL:1");
|
|
69
|
+
|
|
70
|
+
await act(() => {
|
|
71
|
+
capturedDispatch?.({ type: "SET_COMMAND_BAR", open: true, query: "ticker" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await testSetup.renderOnce();
|
|
75
|
+
await testSetup.renderOnce();
|
|
76
|
+
expect(testSetup.captureCharFrame()).toContain("AAPL:1");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("rerenders selector consumers when the theme changes", async () => {
|
|
80
|
+
testSetup = await testRender(
|
|
81
|
+
<AppProvider config={createTickerDetailConfig("AAPL")}>
|
|
82
|
+
<DispatchCapture />
|
|
83
|
+
<ThemeSelectorHarness />
|
|
84
|
+
</AppProvider>,
|
|
85
|
+
{ width: 32, height: 4 },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
await testSetup.renderOnce();
|
|
89
|
+
expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:amber`);
|
|
90
|
+
|
|
91
|
+
await act(() => {
|
|
92
|
+
capturedDispatch?.({ type: "SET_THEME", theme: "green" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await testSetup.renderOnce();
|
|
96
|
+
await testSetup.renderOnce();
|
|
97
|
+
expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -214,6 +214,24 @@ describe("broker account cache", () => {
|
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
+
describe("pane state updates", () => {
|
|
218
|
+
test("returns the existing state object when a pane patch is a no-op", () => {
|
|
219
|
+
const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
|
|
220
|
+
initial.paneState["portfolio-list:main"] = {
|
|
221
|
+
collectionId: "main",
|
|
222
|
+
cursorSymbol: "AAPL",
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const next = appReducer(initial, {
|
|
226
|
+
type: "UPDATE_PANE_STATE",
|
|
227
|
+
paneId: "portfolio-list:main",
|
|
228
|
+
patch: { cursorSymbol: "AAPL" },
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(next).toBe(initial);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
217
235
|
describe("quote merging", () => {
|
|
218
236
|
test("does not overwrite live broker quotes with cloud updates", () => {
|
|
219
237
|
const config = createDefaultConfig("/tmp/gloomberb-test");
|