gloomberb 0.3.2 → 0.3.7
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 +8 -8
- package/package.json +1 -1
- package/src/components/chart/chart-renderer.ts +7 -2
- package/src/components/chart/chart.test.ts +26 -0
- package/src/components/chart/stock-chart.tsx +36 -13
- package/src/plugins/ibkr/gateway-service.test.ts +30 -0
- package/src/plugins/ibkr/gateway-service.ts +38 -2
- package/src/plugins/prediction-markets/chart.test.tsx +139 -0
- package/src/plugins/prediction-markets/chart.tsx +65 -436
- package/src/plugins/prediction-markets/controller-data.ts +6 -2
- package/src/plugins/prediction-markets/detail/outcomes.tsx +2 -6
- package/src/plugins/prediction-markets/detail/pane.tsx +4 -25
- package/src/plugins/prediction-markets/detail/shared.tsx +0 -1
- package/src/plugins/prediction-markets/detail.test.tsx +4 -0
- package/src/plugins/prediction-markets/pane.test.tsx +41 -1
- package/src/plugins/prediction-markets/pane.tsx +1 -31
- package/src/sources/provider-router.test.ts +43 -0
- package/src/sources/provider-router.ts +8 -6
- package/src/utils/api-client.test.ts +86 -0
- package/src/utils/api-client.ts +30 -5
- package/src/utils/price-history.test.ts +25 -0
- package/src/utils/price-history.ts +29 -5
- package/src/version.ts +1 -1
|
@@ -90,7 +90,8 @@ describe("prediction markets pane interactions", () => {
|
|
|
90
90
|
await flushFrames(testSetup);
|
|
91
91
|
|
|
92
92
|
let frame = testSetup.captureCharFrame();
|
|
93
|
-
expect(frame).toContain("
|
|
93
|
+
expect(frame).toContain("All venues");
|
|
94
|
+
expect(frame).not.toContain("VOL = native venue units");
|
|
94
95
|
expect(frame).toContain("Will inflation fall?");
|
|
95
96
|
expect(frame).toContain("Kalshi");
|
|
96
97
|
|
|
@@ -117,6 +118,45 @@ describe("prediction markets pane interactions", () => {
|
|
|
117
118
|
expect(frame).toContain("Kalshi primary rule");
|
|
118
119
|
});
|
|
119
120
|
|
|
121
|
+
test("loads selected detail once instead of refetching in a render loop", async () => {
|
|
122
|
+
const { fetchUrls } = installPredictionMarketMocks();
|
|
123
|
+
|
|
124
|
+
testSetup = await testRender(<Harness />, { width: 120, height: 34 });
|
|
125
|
+
await flushFrames(testSetup);
|
|
126
|
+
|
|
127
|
+
const frame = testSetup.captureCharFrame();
|
|
128
|
+
const lines = frame.split("\n");
|
|
129
|
+
const kalshiRow = lines.findIndex((line) =>
|
|
130
|
+
line.includes("Will the Fed cut rates?"),
|
|
131
|
+
);
|
|
132
|
+
const kalshiCol = lines[kalshiRow]?.indexOf("Will the Fed cut rates?") ?? -1;
|
|
133
|
+
|
|
134
|
+
await act(async () => {
|
|
135
|
+
await testSetup!.mockMouse.click(kalshiCol + 1, kalshiRow);
|
|
136
|
+
await testSetup!.renderOnce();
|
|
137
|
+
});
|
|
138
|
+
await flushFrames(testSetup, 8);
|
|
139
|
+
|
|
140
|
+
const eventFetches = fetchUrls.filter((url) =>
|
|
141
|
+
url.includes("/trade-api/v2/events/FED-1"),
|
|
142
|
+
);
|
|
143
|
+
const orderbookFetches = fetchUrls.filter((url) =>
|
|
144
|
+
url.includes("/trade-api/v2/markets/KAL-1/orderbook"),
|
|
145
|
+
);
|
|
146
|
+
const tradeFetches = fetchUrls.filter((url) =>
|
|
147
|
+
url.includes("/trade-api/v2/markets/trades?ticker=KAL-1"),
|
|
148
|
+
);
|
|
149
|
+
const historyFetches = fetchUrls.filter((url) =>
|
|
150
|
+
url.includes("/trade-api/v2/series/FED/markets/KAL-1/candlesticks"),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(eventFetches).toHaveLength(2);
|
|
154
|
+
expect(orderbookFetches).toHaveLength(1);
|
|
155
|
+
expect(tradeFetches).toHaveLength(1);
|
|
156
|
+
expect(historyFetches).toHaveLength(1);
|
|
157
|
+
expect(testSetup.captureCharFrame()).not.toContain("Loading market detail...");
|
|
158
|
+
});
|
|
159
|
+
|
|
120
160
|
test("moves selection through the list with keyboard navigation", async () => {
|
|
121
161
|
installPredictionMarketMocks();
|
|
122
162
|
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { useRef } from "react";
|
|
2
2
|
import { TextAttributes, type BoxRenderable } from "@opentui/core";
|
|
3
|
-
import {
|
|
3
|
+
import { TabBar } from "../../components";
|
|
4
4
|
import { usePredictionMarketsController } from "./controller";
|
|
5
5
|
import { PredictionMarketDetailPane } from "./detail/pane";
|
|
6
6
|
import { BROWSE_TABS, VENUE_TABS } from "./navigation";
|
|
7
7
|
import { PredictionMarketsTable } from "./table";
|
|
8
|
-
import { formatPredictionTransportBadge } from "./status";
|
|
9
8
|
import { PREDICTION_CATEGORY_OPTIONS } from "./categories";
|
|
10
9
|
import type { PredictionBrowseTab, PredictionCategoryId } from "./types";
|
|
11
10
|
import type { PaneProps } from "../../types/plugin";
|
|
12
11
|
import { colors } from "../../theme/colors";
|
|
13
|
-
import { formatTimeAgo } from "../../utils/format";
|
|
14
12
|
|
|
15
13
|
function clamp(value: number, min: number, max: number): number {
|
|
16
14
|
return Math.min(Math.max(value, min), max);
|
|
@@ -22,12 +20,6 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
|
|
|
22
20
|
const splitDragRef = useRef<{ startX: number; startRatio: number } | null>(
|
|
23
21
|
null,
|
|
24
22
|
);
|
|
25
|
-
const statusBadge = formatPredictionTransportBadge(
|
|
26
|
-
controller.catalogLoadCount > 0 || controller.detailLoadCount > 0
|
|
27
|
-
? "loading"
|
|
28
|
-
: controller.transportState,
|
|
29
|
-
);
|
|
30
|
-
const showSummaryUnitsHint = controller.effectiveVenueScope === "all";
|
|
31
23
|
const minTableWidth = 44;
|
|
32
24
|
const minDetailWidth = 36;
|
|
33
25
|
const effectiveMinTableWidth = controller.selectedSummary
|
|
@@ -108,28 +100,6 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
|
|
|
108
100
|
backgroundColor={colors.panel}
|
|
109
101
|
onMouse={handlePaneMouse}
|
|
110
102
|
>
|
|
111
|
-
{!controller.paneSettings.hideHeader && (
|
|
112
|
-
<box flexDirection="row" height={1} paddingX={1}>
|
|
113
|
-
<text fg={colors.textBright} attributes={TextAttributes.BOLD}>
|
|
114
|
-
Prediction Markets
|
|
115
|
-
</text>
|
|
116
|
-
<box flexGrow={1} />
|
|
117
|
-
{showSummaryUnitsHint && (
|
|
118
|
-
<text fg={colors.textDim}>VOL = native venue units</text>
|
|
119
|
-
)}
|
|
120
|
-
<box width={1} />
|
|
121
|
-
{controller.searchLoading && <Spinner />}
|
|
122
|
-
{controller.searchLoading && <box width={1} />}
|
|
123
|
-
<text fg={statusBadge.color}>{statusBadge.label}</text>
|
|
124
|
-
<box width={1} />
|
|
125
|
-
<text fg={colors.textDim}>
|
|
126
|
-
{controller.lastRefreshAt
|
|
127
|
-
? `refresh ${formatTimeAgo(new Date(controller.lastRefreshAt))}`
|
|
128
|
-
: "waiting"}
|
|
129
|
-
</text>
|
|
130
|
-
</box>
|
|
131
|
-
)}
|
|
132
|
-
|
|
133
103
|
{!controller.paneSettings.hideTabs && (
|
|
134
104
|
<TabBar
|
|
135
105
|
tabs={VENUE_TABS.map((tab) => ({
|
|
@@ -1355,6 +1355,49 @@ describe("ProviderRouter", () => {
|
|
|
1355
1355
|
expect(history.map((point) => point.close)).toEqual([101, 102, 103]);
|
|
1356
1356
|
});
|
|
1357
1357
|
|
|
1358
|
+
test("ignores poisoned cached chart history and refetches clean data", async () => {
|
|
1359
|
+
const dbPath = createTempDbPath("poisoned-chart-cache");
|
|
1360
|
+
const persistence = new AppPersistence(dbPath);
|
|
1361
|
+
|
|
1362
|
+
persistence.resources.set(
|
|
1363
|
+
{
|
|
1364
|
+
namespace: "market",
|
|
1365
|
+
kind: "price-history",
|
|
1366
|
+
entityKey: "AAPL",
|
|
1367
|
+
variantKey: "exchange=NASDAQ;range=1Y",
|
|
1368
|
+
sourceKey: "provider:yahoo",
|
|
1369
|
+
},
|
|
1370
|
+
[
|
|
1371
|
+
{ date: null as any, close: 101 },
|
|
1372
|
+
{ date: null as any, close: 102 },
|
|
1373
|
+
],
|
|
1374
|
+
{
|
|
1375
|
+
cachePolicy: { staleMs: 60_000, expireMs: 60_000 },
|
|
1376
|
+
},
|
|
1377
|
+
);
|
|
1378
|
+
|
|
1379
|
+
let providerCalls = 0;
|
|
1380
|
+
const router = new ProviderRouter({
|
|
1381
|
+
...fallbackProvider,
|
|
1382
|
+
id: "yahoo",
|
|
1383
|
+
name: "Yahoo",
|
|
1384
|
+
async getPriceHistory() {
|
|
1385
|
+
providerCalls += 1;
|
|
1386
|
+
return [
|
|
1387
|
+
{ date: new Date("2026-03-27T00:00:00Z"), close: 201 },
|
|
1388
|
+
{ date: new Date("2026-03-28T00:00:00Z"), close: 202 },
|
|
1389
|
+
];
|
|
1390
|
+
},
|
|
1391
|
+
}, [], persistence.resources);
|
|
1392
|
+
|
|
1393
|
+
const history = await router.getPriceHistory("AAPL", "NASDAQ", "1Y");
|
|
1394
|
+
|
|
1395
|
+
expect(providerCalls).toBe(1);
|
|
1396
|
+
expect(history.map((point) => point.close)).toEqual([201, 202]);
|
|
1397
|
+
|
|
1398
|
+
persistence.close();
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1358
1401
|
test("bypasses cached financials on explicit refresh requests", async () => {
|
|
1359
1402
|
const dbPath = createTempDbPath("forced-financial-refresh");
|
|
1360
1403
|
const persistence = new AppPersistence(dbPath);
|
|
@@ -483,9 +483,10 @@ export class ProviderRouter implements DataProvider {
|
|
|
483
483
|
...this.getProviderSourceKeys(),
|
|
484
484
|
];
|
|
485
485
|
const cached = this.selectCachedArrayResource<PricePoint>("price-history", entityKey, variantKeys, sourceKeys, false);
|
|
486
|
+
const cachedValue = cached ? normalizePriceHistory(cached.value) : [];
|
|
486
487
|
const forceRefresh = context?.cacheMode === "refresh";
|
|
487
|
-
if (
|
|
488
|
-
return
|
|
488
|
+
if (cachedValue.length > 0 && !forceRefresh && cached && !cached.stale) {
|
|
489
|
+
return cachedValue;
|
|
489
490
|
}
|
|
490
491
|
|
|
491
492
|
const brokerHistory = await withBrokerTimeout(this.fetchBrokerPriceHistory(ticker, exchange, range, context));
|
|
@@ -495,7 +496,7 @@ export class ProviderRouter implements DataProvider {
|
|
|
495
496
|
if (providerHistory && providerHistory.value.length > 0) {
|
|
496
497
|
return providerHistory.value;
|
|
497
498
|
}
|
|
498
|
-
if (
|
|
499
|
+
if (cachedValue.length > 0) return cachedValue;
|
|
499
500
|
if (!providerHistory) {
|
|
500
501
|
throw new Error(`No history provider available for ${ticker}`);
|
|
501
502
|
}
|
|
@@ -520,9 +521,10 @@ export class ProviderRouter implements DataProvider {
|
|
|
520
521
|
...this.getProviderSourceKeys(),
|
|
521
522
|
];
|
|
522
523
|
const cached = this.selectCachedArrayResource<PricePoint>("detailed-price-history", entityKey, variantKeys, sourceKeys, false);
|
|
524
|
+
const cachedValue = cached ? normalizePriceHistory(cached.value) : [];
|
|
523
525
|
const forceRefresh = context?.cacheMode === "refresh";
|
|
524
|
-
if (
|
|
525
|
-
return
|
|
526
|
+
if (cachedValue.length > 0 && !forceRefresh && cached && !cached.stale) {
|
|
527
|
+
return cachedValue;
|
|
526
528
|
}
|
|
527
529
|
|
|
528
530
|
const brokerResult = await withBrokerTimeout(this.fetchBrokerDetailedPriceHistory(ticker, exchange, startDate, endDate, barSize, context));
|
|
@@ -532,7 +534,7 @@ export class ProviderRouter implements DataProvider {
|
|
|
532
534
|
if (providerResult && providerResult.value.length > 0) {
|
|
533
535
|
return providerResult.value;
|
|
534
536
|
}
|
|
535
|
-
return
|
|
537
|
+
return cachedValue.length > 0 ? cachedValue : (providerResult?.value ?? []);
|
|
536
538
|
}
|
|
537
539
|
|
|
538
540
|
async getOptionsChain(ticker: string, exchange?: string, expirationDate?: number, context?: MarketDataRequestContext): Promise<OptionsChain> {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AuthUser } from "./api-client";
|
|
3
|
+
import { apiClient } from "./api-client";
|
|
4
|
+
|
|
5
|
+
const originalFetch = globalThis.fetch;
|
|
6
|
+
|
|
7
|
+
const verifiedUser: AuthUser = {
|
|
8
|
+
id: "user-1",
|
|
9
|
+
name: "Test User",
|
|
10
|
+
email: "test@example.com",
|
|
11
|
+
username: "test",
|
|
12
|
+
emailVerified: true,
|
|
13
|
+
image: null,
|
|
14
|
+
createdAt: "2026-03-30T00:00:00.000Z",
|
|
15
|
+
updatedAt: "2026-03-30T00:00:00.000Z",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function createResponse(body: unknown, options: { status?: number; cookies?: string[] } = {}): Response {
|
|
19
|
+
const headers = {
|
|
20
|
+
getSetCookie: () => options.cookies ?? [],
|
|
21
|
+
get: (name: string) => {
|
|
22
|
+
if (name.toLowerCase() !== "set-cookie") return null;
|
|
23
|
+
return options.cookies?.[0] ?? null;
|
|
24
|
+
},
|
|
25
|
+
} as Headers;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
ok: (options.status ?? 200) >= 200 && (options.status ?? 200) < 300,
|
|
29
|
+
status: options.status ?? 200,
|
|
30
|
+
headers,
|
|
31
|
+
text: async () => JSON.stringify(body),
|
|
32
|
+
} as Response;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
globalThis.fetch = originalFetch;
|
|
37
|
+
apiClient.setSessionToken(null);
|
|
38
|
+
apiClient.setWebSocketToken(null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("apiClient auth cookies", () => {
|
|
42
|
+
test("captures secure session cookies after login and reuses them on session refresh", async () => {
|
|
43
|
+
const seenCookies: Array<string | null> = [];
|
|
44
|
+
|
|
45
|
+
globalThis.fetch = (async (_input: Request | string | URL, init?: RequestInit) => {
|
|
46
|
+
const headers = new Headers(init?.headers);
|
|
47
|
+
seenCookies.push(headers.get("Cookie"));
|
|
48
|
+
|
|
49
|
+
if (seenCookies.length === 1) {
|
|
50
|
+
return createResponse(
|
|
51
|
+
{ token: "ws-token", user: verifiedUser },
|
|
52
|
+
{ cookies: ["__Secure-gloomberb.session_token=signed-token.value; Path=/; HttpOnly; Secure; SameSite=Lax"] },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return createResponse({ user: verifiedUser });
|
|
57
|
+
}) as typeof fetch;
|
|
58
|
+
|
|
59
|
+
await apiClient.signIn("test@example.com", "password");
|
|
60
|
+
await apiClient.getSession();
|
|
61
|
+
|
|
62
|
+
expect(apiClient.getSessionToken()).toBe("signed-token.value");
|
|
63
|
+
expect(apiClient.getWebSocketToken()).toBe("ws-token");
|
|
64
|
+
expect(seenCookies).toEqual([
|
|
65
|
+
null,
|
|
66
|
+
"__Secure-gloomberb.session_token=signed-token.value",
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("replays both supported cookie names when restoring a saved session token", async () => {
|
|
71
|
+
const seenCookies: Array<string | null> = [];
|
|
72
|
+
apiClient.setSessionToken("persisted-token.value");
|
|
73
|
+
|
|
74
|
+
globalThis.fetch = (async (_input: Request | string | URL, init?: RequestInit) => {
|
|
75
|
+
const headers = new Headers(init?.headers);
|
|
76
|
+
seenCookies.push(headers.get("Cookie"));
|
|
77
|
+
return createResponse({ user: verifiedUser });
|
|
78
|
+
}) as typeof fetch;
|
|
79
|
+
|
|
80
|
+
await apiClient.getSession();
|
|
81
|
+
|
|
82
|
+
expect(seenCookies).toEqual([
|
|
83
|
+
"__Secure-gloomberb.session_token=persisted-token.value; gloomberb.session_token=persisted-token.value",
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/utils/api-client.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { InstrumentSearchResult } from "../types/instrument";
|
|
|
3
3
|
import { debugLog } from "./debug-log";
|
|
4
4
|
|
|
5
5
|
const DEFAULT_API_URL = "https://api.gloom.sh";
|
|
6
|
+
const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
|
|
6
7
|
const cloudApiLog = debugLog.createLogger("cloud-api");
|
|
7
8
|
|
|
8
9
|
export interface ChatMessage {
|
|
@@ -107,6 +108,7 @@ function marketKey(symbol: string, exchange?: string): string {
|
|
|
107
108
|
|
|
108
109
|
type ChannelListener = (message: ChatMessage) => void;
|
|
109
110
|
type QuoteListener = (target: QuoteStreamTarget, quote: CloudQuotePayload) => void;
|
|
111
|
+
type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number];
|
|
110
112
|
|
|
111
113
|
class ApiRequestError extends Error {
|
|
112
114
|
constructor(message: string, readonly status?: number) {
|
|
@@ -117,6 +119,7 @@ class ApiRequestError extends Error {
|
|
|
117
119
|
|
|
118
120
|
class GloomApiClient {
|
|
119
121
|
private sessionToken: string | null = null;
|
|
122
|
+
private sessionCookieName: SessionCookieName | null = null;
|
|
120
123
|
private websocketToken: string | null = null;
|
|
121
124
|
private currentUser: AuthUser | null = null;
|
|
122
125
|
private readonly baseUrl: string;
|
|
@@ -142,6 +145,9 @@ class GloomApiClient {
|
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
setSessionToken(token: string | null): void {
|
|
148
|
+
if (this.sessionToken !== token) {
|
|
149
|
+
this.sessionCookieName = null;
|
|
150
|
+
}
|
|
145
151
|
this.sessionToken = token;
|
|
146
152
|
if (!token) {
|
|
147
153
|
this.websocketToken = null;
|
|
@@ -195,22 +201,41 @@ class GloomApiClient {
|
|
|
195
201
|
|
|
196
202
|
private extractSessionCookie(res: Response): void {
|
|
197
203
|
const setCookie = res.headers.getSetCookie?.() ?? [];
|
|
204
|
+
const fallbackHeader = res.headers.get("set-cookie");
|
|
205
|
+
if (fallbackHeader) {
|
|
206
|
+
setCookie.push(fallbackHeader);
|
|
207
|
+
}
|
|
198
208
|
for (const cookie of setCookie) {
|
|
199
|
-
const
|
|
200
|
-
|
|
209
|
+
for (const cookieName of SESSION_COOKIE_NAMES) {
|
|
210
|
+
const escapedCookieName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
211
|
+
const match = cookie.match(new RegExp(`${escapedCookieName}=([^;]+)`));
|
|
212
|
+
if (!match) continue;
|
|
201
213
|
this.sessionToken = match[1] ?? null;
|
|
214
|
+
this.sessionCookieName = cookieName;
|
|
215
|
+
return;
|
|
202
216
|
}
|
|
203
217
|
}
|
|
204
218
|
}
|
|
205
219
|
|
|
220
|
+
private buildSessionCookieHeader(): string | null {
|
|
221
|
+
if (!this.sessionToken) return null;
|
|
222
|
+
const cookieNames = this.sessionCookieName ? [this.sessionCookieName] : SESSION_COOKIE_NAMES;
|
|
223
|
+
return cookieNames.map((cookieName) => `${cookieName}=${this.sessionToken}`).join("; ");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private setSessionCookieHeader(headers: Headers): void {
|
|
227
|
+
const cookieHeader = this.buildSessionCookieHeader();
|
|
228
|
+
if (cookieHeader) {
|
|
229
|
+
headers.set("Cookie", cookieHeader);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
206
233
|
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
|
207
234
|
const headers = new Headers(options?.headers);
|
|
208
235
|
if (!headers.has("Content-Type") && options?.method && options.method !== "GET") {
|
|
209
236
|
headers.set("Content-Type", "application/json");
|
|
210
237
|
}
|
|
211
|
-
|
|
212
|
-
headers.set("Cookie", `gloomberb.session_token=${this.sessionToken}`);
|
|
213
|
-
}
|
|
238
|
+
this.setSessionCookieHeader(headers);
|
|
214
239
|
headers.set("Origin", this.baseUrl);
|
|
215
240
|
|
|
216
241
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { normalizePriceHistory } from "./price-history";
|
|
3
|
+
|
|
4
|
+
describe("normalizePriceHistory", () => {
|
|
5
|
+
test("drops poisoned cached history when every timestamp collapses to the same value", () => {
|
|
6
|
+
const history = normalizePriceHistory([
|
|
7
|
+
{ date: null as any, close: 101 },
|
|
8
|
+
{ date: null as any, close: 102 },
|
|
9
|
+
{ date: null as any, close: 103 },
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
expect(history).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("filters invalid dates and keeps the remaining points in chronological order", () => {
|
|
16
|
+
const history = normalizePriceHistory([
|
|
17
|
+
{ date: new Date("2026-03-29T00:00:00Z"), close: 103 },
|
|
18
|
+
{ date: null as any, close: 999 },
|
|
19
|
+
{ date: new Date("2026-03-27T00:00:00Z"), close: 101 },
|
|
20
|
+
{ date: new Date("2026-03-28T00:00:00Z"), close: 102 },
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
expect(history.map((point) => point.close)).toEqual([101, 102, 103]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { PricePoint, TickerFinancials } from "../types/financials";
|
|
2
2
|
|
|
3
3
|
function getPointTimestamp(point: PricePoint): number {
|
|
4
|
-
|
|
4
|
+
const value = point.date as Date | string | number | null | undefined;
|
|
5
|
+
if (value instanceof Date) return value.getTime();
|
|
6
|
+
if (value == null) return Number.NaN;
|
|
7
|
+
return new Date(value).getTime();
|
|
5
8
|
}
|
|
6
9
|
|
|
7
10
|
function comparePricePointsByDate(left: PricePoint, right: PricePoint): number {
|
|
@@ -17,18 +20,39 @@ function comparePricePointsByDate(left: PricePoint, right: PricePoint): number {
|
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export function normalizePriceHistory(points: PricePoint[]): PricePoint[] {
|
|
20
|
-
if (points.length
|
|
23
|
+
if (points.length === 0) return points;
|
|
21
24
|
|
|
25
|
+
const validPoints: PricePoint[] = [];
|
|
26
|
+
let sawDistinctTimestamp = false;
|
|
27
|
+
let firstTimestamp: number | null = null;
|
|
22
28
|
let previousTime = Number.NEGATIVE_INFINITY;
|
|
29
|
+
let requiresSort = false;
|
|
30
|
+
|
|
23
31
|
for (const point of points) {
|
|
24
32
|
const time = getPointTimestamp(point);
|
|
25
|
-
if (!Number.isFinite(time)
|
|
26
|
-
|
|
33
|
+
if (!Number.isFinite(time)) continue;
|
|
34
|
+
|
|
35
|
+
if (firstTimestamp === null) {
|
|
36
|
+
firstTimestamp = time;
|
|
37
|
+
} else if (time !== firstTimestamp) {
|
|
38
|
+
sawDistinctTimestamp = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (time < previousTime) {
|
|
42
|
+
requiresSort = true;
|
|
27
43
|
}
|
|
28
44
|
previousTime = time;
|
|
45
|
+
validPoints.push(point);
|
|
29
46
|
}
|
|
30
47
|
|
|
31
|
-
return
|
|
48
|
+
if (validPoints.length === 0) return [];
|
|
49
|
+
if (validPoints.length === 1) return validPoints;
|
|
50
|
+
if (!sawDistinctTimestamp) return [];
|
|
51
|
+
|
|
52
|
+
if (requiresSort) {
|
|
53
|
+
return [...validPoints].sort(comparePricePointsByDate);
|
|
54
|
+
}
|
|
55
|
+
return validPoints.length === points.length ? points : validPoints;
|
|
32
56
|
}
|
|
33
57
|
|
|
34
58
|
export function normalizeTickerFinancialsPriceHistory(financials: TickerFinancials): TickerFinancials {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.3.
|
|
1
|
+
export const VERSION = "0.3.4";
|