gloomberb 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/components/chart/chart-renderer.ts +109 -32
  3. package/src/components/chart/chart-types.ts +18 -0
  4. package/src/components/chart/chart.test.ts +152 -5
  5. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  6. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  7. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  8. package/src/components/chart/market-session.ts +187 -0
  9. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  10. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  11. package/src/components/chart/stock-chart.tsx +36 -8
  12. package/src/components/chart/time-axis-label.tsx +56 -2
  13. package/src/components/command-bar/command-bar.test.tsx +1 -1
  14. package/src/components/command-bar/command-bar.tsx +7 -1
  15. package/src/components/command-bar/list-model.ts +8 -4
  16. package/src/components/command-bar/view-model.test.ts +20 -0
  17. package/src/components/command-bar/view-model.ts +15 -3
  18. package/src/components/ticker-list-table.tsx +4 -15
  19. package/src/core/state/session-persistence.ts +25 -0
  20. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  21. package/src/market-data/coordinator.test.ts +122 -2
  22. package/src/market-data/coordinator.ts +192 -55
  23. package/src/market-data/hooks.test.tsx +31 -0
  24. package/src/market-data/hooks.tsx +1 -0
  25. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  26. package/src/plugins/builtin/chat-controller.ts +8 -7
  27. package/src/plugins/builtin/chat.test.tsx +55 -0
  28. package/src/plugins/builtin/chat.tsx +20 -6
  29. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  30. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  31. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  32. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  33. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  34. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  35. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  36. package/src/plugins/builtin/sectors/index.tsx +30 -9
  37. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  38. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  39. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  40. package/src/sources/gloomberb-cloud.test.ts +55 -2
  41. package/src/sources/gloomberb-cloud.ts +180 -29
  42. package/src/sources/provider-router.test.ts +567 -10
  43. package/src/sources/provider-router.ts +323 -93
  44. package/src/sources/yahoo-finance.test.ts +37 -0
  45. package/src/sources/yahoo-finance.ts +48 -2
  46. package/src/state/app-bootstrap.test.ts +50 -23
  47. package/src/state/app-bootstrap.ts +26 -10
  48. package/src/state/session-persistence.test.ts +5 -3
  49. package/src/state/use-inline-tickers.ts +3 -0
  50. package/src/state/use-quote-streaming.ts +11 -0
  51. package/src/types/data-provider.ts +5 -1
  52. package/src/types/financials.ts +1 -0
  53. package/src/utils/api-client.test.ts +120 -0
  54. package/src/utils/api-client.ts +66 -19
  55. package/src/utils/exchanges.test.ts +16 -0
  56. package/src/utils/exchanges.ts +33 -0
  57. package/src/utils/options.ts +1 -1
  58. package/src/utils/price-history.test.ts +28 -1
  59. package/src/utils/price-history.ts +16 -0
  60. package/src/utils/quote-freshness.test.ts +150 -0
  61. package/src/utils/quote-freshness.ts +42 -1
  62. package/src/utils/quote-resolution.ts +2 -0
  63. package/src/utils/quote-time.test.ts +34 -0
  64. package/src/utils/quote-time.ts +18 -5
  65. package/src/version.ts +1 -1
  66. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -27,12 +27,13 @@ import {
27
27
  type CloudMarketResponse,
28
28
  type CloudNewsPayload,
29
29
  type CloudNewsStoryItemPayload,
30
+ type CloudOptionsChainPayload,
30
31
  type CloudPricePointPayload,
31
32
  type CloudQuotePayload,
32
33
  } from "../utils/api-client";
33
34
  import type { NewsArticle, NewsQuery, NewsStoryItem } from "../types/news-source";
34
35
  import { normalizePriceValueByDivisor, resolveCurrencyUnit } from "../utils/currency-units";
35
- import { canonicalTickerKey, publicExchange } from "../utils/exchanges";
36
+ import { canonicalTickerKey, publicExchange, resolveExchangeTimeZone } from "../utils/exchanges";
36
37
  import { collectNewsDisplayTickers } from "../news/ticker-symbols";
37
38
  import { createProviderMiss } from "./provider-errors";
38
39
 
