gloomberb 0.7.3 → 0.7.5

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +59 -30
  3. package/src/components/chart/chart-renderer.ts +109 -32
  4. package/src/components/chart/chart-types.ts +18 -0
  5. package/src/components/chart/chart.test.ts +152 -5
  6. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  7. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  8. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  9. package/src/components/chart/market-session.ts +187 -0
  10. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  11. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  12. package/src/components/chart/stock-chart.tsx +36 -8
  13. package/src/components/chart/time-axis-label.tsx +56 -2
  14. package/src/components/command-bar/command-bar.test.tsx +1 -1
  15. package/src/components/command-bar/command-bar.tsx +26 -2
  16. package/src/components/command-bar/list-model.ts +8 -4
  17. package/src/components/command-bar/view-model.test.ts +20 -0
  18. package/src/components/command-bar/view-model.ts +15 -3
  19. package/src/components/layout/shell.test.tsx +26 -0
  20. package/src/components/layout/shell.tsx +69 -21
  21. package/src/components/ticker-list-table.tsx +4 -15
  22. package/src/core/state/session-persistence.ts +25 -0
  23. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  24. package/src/market-data/coordinator.test.ts +122 -2
  25. package/src/market-data/coordinator.ts +192 -55
  26. package/src/market-data/hooks.test.tsx +31 -0
  27. package/src/market-data/hooks.tsx +1 -0
  28. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  29. package/src/plugins/builtin/chat-controller.ts +8 -7
  30. package/src/plugins/builtin/chat.test.tsx +55 -0
  31. package/src/plugins/builtin/chat.tsx +20 -6
  32. package/src/plugins/builtin/holders/index.test.ts +30 -1
  33. package/src/plugins/builtin/holders/index.tsx +7 -3
  34. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  35. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  36. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  37. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  38. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  39. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  40. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  41. package/src/plugins/builtin/sectors/index.tsx +30 -9
  42. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  43. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  44. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  45. package/src/plugins/pane-manager.test.ts +31 -0
  46. package/src/plugins/pane-manager.ts +32 -6
  47. package/src/renderers/electrobun/bun/index.ts +83 -3
  48. package/src/renderers/electrobun/shared/protocol.ts +8 -1
  49. package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
  50. package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
  51. package/src/renderers/electrobun/view/main.tsx +38 -24
  52. package/src/renderers/electrobun/view/styles.css +48 -4
  53. package/src/sources/gloomberb-cloud.test.ts +55 -2
  54. package/src/sources/gloomberb-cloud.ts +180 -29
  55. package/src/sources/provider-router.test.ts +567 -10
  56. package/src/sources/provider-router.ts +323 -93
  57. package/src/sources/yahoo-finance.test.ts +37 -0
  58. package/src/sources/yahoo-finance.ts +48 -2
  59. package/src/state/app-bootstrap.test.ts +50 -23
  60. package/src/state/app-bootstrap.ts +26 -10
  61. package/src/state/session-persistence.test.ts +5 -3
  62. package/src/state/use-inline-tickers.ts +3 -0
  63. package/src/state/use-quote-streaming.ts +11 -0
  64. package/src/types/data-provider.ts +5 -1
  65. package/src/types/financials.ts +1 -0
  66. package/src/utils/api-client.test.ts +120 -0
  67. package/src/utils/api-client.ts +66 -19
  68. package/src/utils/exchanges.test.ts +16 -0
  69. package/src/utils/exchanges.ts +33 -0
  70. package/src/utils/options.ts +1 -1
  71. package/src/utils/price-history.test.ts +28 -1
  72. package/src/utils/price-history.ts +16 -0
  73. package/src/utils/quote-freshness.test.ts +150 -0
  74. package/src/utils/quote-freshness.ts +42 -1
  75. package/src/utils/quote-resolution.ts +2 -0
  76. package/src/utils/quote-time.test.ts +34 -0
  77. package/src/utils/quote-time.ts +18 -5
  78. package/src/utils/ticker-search.ts +72 -1
  79. package/src/version.ts +1 -1
  80. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -5,6 +5,7 @@ import type {
5
5
  Fundamentals,
6
6
  HolderData,
7
7
  HolderRecord,
8
+ OptionsChain,
8
9
  PricePoint,
9
10
  Quote,
10
11
  TickerFinancials,
@@ -100,6 +101,10 @@ export interface CloudQuotePayload extends Quote {
100
101
  dataSource: "live" | "delayed";
101
102
  }
102
103
 
104
+ export interface CloudOptionsChainPayload extends OptionsChain {
105
+ providerId: "gloomberb-cloud";
106
+ }
107
+
103
108
  export interface CloudCompanyProfile extends CompanyProfile {}
104
109
 
105
110
  export interface CloudFundamentals extends Fundamentals {}
@@ -317,10 +322,19 @@ export interface CloudMarketResponse<T> {
317
322
  reasonCode?: string;
318
323
  asOf?: string;
319
324
  staleAt?: string;
325
+ stale?: boolean;
326
+ currency?: string;
320
327
  providerMeta?: {
328
+ provider?: string;
321
329
  upstream?: string;
330
+ status?: CloudMarketStatus;
331
+ reasonCode?: string;
322
332
  normalizedSymbol?: string;
323
333
  normalizedExchange?: string;
334
+ stale?: boolean;
335
+ fallbackReason?: string;
336
+ requestedResolution?: string;
337
+ servedResolution?: string;
324
338
  latencyMs?: number;
325
339
  range?: string;
326
340
  granularity?: string;
@@ -356,6 +370,10 @@ export interface CloudVerificationResponse {
356
370
  export interface QuoteStreamTarget {
357
371
  symbol: string;
358
372
  exchange?: string;
373
+ surface?: "portfolio" | "watchlist" | "detail" | "monitor" | "inline" | "screener" | "unknown";
374
+ visible?: boolean;
375
+ selected?: boolean;
376
+ weight?: number;
359
377
  }
360
378
 
361
379
  function marketKey(symbol: string, exchange?: string): string {
@@ -602,18 +620,20 @@ class GloomApiClient {
602
620
  }
603
621
 
604
622
  private shouldKeepSocketOpen(): boolean {
623
+ if (this.quoteTargets.size > 0) return true;
605
624
  return !!this.getSocketAuthToken()
606
625
  && !!this.currentUser?.emailVerified
607
- && (this.channelListeners.size > 0 || this.quoteTargets.size > 0);
626
+ && this.channelListeners.size > 0;
608
627
  }
609
628
 
610
629
  private ensureSocket(): void {
611
630
  if (!this.shouldKeepSocketOpen() || this.ws || this.reconnectTimer) return;
612
631
 
613
632
  const socketToken = this.getSocketAuthToken();
614
- if (!socketToken) return;
615
633
  const usingWebSocketToken = !!this.websocketToken;
616
- const url = `${this.getWebSocketBaseUrl()}/cloud/ws?token=${encodeURIComponent(socketToken)}`;
634
+ const url = socketToken
635
+ ? `${this.getWebSocketBaseUrl()}/cloud/ws?token=${encodeURIComponent(socketToken)}`
636
+ : `${this.getWebSocketBaseUrl()}/cloud/ws`;
617
637
  cloudApiLog.info("open websocket", {
618
638
  hasToken: !!socketToken,
619
639
  tokenSource: usingWebSocketToken ? "websocket" : "session",
@@ -714,10 +734,7 @@ class GloomApiClient {
714
734
  if (this.quoteTargets.size > 0) {
715
735
  this.sendSocketMessage({
716
736
  type: "market.subscribe",
717
- symbols: [...this.quoteTargets.values()].map((target) => ({
718
- symbol: target.symbol,
719
- exchange: target.exchange ?? "",
720
- })),
737
+ symbols: [...this.quoteTargets.values()].map((target) => this.serializeQuoteStreamTarget(target)),
721
738
  });
722
739
  }
723
740
  }
@@ -761,23 +778,28 @@ class GloomApiClient {
761
778
  if (subscribes.length > 0) {
762
779
  this.sendSocketMessage({
763
780
  type: "market.subscribe",
764
- symbols: subscribes.map((target) => ({
765
- symbol: target.symbol,
766
- exchange: target.exchange ?? "",
767
- })),
781
+ symbols: subscribes.map((target) => this.serializeQuoteStreamTarget(target)),
768
782
  });
769
783
  }
770
784
  if (unsubscribes.length > 0) {
771
785
  this.sendSocketMessage({
772
786
  type: "market.unsubscribe",
773
- symbols: unsubscribes.map((target) => ({
774
- symbol: target.symbol,
775
- exchange: target.exchange ?? "",
776
- })),
787
+ symbols: unsubscribes.map((target) => this.serializeQuoteStreamTarget(target)),
777
788
  });
778
789
  }
779
790
  }
780
791
 
792
+ private serializeQuoteStreamTarget(target: QuoteStreamTarget): QuoteStreamTarget {
793
+ return {
794
+ symbol: target.symbol,
795
+ exchange: target.exchange ?? "",
796
+ ...(target.surface ? { surface: target.surface } : {}),
797
+ ...(target.visible ? { visible: true } : {}),
798
+ ...(target.selected ? { selected: true } : {}),
799
+ ...(Number.isFinite(target.weight) ? { weight: target.weight } : {}),
800
+ };
801
+ }
802
+
781
803
  private async handleSocketMessage(raw: string): Promise<void> {
782
804
  let parsed: any;
783
805
  try {
@@ -808,6 +830,9 @@ class GloomApiClient {
808
830
  if (this.currentUser) {
809
831
  this.currentUser = { ...this.currentUser, emailVerified: false };
810
832
  }
833
+ if (this.quoteTargets.size > 0) {
834
+ return;
835
+ }
811
836
  this.teardownSocket();
812
837
  return;
813
838
  }
@@ -1036,17 +1061,27 @@ class GloomApiClient {
1036
1061
  const normalized = {
1037
1062
  symbol: normalizeSymbol(target.symbol),
1038
1063
  exchange: canonicalExchange(target.exchange),
1064
+ surface: target.surface,
1065
+ visible: target.visible,
1066
+ selected: target.selected,
1067
+ weight: target.weight,
1039
1068
  } satisfies QuoteStreamTarget;
1040
1069
  return [marketKey(normalized.symbol, normalized.exchange), normalized] as const;
1041
1070
  }),
1042
1071
  ).values()];
1043
1072
 
1044
1073
  const newSubscriptions: QuoteStreamTarget[] = [];
1074
+ const updatedSubscriptions: QuoteStreamTarget[] = [];
1045
1075
  for (const target of uniqueTargets) {
1046
1076
  const key = marketKey(target.symbol, target.exchange);
1047
1077
  const listeners = this.quoteListeners.get(key) ?? new Set<QuoteListener>();
1048
1078
  if (listeners.size === 0) {
1049
1079
  newSubscriptions.push(target);
1080
+ } else {
1081
+ const existing = this.quoteTargets.get(key);
1082
+ if (JSON.stringify(this.serializeQuoteStreamTarget(existing ?? target)) !== JSON.stringify(this.serializeQuoteStreamTarget(target))) {
1083
+ updatedSubscriptions.push(target);
1084
+ }
1050
1085
  }
1051
1086
  listeners.add(onQuote);
1052
1087
  this.quoteListeners.set(key, listeners);
@@ -1054,12 +1089,13 @@ class GloomApiClient {
1054
1089
  }
1055
1090
 
1056
1091
  this.ensureSocket();
1057
- if (newSubscriptions.length > 0) {
1092
+ const subscriptionsToSend = [...newSubscriptions, ...updatedSubscriptions];
1093
+ if (subscriptionsToSend.length > 0) {
1058
1094
  cloudApiLog.info("register quote listeners", {
1059
- count: newSubscriptions.length,
1060
- symbols: newSubscriptions.map((target) => marketKey(target.symbol, target.exchange)),
1095
+ count: subscriptionsToSend.length,
1096
+ symbols: subscriptionsToSend.map((target) => marketKey(target.symbol, target.exchange)),
1061
1097
  });
1062
- this.queueQuoteSubscribes(newSubscriptions);
1098
+ this.queueQuoteSubscribes(subscriptionsToSend);
1063
1099
  }
1064
1100
 
1065
1101
  return () => {
@@ -1139,6 +1175,17 @@ class GloomApiClient {
1139
1175
  });
1140
1176
  }
1141
1177
 
1178
+ async getCloudOptionsChain(
1179
+ symbol: string,
1180
+ exchange?: string,
1181
+ expirationDate?: number,
1182
+ ): Promise<CloudMarketResponse<CloudOptionsChainPayload>> {
1183
+ const params = new URLSearchParams({ symbol });
1184
+ if (exchange) params.set("exchange", exchange);
1185
+ if (expirationDate != null) params.set("expirationDate", String(expirationDate));
1186
+ return this.request<CloudMarketResponse<CloudOptionsChainPayload>>(`/market/options?${params.toString()}`);
1187
+ }
1188
+
1142
1189
  async getCloudProfile(symbol: string, exchange?: string): Promise<CloudMarketResponse<CloudCompanyProfile>> {
1143
1190
  const params = new URLSearchParams({ symbol });
1144
1191
  if (exchange) params.set("exchange", exchange);
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { CANONICAL_EXCHANGE_ALIASES, EXCHANGE_TIME_ZONES } from "./exchanges";
3
+
4
+ describe("exchange metadata", () => {
5
+ test("has a valid timezone for every canonical exchange", () => {
6
+ const canonicalExchanges = [...new Set(Object.values(CANONICAL_EXCHANGE_ALIASES))].sort();
7
+ const missing = canonicalExchanges.filter((exchange) => !EXCHANGE_TIME_ZONES[exchange]);
8
+
9
+ expect(missing).toEqual([]);
10
+ for (const exchange of canonicalExchanges) {
11
+ expect(() => new Intl.DateTimeFormat("en-US", {
12
+ timeZone: EXCHANGE_TIME_ZONES[exchange],
13
+ })).not.toThrow();
14
+ }
15
+ });
16
+ });
@@ -31,6 +31,7 @@ export const CANONICAL_EXCHANGE_ALIASES: Record<string, string> = {
31
31
  TSX: "TSX",
32
32
  XTSE: "TSX",
33
33
  TSXV: "TSXV",
34
+ VAN: "TSXV",
34
35
  CN: "CSE",
35
36
  CNSX: "CSE",
36
37
  CSE: "CSE",
@@ -118,6 +119,7 @@ export const CANONICAL_EXCHANGE_ALIASES: Record<string, string> = {
118
119
  XJSE: "JSE",
119
120
  TLV: "TASE",
120
121
  XTAE: "TASE",
122
+ CCC: "CCC",
121
123
  };
122
124
 
123
125
  export const PUBLIC_EXCHANGE_ALIASES: Record<string, string> = {
@@ -128,6 +130,8 @@ export const PUBLIC_EXCHANGE_ALIASES: Record<string, string> = {
128
130
  HKEX: "XHKG",
129
131
  LSE: "XLON",
130
132
  TSX: "XTSE",
133
+ TSXV: "XTSX",
134
+ CSE: "XCNQ",
131
135
  ASX: "XASX",
132
136
  NZX: "XNZE",
133
137
  SWX: "XSWX",
@@ -172,6 +176,21 @@ export const EXCHANGE_TIME_ZONES: Record<string, string> = {
172
176
  SFB: "Europe/Stockholm",
173
177
  HKEX: "Asia/Hong_Kong",
174
178
  JPX: "Asia/Tokyo",
179
+ TSX: "America/Toronto",
180
+ TSXV: "America/Toronto",
181
+ CSE: "America/Toronto",
182
+ EPA: "Europe/Paris",
183
+ AMS: "Europe/Amsterdam",
184
+ BRU: "Europe/Brussels",
185
+ LIS: "Europe/Lisbon",
186
+ BIT: "Europe/Rome",
187
+ HEL: "Europe/Helsinki",
188
+ CPH: "Europe/Copenhagen",
189
+ OSL: "Europe/Oslo",
190
+ ICEX: "Atlantic/Reykjavik",
191
+ WSE: "Europe/Warsaw",
192
+ PSE: "Europe/Prague",
193
+ VIE: "Europe/Vienna",
175
194
  TPEX: "Asia/Taipei",
176
195
  TWSE: "Asia/Taipei",
177
196
  NSE: "Asia/Kolkata",
@@ -181,6 +200,15 @@ export const EXCHANGE_TIME_ZONES: Record<string, string> = {
181
200
  SGX: "Asia/Singapore",
182
201
  KRX: "Asia/Seoul",
183
202
  KOSDAQ: "Asia/Seoul",
203
+ NZX: "Pacific/Auckland",
204
+ SSE: "Asia/Shanghai",
205
+ SZSE: "Asia/Shanghai",
206
+ BMV: "America/Mexico_City",
207
+ B3: "America/Sao_Paulo",
208
+ BYMA: "America/Argentina/Buenos_Aires",
209
+ JSE: "Africa/Johannesburg",
210
+ TASE: "Asia/Jerusalem",
211
+ CCC: "UTC",
184
212
  };
185
213
 
186
214
  export function normalizeSymbol(value: string): string {
@@ -196,6 +224,11 @@ export function canonicalExchange(value?: string): string {
196
224
  return CANONICAL_EXCHANGE_ALIASES[normalized] ?? normalized;
197
225
  }
198
226
 
227
+ export function resolveExchangeTimeZone(value?: string): string | null {
228
+ const canonical = canonicalExchange(value);
229
+ return canonical ? EXCHANGE_TIME_ZONES[canonical] ?? null : null;
230
+ }
231
+
199
232
  export function publicExchange(value?: string): string {
200
233
  const canonical = canonicalExchange(value);
201
234
  return PUBLIC_EXCHANGE_ALIASES[canonical] ?? canonical;
@@ -23,7 +23,7 @@ export function parseOptionSymbol(symbol: string): { underlying: string; expTs:
23
23
  const year = 2000 + parseInt(yy!, 10);
24
24
  const month = parseInt(mm!, 10) - 1;
25
25
  const day = parseInt(dd!, 10);
26
- const expTs = Math.floor(new Date(year, month, day).getTime() / 1000);
26
+ const expTs = Math.floor(Date.UTC(year, month, day) / 1000);
27
27
  return { underlying: underlying!, expTs, strike, side: side as "C" | "P" };
28
28
  }
29
29
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { normalizePriceHistory } from "./price-history";
2
+ import { isPriceHistoryStaleForCurrentWindow, normalizePriceHistory } from "./price-history";
3
3
 
4
4
  describe("normalizePriceHistory", () => {
5
5
  test("drops poisoned cached history when every timestamp collapses to the same value", () => {
@@ -22,4 +22,31 @@ describe("normalizePriceHistory", () => {
22
22
 
23
23
  expect(history.map((point) => point.close)).toEqual([101, 102, 103]);
24
24
  });
25
+
26
+ test("drops zero-price bars instead of treating missing upstream prices as real data", () => {
27
+ const history = normalizePriceHistory([
28
+ { date: new Date("2026-05-13T13:30:00Z"), close: 64 },
29
+ { date: new Date("2026-05-13T13:45:00Z"), close: 0 },
30
+ { date: new Date("2026-05-13T14:00:00Z"), close: Number.NaN },
31
+ { date: new Date("2026-05-13T14:15:00Z"), close: 65 },
32
+ ]);
33
+
34
+ expect(history.map((point) => point.close)).toEqual([64, 65]);
35
+ });
36
+
37
+ test("detects intraday history that is old even when the cache record is fresh", () => {
38
+ expect(
39
+ isPriceHistoryStaleForCurrentWindow(
40
+ [{ date: new Date("2026-04-17T20:00:00Z"), close: 67 }],
41
+ Date.parse("2026-05-13T23:00:00Z"),
42
+ ),
43
+ ).toBe(true);
44
+
45
+ expect(
46
+ isPriceHistoryStaleForCurrentWindow(
47
+ [{ date: new Date("2026-05-13T22:45:00Z"), close: 67 }],
48
+ Date.parse("2026-05-13T23:00:00Z"),
49
+ ),
50
+ ).toBe(false);
51
+ });
25
52
  });
@@ -1,5 +1,7 @@
1
1
  import type { PricePoint, TickerFinancials } from "../types/financials";
2
2
 
3
+ const MAX_CURRENT_INTRADAY_HISTORY_LAG_MS = 18 * 60 * 60 * 1000;
4
+
3
5
  function getPointTimestamp(point: PricePoint): number {
4
6
  const value = point.date as Date | string | number | null | undefined;
5
7
  if (value instanceof Date) return value.getTime();
@@ -7,6 +9,10 @@ function getPointTimestamp(point: PricePoint): number {
7
9
  return new Date(value).getTime();
8
10
  }
9
11
 
12
+ function hasValidClose(point: PricePoint): boolean {
13
+ return Number.isFinite(point.close) && point.close > 0;
14
+ }
15
+
10
16
  function comparePricePointsByDate(left: PricePoint, right: PricePoint): number {
11
17
  const leftTime = getPointTimestamp(left);
12
18
  const rightTime = getPointTimestamp(right);
@@ -31,6 +37,7 @@ export function normalizePriceHistory(points: PricePoint[]): PricePoint[] {
31
37
  for (const point of points) {
32
38
  const time = getPointTimestamp(point);
33
39
  if (!Number.isFinite(time)) continue;
40
+ if (!hasValidClose(point)) continue;
34
41
 
35
42
  if (firstTimestamp === null) {
36
43
  firstTimestamp = time;
@@ -55,6 +62,15 @@ export function normalizePriceHistory(points: PricePoint[]): PricePoint[] {
55
62
  return validPoints.length === points.length ? points : validPoints;
56
63
  }
57
64
 
65
+ export function isPriceHistoryStaleForCurrentWindow(points: PricePoint[], now = Date.now()): boolean {
66
+ const normalized = normalizePriceHistory(points);
67
+ const latest = normalized.at(-1);
68
+ if (!latest) return false;
69
+
70
+ const latestTime = getPointTimestamp(latest);
71
+ return Number.isFinite(latestTime) && now - latestTime > MAX_CURRENT_INTRADAY_HISTORY_LAG_MS;
72
+ }
73
+
58
74
  export function normalizeTickerFinancialsPriceHistory(financials: TickerFinancials): TickerFinancials {
59
75
  const priceHistory = normalizePriceHistory(financials.priceHistory ?? []);
60
76
  return priceHistory === financials.priceHistory
@@ -0,0 +1,150 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Quote } from "../types/financials";
3
+ import { isQuoteStaleForCurrentSession } from "./quote-freshness";
4
+
5
+ function quote(overrides: Partial<Quote>): Quote {
6
+ return {
7
+ symbol: "2337",
8
+ price: 168,
9
+ currency: "TWD",
10
+ change: 0,
11
+ changePercent: 0,
12
+ lastUpdated: Date.parse("2026-05-08T06:00:00Z"),
13
+ listingExchangeName: "TWSE",
14
+ exchangeName: "TWSE",
15
+ marketState: "CLOSED",
16
+ dataSource: "delayed",
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe("quote freshness", () => {
22
+ test("treats multi-business-day-old non-US closed quotes as stale", () => {
23
+ expect(
24
+ isQuoteStaleForCurrentSession(
25
+ quote({}),
26
+ Date.parse("2026-05-13T21:00:00Z"),
27
+ ),
28
+ ).toBe(true);
29
+ });
30
+
31
+ test("treats stale Paris and Toronto quotes as refreshable", () => {
32
+ const now = Date.parse("2026-05-13T21:00:00Z");
33
+
34
+ expect(
35
+ isQuoteStaleForCurrentSession(
36
+ quote({
37
+ symbol: "ALRIB",
38
+ currency: "EUR",
39
+ lastUpdated: Date.parse("2026-05-08T15:30:00Z"),
40
+ listingExchangeName: "PAR",
41
+ exchangeName: "PAR",
42
+ }),
43
+ now,
44
+ ),
45
+ ).toBe(true);
46
+
47
+ expect(
48
+ isQuoteStaleForCurrentSession(
49
+ quote({
50
+ symbol: "HPS-A.TO",
51
+ currency: "CAD",
52
+ lastUpdated: Date.parse("2026-05-08T20:00:00Z"),
53
+ listingExchangeName: "TOR",
54
+ exchangeName: "TOR",
55
+ }),
56
+ now,
57
+ ),
58
+ ).toBe(true);
59
+ });
60
+
61
+ test("treats old crypto quotes as stale on the 24/7 venue", () => {
62
+ expect(
63
+ isQuoteStaleForCurrentSession(
64
+ quote({
65
+ symbol: "BTC-USD",
66
+ currency: "USD",
67
+ lastUpdated: Date.parse("2026-05-09T15:29:00Z"),
68
+ listingExchangeName: "CCC",
69
+ exchangeName: "CCC",
70
+ marketState: "REGULAR",
71
+ }),
72
+ Date.parse("2026-05-13T21:00:00Z"),
73
+ ),
74
+ ).toBe(true);
75
+ });
76
+
77
+ test("allows the previous local business day before the market reopens", () => {
78
+ expect(
79
+ isQuoteStaleForCurrentSession(
80
+ quote({
81
+ symbol: "1211",
82
+ currency: "HKD",
83
+ lastUpdated: Date.parse("2026-05-13T08:00:00Z"),
84
+ listingExchangeName: "SEHK",
85
+ exchangeName: "SEHK",
86
+ }),
87
+ Date.parse("2026-05-13T21:00:00Z"),
88
+ ),
89
+ ).toBe(false);
90
+ });
91
+
92
+ test("rejects previous-day weekday quotes that are older than a normal overnight close", () => {
93
+ expect(
94
+ isQuoteStaleForCurrentSession(
95
+ quote({
96
+ symbol: "285A.T",
97
+ currency: "JPY",
98
+ lastUpdated: Date.parse("2026-05-12T21:00:00Z"),
99
+ listingExchangeName: "JPX",
100
+ exchangeName: "JPX",
101
+ }),
102
+ Date.parse("2026-05-13T21:00:00Z"),
103
+ ),
104
+ ).toBe(true);
105
+
106
+ expect(
107
+ isQuoteStaleForCurrentSession(
108
+ quote({
109
+ symbol: "285A.T",
110
+ currency: "JPY",
111
+ lastUpdated: Date.parse("2026-05-13T06:24:00Z"),
112
+ listingExchangeName: "JPX",
113
+ exchangeName: "JPX",
114
+ }),
115
+ Date.parse("2026-05-13T21:00:00Z"),
116
+ ),
117
+ ).toBe(false);
118
+ });
119
+
120
+ test("allows Friday closes before Monday reopen", () => {
121
+ expect(
122
+ isQuoteStaleForCurrentSession(
123
+ quote({
124
+ symbol: "285A.T",
125
+ currency: "JPY",
126
+ lastUpdated: Date.parse("2026-05-08T06:24:00Z"),
127
+ listingExchangeName: "JPX",
128
+ exchangeName: "JPX",
129
+ }),
130
+ Date.parse("2026-05-10T21:00:00Z"),
131
+ ),
132
+ ).toBe(false);
133
+ });
134
+
135
+ test("treats prior-date regular-session quotes as stale immediately", () => {
136
+ expect(
137
+ isQuoteStaleForCurrentSession(
138
+ quote({
139
+ symbol: "HY9H",
140
+ currency: "EUR",
141
+ lastUpdated: Date.parse("2026-04-07T19:55:00Z"),
142
+ listingExchangeName: "FWB2",
143
+ exchangeName: "FWB2",
144
+ marketState: "REGULAR",
145
+ }),
146
+ Date.parse("2026-04-08T10:49:00Z"),
147
+ ),
148
+ ).toBe(true);
149
+ });
150
+ });
@@ -3,6 +3,8 @@ import { canonicalExchange, EXCHANGE_TIME_ZONES } from "./exchanges";
3
3
 
4
4
  const US_EXTENDED_HOURS_EXCHANGES = new Set(["NASDAQ", "NYSE", "AMEX", "ARCA", "BATS"]);
5
5
  const exchangeLocalDateFormatters = new Map<string, Intl.DateTimeFormat>();
6
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
7
+ const OVERNIGHT_CLOSE_MAX_AGE_MS = 20 * 60 * 60 * 1000;
6
8
  const usSessionFormatter = new Intl.DateTimeFormat("en-US", {
7
9
  timeZone: "America/New_York",
8
10
  weekday: "short",
@@ -43,6 +45,38 @@ function exchangeLocalDate(exchange: string, timestampMs: number): string | null
43
45
  return `${year}-${month}-${day}`;
44
46
  }
45
47
 
48
+ function isoLocalDateToUtcDay(date: string): number | null {
49
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
50
+ if (!match) return null;
51
+
52
+ const year = Number(match[1]);
53
+ const month = Number(match[2]);
54
+ const day = Number(match[3]);
55
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
56
+
57
+ return Math.floor(Date.UTC(year, month - 1, day) / MS_PER_DAY);
58
+ }
59
+
60
+ function localWeekdaysBetween(earlierDate: string, laterDate: string): number {
61
+ const earlierDay = isoLocalDateToUtcDay(earlierDate);
62
+ const laterDay = isoLocalDateToUtcDay(laterDate);
63
+ if (earlierDay == null || laterDay == null || laterDay <= earlierDay) return 0;
64
+
65
+ let weekdays = 0;
66
+ for (let day = earlierDay + 1; day <= laterDay; day += 1) {
67
+ const weekday = new Date(day * MS_PER_DAY).getUTCDay();
68
+ if (weekday !== 0 && weekday !== 6) {
69
+ weekdays += 1;
70
+ }
71
+ }
72
+ return weekdays;
73
+ }
74
+
75
+ function localWeekday(date: string): number | null {
76
+ const day = isoLocalDateToUtcDay(date);
77
+ return day == null ? null : new Date(day * MS_PER_DAY).getUTCDay();
78
+ }
79
+
46
80
  function usSessionState(timestampMs: number): UsSessionState {
47
81
  const parts = usSessionFormatter.formatToParts(new Date(timestampMs));
48
82
 
@@ -85,9 +119,16 @@ export function isQuoteStaleForCurrentSession(quote: Quote | null | undefined, n
85
119
 
86
120
  if (isUsExtendedHoursExchange(exchange)) {
87
121
  const session = usSessionState(now);
88
- return session === "PRE" || session === "REGULAR" || session === "POST";
122
+ if (session === "PRE" || session === "REGULAR" || session === "POST") {
123
+ return true;
124
+ }
89
125
  }
90
126
 
127
+ const weekdaysBehind = localWeekdaysBetween(quoteDate, currentDate);
128
+ if (weekdaysBehind > 1) return true;
129
+ if (weekdaysBehind === 1 && now - quote.lastUpdated > OVERNIGHT_CLOSE_MAX_AGE_MS) {
130
+ return localWeekday(currentDate) !== 1;
131
+ }
91
132
  return false;
92
133
  }
93
134
 
@@ -30,6 +30,7 @@ const PRICE_FIELD_KEYS = [
30
30
  "low",
31
31
  "mark",
32
32
  "lastUpdated",
33
+ "receivedAt",
33
34
  ] as const;
34
35
 
35
36
  const SESSION_FIELD_KEYS = [
@@ -516,6 +517,7 @@ export function resolveCanonicalQuote(
516
517
  volume: resolved.volume as Quote["volume"],
517
518
  name: resolved.name as Quote["name"],
518
519
  lastUpdated: Number(resolved.lastUpdated ?? priceProvider?.lastUpdated ?? sessionProvider?.lastUpdated ?? now),
520
+ receivedAt: resolved.receivedAt as Quote["receivedAt"],
519
521
  exchangeName: resolved.exchangeName as Quote["exchangeName"],
520
522
  fullExchangeName: resolved.fullExchangeName as Quote["fullExchangeName"],
521
523
  listingExchangeName: resolved.listingExchangeName as Quote["listingExchangeName"],
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatQuoteAge, formatQuoteAgeWithSource, getMostRecentQuoteUpdate } from "./quote-time";
3
+
4
+ describe("quote-time", () => {
5
+ test("formats sub-second quote age in milliseconds", () => {
6
+ const now = 1_700_000_030_000;
7
+
8
+ expect(formatQuoteAge(now - 999, now)).toBe("999ms");
9
+ expect(formatQuoteAge(now, now)).toBe("0ms");
10
+ expect(formatQuoteAgeWithSource({
11
+ lastUpdated: now - 100,
12
+ dataSource: "delayed",
13
+ }, now)).toBe("◷100ms");
14
+ });
15
+
16
+ test("uses stream receipt time for displayed quote age when available", () => {
17
+ const now = 1_700_000_030_000;
18
+ const quote = {
19
+ lastUpdated: 1_700_000_000_000,
20
+ receivedAt: 1_700_000_029_000,
21
+ };
22
+
23
+ expect(formatQuoteAgeWithSource(quote, now)).toBe("1s");
24
+ expect(getMostRecentQuoteUpdate([quote], now)).toBe(1_700_000_029_000);
25
+ });
26
+
27
+ test("keeps delayed source marker while using receipt time for age", () => {
28
+ expect(formatQuoteAgeWithSource({
29
+ lastUpdated: 1_700_000_000_000,
30
+ receivedAt: 1_700_000_029_000,
31
+ dataSource: "delayed",
32
+ }, 1_700_000_030_000)).toBe("◷1s");
33
+ });
34
+ });