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.
- package/package.json +1 -1
- package/src/components/chart/chart-renderer.ts +109 -32
- package/src/components/chart/chart-types.ts +18 -0
- package/src/components/chart/chart.test.ts +152 -5
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +45 -8
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +65 -2
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
- package/src/components/chart/stock-chart.tsx +36 -8
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +1 -1
- package/src/components/command-bar/command-bar.tsx +7 -1
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/view-model.test.ts +20 -0
- package/src/components/command-bar/view-model.ts +15 -3
- package/src/components/ticker-list-table.tsx +4 -15
- package/src/core/state/session-persistence.ts +25 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +192 -55
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/chat-controller.ts +8 -7
- package/src/plugins/builtin/chat.test.tsx +55 -0
- package/src/plugins/builtin/chat.tsx +20 -6
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +0 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
- package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
- package/src/plugins/builtin/sectors/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +26 -10
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/utils/api-client.test.ts +120 -0
- package/src/utils/api-client.ts +66 -19
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
package/src/utils/api-client.ts
CHANGED
|
@@ -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
|
-
&&
|
|
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 =
|
|
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
|
-
|
|
1092
|
+
const subscriptionsToSend = [...newSubscriptions, ...updatedSubscriptions];
|
|
1093
|
+
if (subscriptionsToSend.length > 0) {
|
|
1058
1094
|
cloudApiLog.info("register quote listeners", {
|
|
1059
|
-
count:
|
|
1060
|
-
symbols:
|
|
1095
|
+
count: subscriptionsToSend.length,
|
|
1096
|
+
symbols: subscriptionsToSend.map((target) => marketKey(target.symbol, target.exchange)),
|
|
1061
1097
|
});
|
|
1062
|
-
this.queueQuoteSubscribes(
|
|
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
|
+
});
|
package/src/utils/exchanges.ts
CHANGED
|
@@ -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;
|
package/src/utils/options.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
+
});
|