@@ -42,7 +43,6 @@ const CLOUD_RESOLUTION_SUPPORT = normalizeChartResolutionSupport([
42
43
  { resolution: "5m", maxRange: "1M" },
43
44
  { resolution: "15m", maxRange: "3M" },
44
45
  { resolution: "30m", maxRange: "6M" },
45
- { resolution: "45m", maxRange: "6M" },
46
46
  { resolution: "1h", maxRange: "1Y" },
47
47
  { resolution: "1d", maxRange: "ALL" },
48
48
  { resolution: "1wk", maxRange: "ALL" },
@@ -54,6 +54,7 @@ const CLOUD_PROVIDER_MISS_PATTERNS = [
54
54
  /figi.*missing or invalid/i,
55
55
  /error in the query/i,
56
56
  ];
57
+ type CloudProviderMeta = NonNullable<CloudMarketResponse<unknown>["providerMeta"]>;
57
58
 
58
59
  function isCloudProviderMiss(error: unknown): boolean {
59
60
  const message = error instanceof Error ? error.message : String(error);
@@ -71,10 +72,20 @@ async function withCloudFallback<T>(load: () => Promise<T>, message: string): Pr
71
72
  }
72
73
  }
73
74
 
74
- function mapQuote(quote: CloudQuotePayload): Quote {
75
+ function cloudInternalProviderId(providerMeta?: CloudProviderMeta): string | null {
76
+ const upstream = providerMeta?.provider ?? providerMeta?.upstream;
77
+ return upstream ? `${providerId}:${upstream}` : null;
78
+ }
79
+
80
+ function isStaleCloudResponse(response: CloudMarketResponse<unknown>): boolean {
81
+ return response.stale === true || response.providerMeta?.stale === true;
82
+ }
83
+
84
+ function mapQuote(quote: CloudQuotePayload, providerMeta?: CloudProviderMeta): Quote {
75
85
  const { currency, divisor } = resolveCurrencyUnit(quote.currency);
76
86
  const listingExchangeName = quote.listingExchangeName ?? quote.exchangeName;
77
87
  const listingExchangeFullName = quote.listingExchangeFullName ?? quote.fullExchangeName ?? listingExchangeName;
88
+ const internalProviderId = cloudInternalProviderId(providerMeta);
78
89
  return {
79
90
  ...quote,
80
91
  currency: currency || quote.currency,
@@ -98,12 +109,100 @@ function mapQuote(quote: CloudQuotePayload): Quote {
98
109
  exchangeName: listingExchangeName,
99
110
  fullExchangeName: listingExchangeFullName,
100
111
  providerId,
112
+ provenance: internalProviderId
113
+ ? {
114
+ ...quote.provenance,
115
+ price: {
116
+ providerId: internalProviderId,
117
+ dataSource: quote.dataSource,
118
+ },
119
+ session: {
120
+ providerId: internalProviderId,
121
+ dataSource: quote.dataSource,
122
+ },
123
+ routing: {
124
+ providerId,
125
+ dataSource: quote.dataSource,
126
+ },
127
+ fields: {
128
+ ...quote.provenance?.fields,
129
+ cloudProvider: { providerId: internalProviderId, dataSource: quote.dataSource },
130
+ ...(providerMeta?.fallbackReason
131
+ ? { fallbackReason: { providerId: providerMeta.fallbackReason } }
132
+ : {}),
133
+ },
134
+ }
135
+ : quote.provenance,
101
136
  };
102
137
  }
103
138
 
104
- function mapPricePoint(point: CloudPricePointPayload, divisor = 1): PricePoint {
139
+ const LOCAL_DATE_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?)?$/;
140
+ const EXPLICIT_TIME_ZONE_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/i;
141
+
142
+ function getZonedDateParts(date: Date, timeZone: string): Map<string, string> {
143
+ const formatter = new Intl.DateTimeFormat("en-US", {
144
+ timeZone,
145
+ hourCycle: "h23",
146
+ year: "numeric",
147
+ month: "2-digit",
148
+ day: "2-digit",
149
+ hour: "2-digit",
150
+ minute: "2-digit",
151
+ second: "2-digit",
152
+ });
153
+ const parts = new Map<string, string>();
154
+ for (const part of formatter.formatToParts(date)) {
155
+ if (part.type !== "literal") parts.set(part.type, part.value);
156
+ }
157
+ return parts;
158
+ }
159
+
160
+ function getTimeZoneOffsetMs(utcMs: number, timeZone: string): number {
161
+ const parts = getZonedDateParts(new Date(utcMs), timeZone);
162
+ const zonedAsUtcMs = Date.UTC(
163
+ Number(parts.get("year")),
164
+ Number(parts.get("month")) - 1,
165
+ Number(parts.get("day")),
166
+ Number(parts.get("hour")),
167
+ Number(parts.get("minute")),
168
+ Number(parts.get("second")),
169
+ );
170
+ return zonedAsUtcMs - utcMs;
171
+ }
172
+
173
+ function exchangeLocalDateTimeToUtc(match: RegExpMatchArray, timeZone: string): Date {
174
+ const year = Number(match[1]);
175
+ const month = Number(match[2]);
176
+ const day = Number(match[3]);
177
+ const hour = Number(match[4] ?? "0");
178
+ const minute = Number(match[5] ?? "0");
179
+ const second = Number(match[6] ?? "0");
180
+ const localAsUtcMs = Date.UTC(year, month - 1, day, hour, minute, second);
181
+ const firstOffset = getTimeZoneOffsetMs(localAsUtcMs, timeZone);
182
+ const firstUtcMs = localAsUtcMs - firstOffset;
183
+ const verifiedOffset = getTimeZoneOffsetMs(firstUtcMs, timeZone);
184
+ return new Date(localAsUtcMs - verifiedOffset);
185
+ }
186
+
187
+ function parseCloudPricePointDate(value: Date | string | number, exchange: string): Date {
188
+ if (value instanceof Date || typeof value === "number") return new Date(value);
189
+ if (EXPLICIT_TIME_ZONE_PATTERN.test(value)) return new Date(value);
190
+
191
+ const match = value.match(LOCAL_DATE_TIME_PATTERN);
192
+ if (!match) return new Date(value);
193
+
194
+ const hasTime = match[4] !== undefined;
195
+ if (!hasTime) {
196
+ return new Date(`${match[1]}-${match[2]}-${match[3]}T00:00:00Z`);
197
+ }
198
+
199
+ const timeZone = resolveExchangeTimeZone(exchange);
200
+ return timeZone ? exchangeLocalDateTimeToUtc(match, timeZone) : new Date(value);
201
+ }
202
+
203
+ function mapPricePoint(point: CloudPricePointPayload, divisor = 1, exchange = ""): PricePoint {
105
204
  return {
106
- date: new Date(point.date),
205
+ date: parseCloudPricePointDate(point.date, exchange),
107
206
  open: normalizePriceValueByDivisor(point.open, divisor),
108
207
  high: normalizePriceValueByDivisor(point.high, divisor),
109
208
  low: normalizePriceValueByDivisor(point.low, divisor),
@@ -112,8 +211,8 @@ function mapPricePoint(point: CloudPricePointPayload, divisor = 1): PricePoint {
112
211
  };
113
212
  }
114
213
 
115
- function mapCloudFinancials(financials: TickerFinancials): TickerFinancials {
116
- const quote = financials.quote ? mapQuote(financials.quote as CloudQuotePayload) : undefined;
214
+ function mapCloudFinancials(financials: TickerFinancials, providerMeta?: CloudProviderMeta): TickerFinancials {
215
+ const quote = financials.quote ? mapQuote(financials.quote as CloudQuotePayload, providerMeta) : undefined;
117
216
  const divisor = quote ? resolveCurrencyUnit(quote.currency).divisor : 1;
118
217
  return {
119
218
  quote,
@@ -130,6 +229,15 @@ function mapCloudFinancials(financials: TickerFinancials): TickerFinancials {
130
229
  };
131
230
  }
132
231
 
232
+ function mapOptionsChain(chain: CloudOptionsChainPayload): OptionsChain {
233
+ return {
234
+ underlyingSymbol: chain.underlyingSymbol,
235
+ expirationDates: chain.expirationDates ?? [],
236
+ calls: chain.calls ?? [],
237
+ puts: chain.puts ?? [],
238
+ };
239
+ }
240
+
133
241
  function mapBatchError<T>(
134
242
  item: CloudMarketBatchItem<T>,
135
243
  fallbackMessage: string,
@@ -273,10 +381,18 @@ function padTimePart(value: number): string {
273
381
  return String(value).padStart(2, "0");
274
382
  }
275
383
 
276
- function formatCloudDateTime(date: Date, includeTime: boolean): string {
277
- const year = date.getFullYear();
278
- const month = padTimePart(date.getMonth() + 1);
279
- const day = padTimePart(date.getDate());
384
+ function formatCloudDateTime(date: Date, includeTime: boolean, exchange = ""): string {
385
+ if (includeTime) {
386
+ const timeZone = resolveExchangeTimeZone(exchange);
387
+ if (timeZone) {
388
+ const parts = getZonedDateParts(date, timeZone);
389
+ return `${parts.get("year")}-${parts.get("month")}-${parts.get("day")} ${parts.get("hour")}:${parts.get("minute")}:${parts.get("second")}`;
390
+ }
391
+ }
392
+
393
+ const year = includeTime ? date.getFullYear() : date.getUTCFullYear();
394
+ const month = padTimePart((includeTime ? date.getMonth() : date.getUTCMonth()) + 1);
395
+ const day = padTimePart(includeTime ? date.getDate() : date.getUTCDate());
280
396
  if (!includeTime) {
281
397
  return `${year}-${month}-${day}`;
282
398
  }
@@ -396,7 +512,13 @@ export class GloomberbCloudProvider implements AssetDataProvider {
396
512
  await requireVerifiedSession();
397
513
  return withCloudFallback(async () => {
398
514
  const response = await apiClient.getCloudFinancials(ticker, exchange);
399
- return mapCloudFinancials(unwrapRequiredCloudResponse(response, `Cloud financials are unavailable for ${ticker}`));
515
+ if (isStaleCloudResponse(response)) {
516
+ throw createProviderMiss(`Cloud financials are stale for ${ticker}`);
517
+ }
518
+ return mapCloudFinancials(
519
+ unwrapRequiredCloudResponse(response, `Cloud financials are unavailable for ${ticker}`),
520
+ response.providerMeta,
521
+ );
400
522
  }, `Cloud financials are unavailable for ${ticker}`);
401
523
  }
402
524
 
@@ -437,10 +559,16 @@ export class GloomberbCloudProvider implements AssetDataProvider {
437
559
  async getQuote(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise<Quote> {
438
560
  await requireVerifiedSession();
439
561
  return withCloudFallback(
440
- async () => mapQuote(unwrapRequiredCloudResponse(
441
- await apiClient.getCloudQuote(ticker, exchange),
442
- `Cloud quotes are unavailable for ${ticker}`,
443
- )),
562
+ async () => {
563
+ const response = await apiClient.getCloudQuote(ticker, exchange);
564
+ if (isStaleCloudResponse(response)) {
565
+ throw createProviderMiss(`Cloud quotes are stale for ${ticker}`);
566
+ }
567
+ return mapQuote(
568
+ unwrapRequiredCloudResponse(response, `Cloud quotes are unavailable for ${ticker}`),
569
+ response.providerMeta,
570
+ );
571
+ },
444
572
  `Cloud quotes are unavailable for ${ticker}`,
445
573
  );
446
574
  }
@@ -550,9 +678,12 @@ export class GloomberbCloudProvider implements AssetDataProvider {
550
678
  () => apiClient.getCloudHistory(ticker, exchange, request),
551
679
  `Cloud chart data is unavailable for ${ticker}`,
552
680
  );
553
- const { divisor } = resolveCurrencyUnit(response.providerMeta?.currency);
681
+ if (isStaleCloudResponse(response)) {
682
+ throw createProviderMiss(`Cloud chart data is stale for ${ticker}`);
683
+ }
684
+ const { divisor } = resolveCurrencyUnit(response.currency ?? response.providerMeta?.currency);
554
685
  return unwrapRequiredCloudResponse(response, `Cloud chart data is unavailable for ${ticker}`)
555
- .map((point) => mapPricePoint(point, divisor));
686
+ .map((point) => mapPricePoint(point, divisor, exchange));
556
687
  }
557
688
 
558
689
  async getPriceHistoryForResolution(
@@ -570,14 +701,17 @@ export class GloomberbCloudProvider implements AssetDataProvider {
570
701
  const response = await withCloudFallback(
571
702
  () => apiClient.getCloudHistory(ticker, exchange, {
572
703
  interval,
573
- startDate: formatCloudDateTime(startDate, includeTime),
574
- endDate: formatCloudDateTime(endDate, includeTime),
704
+ startDate: formatCloudDateTime(startDate, includeTime, exchange),
705
+ endDate: formatCloudDateTime(endDate, includeTime, exchange),
575
706
  }),
576
707
  `Cloud chart data is unavailable for ${ticker}`,
577
708
  );
578
- const { divisor } = resolveCurrencyUnit(response.providerMeta?.currency);
709
+ if (isStaleCloudResponse(response)) {
710
+ throw createProviderMiss(`Cloud chart data is stale for ${ticker}`);
711
+ }
712
+ const { divisor } = resolveCurrencyUnit(response.currency ?? response.providerMeta?.currency);
579
713
  return unwrapRequiredCloudResponse(response, `Cloud chart data is unavailable for ${ticker}`)
580
- .map((point) => mapPricePoint(point, divisor));
714
+ .map((point) => mapPricePoint(point, divisor, exchange));
581
715
  }
582
716
 
583
717
  async getDetailedPriceHistory(
@@ -594,25 +728,38 @@ export class GloomberbCloudProvider implements AssetDataProvider {
594
728
  const response = await withCloudFallback(
595
729
  () => apiClient.getCloudHistory(ticker, exchange, {
596
730
  interval,
597
- startDate: formatCloudDateTime(startDate, includeTime),
598
- endDate: formatCloudDateTime(endDate, includeTime),
731
+ startDate: formatCloudDateTime(startDate, includeTime, exchange),
732
+ endDate: formatCloudDateTime(endDate, includeTime, exchange),
599
733
  }),
600
734
  `Cloud detailed chart history is unavailable for ${ticker}`,
601
735
  );
602
- const { divisor } = resolveCurrencyUnit(response.providerMeta?.currency);
736
+ if (isStaleCloudResponse(response)) {
737
+ throw createProviderMiss(`Cloud detailed chart history is stale for ${ticker}`);
738
+ }
739
+ const { divisor } = resolveCurrencyUnit(response.currency ?? response.providerMeta?.currency);
603
740
  return unwrapRequiredCloudResponse(response, `Cloud detailed chart history is unavailable for ${ticker}`)
604
- .map((point) => mapPricePoint(point, divisor));
741
+ .map((point) => mapPricePoint(point, divisor, exchange));
605
742
  }
606
743
 
607
- async getOptionsChain(_ticker: string, _exchange?: string, _expirationDate?: number, _context?: MarketDataRequestContext): Promise<OptionsChain> {
608
- throw createProviderMiss("Cloud options chains are not available");
744
+ async getOptionsChain(ticker: string, exchange?: string, expirationDate?: number, _context?: MarketDataRequestContext): Promise<OptionsChain> {
745
+ await requireVerifiedSession();
746
+ return withCloudFallback(async () => {
747
+ const response = await apiClient.getCloudOptionsChain(ticker, exchange, expirationDate);
748
+ const chain = unwrapRequiredCloudResponse(
749
+ response,
750
+ `Cloud options chains are unavailable for ${ticker}`,
751
+ );
752
+ return mapOptionsChain(chain);
753
+ }, `Cloud options chains are unavailable for ${ticker}`);
609
754
  }
610
755
 
611
756
  subscribeQuotes(
612
757
  targets: QuoteSubscriptionTarget[],
613
758
  onQuote: (target: QuoteSubscriptionTarget, quote: Quote) => void,
614
759
  ): () => void {
615
- void apiClient.ensureVerifiedSession().catch(() => {});
760
+ if (apiClient.getSessionToken()) {
761
+ void apiClient.ensureVerifiedSession().catch(() => {});
762
+ }
616
763
  const targetMap = new Map<string, QuoteSubscriptionTarget[]>();
617
764
  for (const target of targets) {
618
765
  const key = quoteTargetKey(target.symbol, target.exchange);
@@ -625,6 +772,10 @@ export class GloomberbCloudProvider implements AssetDataProvider {
625
772
  targets.map((target) => ({
626
773
  symbol: target.symbol,
627
774
  exchange: target.exchange,
775
+ surface: target.surface,
776
+ visible: target.visible,
777
+ selected: target.selected,
778
+ weight: target.weight,
628
779
  })),
629
780
  (target, quote) => {
630
781
  const key = quoteTargetKey(target.symbol, target.exchange);