import { useRegularMarketSession } from "../../test-support/market-session"; import { afterEach, describe, expect, test } from "bun:test"; import { AppPersistence } from "../../data/app-persistence"; import type { DataProvider } from "../../types/data-provider"; import { AssetDataRouter } from "./index"; import { cleanupProviderRouterTestFiles, createTempDbPath, fallbackProvider, makeFinancials, makeQuote, } from "./test-support"; const originalConsoleError = console.error; const originalDateNow = Date.now; useRegularMarketSession(); afterEach(() => { console.error = originalConsoleError; Date.now = originalDateNow; cleanupProviderRouterTestFiles(); }); describe("AssetDataRouter chart history", () => { test("retires persisted ALL bars with unverified cadence without losing finite windows or corrected cache reuse", async () => { const path = createTempDbPath("all-cadence-cache"); let persistence = new AppPersistence(path); const policy = { staleMs: 60_000, expireMs: 60_000 }; const wrong = [{ date: new Date("2026-08-10"), close: 0.000004 }]; const corrected = [{ date: new Date("2026-08-01"), close: 0.000006 }]; const sourceKey = "provider:gloomberb-cloud"; for (const symbol of ["SHIB-USD", "OFFLINE-USD"]) { persistence.resources.set({ namespace: "market", kind: "price-history", entityKey: symbol, variantKey: "exchange=CCC;range=ALL;resolution=1mo;version=5;calendar=1", sourceKey }, wrong, { cachePolicy: policy }); } persistence.resources.set({ namespace: "market", kind: "price-history", entityKey: "FINITE-USD", variantKey: "exchange=CCC;range=5Y;resolution=1mo;version=5;calendar=1", sourceKey }, corrected, { cachePolicy: policy }); persistence.close(); persistence = new AppPersistence(path); let calls = 0; const provider: DataProvider = { ...fallbackProvider, id: "gloomberb-cloud", name: "Cloud", async getPriceHistoryForResolution(symbol) { calls++; if (symbol !== "SHIB-USD") throw new Error("Provider temporarily unavailable"); return corrected; } }; try { let router = new AssetDataRouter(provider, [], persistence.resources); expect((await router.getPriceHistoryForResolution("SHIB-USD", "CCC", "ALL", "1mo")).map((p) => p.close)).toEqual([0.000006]); expect(calls).toBe(1); // A new app instance must read the corrected record, including as a // wider source buffer for a shorter window, without refetching. persistence.close(); persistence = new AppPersistence(path); router = new AssetDataRouter(provider, [], persistence.resources); expect((await router.getPriceHistoryForResolution("SHIB-USD", "CCC", "ALL", "1mo")).map((p) => p.close)).toEqual([0.000006]); expect((await router.getPriceHistoryForResolution("SHIB-USD", "CCC", "5Y", "1mo")).map((p) => p.close)).toEqual([0.000006]); expect((await router.getPriceHistoryForResolution("FINITE-USD", "CCC", "5Y", "1mo")).map((p) => p.close)).toEqual([0.000006]); expect(calls).toBe(1); await expect(router.getPriceHistoryForResolution("OFFLINE-USD", "CCC", "ALL", "1mo")).rejects.toThrow("No resolution-aware history provider"); expect(calls).toBe(2); } finally { persistence.close(); } }); test("uses requested bar cadence for fetched and cached charts and infers generic daily data", async () => { const originalNow = Date.now; Date.now = () => Date.parse("2026-09-10T19:39:09Z"); const persistence = new AppPersistence(createTempDbPath("bar-cadence-freshness")); const intraday = ["2026-09-10T18:45:00Z", "2026-09-10T19:00:00Z"].map((date) => ({ date: new Date(date), close: 709 })); const daily = ["2026-09-04T00:00:00Z", "2026-09-08T00:00:00Z", "2026-09-09T00:00:00Z"].map((date) => ({ date: new Date(date), close: 716.31 })); const weekly = [{ date: new Date("2026-09-07T00:00:00Z"), close: 716.31 }]; const calls = { generic: 0, resolution: 0, detailed: 0 }; const router = new AssetDataRouter({ ...fallbackProvider, async getPriceHistory() { calls.generic++; return daily; }, async getPriceHistoryForResolution() { calls.resolution++; return intraday; }, async getDetailedPriceHistory(_ticker, _exchange, _start, _end, barSize) { calls.detailed++; return barSize === "1wk" ? weekly : intraday; }, }, [], persistence.resources); const start = new Date("2026-08-10T19:39:09Z"); const end = new Date(Date.now()); try { for (let attempt = 0; attempt < 2; attempt++) { // Persistence serializes Date labels; values and timestamps must survive. expect(JSON.stringify(await router.getPriceHistory("QQQ", "NASDAQ", "1M"))).toBe(JSON.stringify(daily)); expect(JSON.stringify(await router.getPriceHistoryForResolution("QQQ", "NASDAQ", "1M", "15m"))).toBe(JSON.stringify(intraday)); expect(JSON.stringify(await router.getDetailedPriceHistory("QQQ", "NASDAQ", start, end, "15m"))).toBe(JSON.stringify(intraday)); expect(JSON.stringify(await router.getDetailedPriceHistory("QQQ", "NASDAQ", start, end, "1wk"))).toBe(JSON.stringify(weekly)); } expect(calls).toEqual({ generic: 1, resolution: 1, detailed: 2 }); await expect(router.getPriceHistoryForResolution("QQQ", "NASDAQ", "1M", "1m")).rejects.toThrow("No resolution-aware history provider"); Date.now = () => Date.parse("2026-09-11T19:39:09Z"); await expect(router.getPriceHistoryForResolution("QQQ", "NASDAQ", "1M", "15m")).rejects.toThrow("No resolution-aware history provider"); } finally { persistence.close(); Date.now = originalNow; } }); test("does not log expected provider misses for missing chart data", async () => { const noisyProvider: DataProvider = { ...fallbackProvider, id: "yahoo", name: "Yahoo", async getPriceHistory() { throw new Error('[404] {"chart":{"result":null,"error":{"code":"Not Found","description":"No data found, symbol may be delisted"}}}'); }, }; const router = new AssetDataRouter(fallbackProvider, [noisyProvider]); const logged: unknown[][] = []; console.error = (...args: unknown[]) => { logged.push(args); }; const history = await router.getPriceHistory("BAD", "NASDAQ", "1Y"); expect(history).toEqual([]); expect(logged).toHaveLength(0); }); test("falls back to later providers when the preferred chart source is empty", async () => { const dbPath = createTempDbPath("chart-fallback"); const persistence = new AppPersistence(dbPath); const cloudProvider: DataProvider = { ...fallbackProvider, id: "cloud", name: "Cloud", priority: 100, async getPriceHistory() { return []; }, }; const yahooProvider: DataProvider = { ...fallbackProvider, id: "yahoo", name: "Yahoo", priority: 1000, async getPriceHistory() { return [{ date: new Date("2026-03-28T00:00:00Z"), close: 101 }]; }, }; const seedRouter = new AssetDataRouter(yahooProvider, [cloudProvider], persistence.resources); const seeded = await seedRouter.getPriceHistory("AAPL", "NASDAQ", "1Y"); expect(seeded[0]?.close).toBe(101); const cachedRouter = new AssetDataRouter(yahooProvider, [cloudProvider], persistence.resources); const cached = await cachedRouter.getPriceHistory("AAPL", "NASDAQ", "1Y"); expect(cached[0]?.close).toBe(101); persistence.close(); }); test("a broader cached range answers a narrower one only at that range's bar size", async () => { const persistence = new AppPersistence(createTempDbPath("range-bar-size-cache")); const DAY = 86_400_000, now = Date.now(), today = now - (now % DAY); const bars = (count: number, step: number) => Array.from({ length: count }, (_, index) => ({ date: new Date(today - (count - 1 - index) * step), close: 100 + index })); const calls: string[] = []; const provider: DataProvider = { ...fallbackProvider, id: "gloomberb-cloud", name: "Cloud", async getPriceHistory(_symbol, _exchange, range) { calls.push(range); return range === "5Y" ? bars(12, 7 * DAY) : bars(60, DAY); } }; try { const router = new AssetDataRouter(provider, [], persistence.resources); await router.getPriceHistory("AAPL", "NASDAQ", "5Y"); // Weekly bars clipped to 1M would hide the current week. expect((await router.getPriceHistory("AAPL", "NASDAQ", "1M")).length).toBeGreaterThan(12); expect(calls).toEqual(["5Y", "1M"]); // Daily bars from a broader range still answer 1M without a fetch. await router.getPriceHistory("MSFT", "NASDAQ", "3M"); expect((await router.getPriceHistory("MSFT", "NASDAQ", "1M")).length).toBeGreaterThan(20); expect(calls).toEqual(["5Y", "1M", "3M"]); } finally { persistence.close(); } }); test("a 1D answer from a broader cached range keeps only the latest session", async () => { Date.now = () => Date.parse("2026-09-15T14:00:00Z"); const persistence = new AppPersistence(createTempDbPath("latest-session-cache")); const FIVE_MIN = 300_000; const session = (open: string, count: number) => Array.from({ length: count }, (_, index) => ({ date: new Date(Date.parse(open) + index * FIVE_MIN), close: 100 + index, volume: 1_000 })); const calls: string[] = []; const provider: DataProvider = { ...fallbackProvider, id: "gloomberb-cloud", name: "Cloud", async getPriceHistory(_symbol, _exchange, range) { calls.push(range); return [...session("2026-09-14T13:30:00Z", 78), ...session("2026-09-15T13:30:00Z", 6)]; } }; try { const router = new AssetDataRouter(provider, [], persistence.resources); await router.getPriceHistory("QQQ", "", "1W"); const day = await router.getPriceHistory("QQQ", "", "1D"); expect(calls).toEqual(["1W"]); expect(day.map((point) => new Date(point.date).toISOString().slice(0, 10))).toEqual(Array(6).fill("2026-09-15")); } finally { persistence.close(); } }); test("a thin listing's quiet hours within one session are not a session break", async () => { Date.now = () => Date.parse("2026-09-15T18:00:00Z"); const persistence = new AppPersistence(createTempDbPath("quiet-session-cache")); const FIVE_MIN = 300_000; const bars = (open: string, count: number) => Array.from({ length: count }, (_, index) => ({ date: new Date(Date.parse(open) + index * FIVE_MIN), close: 1 + index / 100, volume: 100 })); // Nothing trades between 09:40 and 13:00 New York time. const today = [...bars("2026-09-15T13:30:00Z", 2), ...bars("2026-09-15T17:00:00Z", 12)]; const provider: DataProvider = { ...fallbackProvider, id: "gloomberb-cloud", name: "Cloud", async getPriceHistory() { return [...bars("2026-09-14T13:30:00Z", 78), ...today]; } }; try { const router = new AssetDataRouter(provider, [], persistence.resources); await router.getPriceHistory("THIN", "NASDAQ", "1W"); const day = await router.getPriceHistory("THIN", "NASDAQ", "1D"); expect(day.map((point) => new Date(point.date).toISOString())).toEqual(today.map((bar) => bar.date.toISOString())); } finally { persistence.close(); } }); test("sorts reversed chart history into chronological order", async () => { const router = new AssetDataRouter({ ...fallbackProvider, id: "cloud", name: "Cloud", async getPriceHistory() { return [ { date: new Date("2026-03-29T00:00:00Z"), close: 103 }, { date: new Date("2026-03-27T00:00:00Z"), close: 101 }, { date: new Date("2026-03-28T00:00:00Z"), close: 102 }, ]; }, }); const history = await router.getPriceHistory("AAPL", "NASDAQ", "1Y"); expect(history.map((point) => point.close)).toEqual([101, 102, 103]); }); test("ignores poisoned cached chart history and refetches clean data", async () => { const dbPath = createTempDbPath("poisoned-chart-cache"); const persistence = new AppPersistence(dbPath); persistence.resources.set( { namespace: "market", kind: "price-history", entityKey: "AAPL", variantKey: "exchange=NASDAQ;range=1Y", sourceKey: "provider:yahoo", }, [ { date: null, close: 101 }, { date: null, close: 102 }, ], { cachePolicy: { staleMs: 60_000, expireMs: 60_000 }, }, ); let providerCalls = 0; const router = new AssetDataRouter({ ...fallbackProvider, id: "yahoo", name: "Yahoo", async getPriceHistory() { providerCalls += 1; return [ { date: new Date("2026-03-27T00:00:00Z"), close: 201 }, { date: new Date("2026-03-28T00:00:00Z"), close: 202 }, ]; }, }, [], persistence.resources); const history = await router.getPriceHistory("AAPL", "NASDAQ", "1Y"); expect(providerCalls).toBe(1); expect(history.map((point) => point.close)).toEqual([201, 202]); persistence.close(); }); test("ignores the previous chart history cache generation", async () => { const dbPath = createTempDbPath("previous-chart-cache-generation"); const persistence = new AppPersistence(dbPath); const latestDate = new Date(Date.now() - 60_000); const previousDate = new Date(latestDate.getTime() - 5 * 60_000); persistence.resources.set( { namespace: "market", kind: "price-history", entityKey: "META", variantKey: "exchange=NASDAQ;range=1M;resolution=5m;version=4", sourceKey: "provider:gloomberb-cloud", }, [ { date: previousDate, close: 620 }, { date: latestDate, close: 680 }, ], { cachePolicy: { staleMs: 60_000, expireMs: 60_000 }, }, ); let providerCalls = 0; const router = new AssetDataRouter({ ...fallbackProvider, id: "gloomberb-cloud", name: "Gloomberb Cloud", async getPriceHistoryForResolution() { providerCalls += 1; return [ { date: previousDate, close: 620 }, { date: latestDate, close: 560 }, ]; }, }, [], persistence.resources); const history = await router.getPriceHistoryForResolution("META", "NASDAQ", "1M", "5m"); expect(providerCalls).toBe(1); expect(history.map((point) => point.close)).toEqual([620, 560]); const cachedRows = persistence.database.connection .query("SELECT variant_key FROM resource_cache WHERE namespace = ? AND kind = ? AND entity_key = ? ORDER BY variant_key") .all("market", "price-history", "META") as Array<{ variant_key: string }>; expect(cachedRows.map((row) => row.variant_key)).toEqual([ "exchange=NASDAQ;range=1M;resolution=5m;version=4", "exchange=NASDAQ;range=1M;resolution=5m;version=5;historyData=1", ]); persistence.close(); }); test("ignores legacy unversioned sub-unit chart history caches", async () => { const dbPath = createTempDbPath("subunit-chart-cache"); const persistence = new AppPersistence(dbPath); persistence.resources.set( { namespace: "market", kind: "price-history", entityKey: "FTC", variantKey: "exchange=LSE;range=ALL;resolution=1wk", sourceKey: "provider:gloomberb-cloud", }, [ { date: new Date("2026-05-21T00:00:00Z"), close: 405 }, { date: new Date("2026-05-22T00:00:00Z"), close: 379 }, ], { cachePolicy: { staleMs: 60_000, expireMs: 60_000 }, }, ); let providerCalls = 0; const router = new AssetDataRouter({ ...fallbackProvider, id: "gloomberb-cloud", name: "Gloomberb Cloud", async getPriceHistoryForResolution() { providerCalls += 1; return [ { date: new Date("2026-05-21T00:00:00Z"), close: 4.05 }, { date: new Date("2026-05-22T00:00:00Z"), close: 3.79 }, ]; }, }, [], persistence.resources); const history = await router.getPriceHistoryForResolution("FTC", "LSE", "ALL", "1wk"); expect(providerCalls).toBe(1); expect(history.map((point) => point.close)).toEqual([4.05, 3.79]); const cachedRows = persistence.database.connection .query("SELECT variant_key FROM resource_cache WHERE namespace = ? AND kind = ? AND entity_key = ? ORDER BY variant_key") .all("market", "price-history", "FTC") as Array<{ variant_key: string }>; expect(cachedRows.map((row) => row.variant_key)).toEqual([ "exchange=LSE;range=ALL;resolution=1wk", "exchange=LSE;range=ALL;resolution=1wk;version=5;historyData=1;granularity=1;unit=GBP", ]); persistence.close(); }); test("bypasses cached financials on explicit refresh requests", async () => { const dbPath = createTempDbPath("forced-financial-refresh"); const persistence = new AppPersistence(dbPath); const seedRouter = new AssetDataRouter({ ...fallbackProvider, async getTickerFinancials() { return makeFinancials({ priceHistory: [{ date: new Date("2026-03-27T00:00:00Z"), close: 101 }], quote: makeQuote({ price: 101, change: 1, changePercent: 1, }), }); }, }, [], persistence.resources); await seedRouter.getTickerFinancials("AAPL", "NASDAQ"); let providerCalls = 0; const refreshRouter = new AssetDataRouter({ ...fallbackProvider, async getTickerFinancials() { providerCalls += 1; return makeFinancials({ priceHistory: [{ date: new Date("2026-03-28T00:00:00Z"), close: 202 }], quote: makeQuote({ price: 202, change: 2, changePercent: 1, }), }); }, }, [], persistence.resources); const refreshed = await refreshRouter.getTickerFinancials("AAPL", "NASDAQ", { cacheMode: "refresh" }); expect(providerCalls).toBe(1); expect(refreshed.quote?.price).toBe(202); expect(refreshed.priceHistory[0]?.close).toBe(202); persistence.close(); }); test("returns stale unexpired chart history immediately and refreshes in the background", async () => { const dbPath = createTempDbPath("stale-chart-hit"); const persistence = new AppPersistence(dbPath); const seedRouter = new AssetDataRouter({ ...fallbackProvider, async getPriceHistory() { return [{ date: new Date("2026-03-27T00:00:00Z"), close: 101 }]; }, }, [], persistence.resources); await seedRouter.getPriceHistory("AAPL", "NASDAQ", "1Y"); persistence.database.connection .query("UPDATE resource_cache SET stale_at = ? WHERE namespace = ? AND kind = ? AND entity_key = ?") .run(Date.now() - 1, "market", "price-history", "AAPL"); let resolveFresh!: (points: Array<{ date: Date; close: number }>) => void; const freshHistory = new Promise>((resolve) => { resolveFresh = resolve; }); let providerCalls = 0; const refreshRouter = new AssetDataRouter({ ...fallbackProvider, async getPriceHistory() { providerCalls += 1; return freshHistory; }, }, [], persistence.resources); const history = await refreshRouter.getPriceHistory("AAPL", "NASDAQ", "1Y"); expect(history[0]?.close).toBe(101); await Promise.resolve(); expect(providerCalls).toBe(1); resolveFresh([{ date: new Date("2026-03-28T00:00:00Z"), close: 202 }]); await freshHistory; await new Promise((resolve) => setTimeout(resolve, 20)); const refreshed = await refreshRouter.getPriceHistory("AAPL", "NASDAQ", "1Y"); expect(refreshed[0]?.close).toBe(202); persistence.close(); }); test("clips a shorter resolution range from a wider cached series", async () => { const dbPath = createTempDbPath("clip-wider-chart-cache"); const persistence = new AppPersistence(dbPath); const now = Date.parse("2026-03-28T00:00:00Z"); const sixYearsAgo = new Date(now - 6 * 365 * 24 * 60 * 60_000); const twoYearsAgo = new Date(now - 2 * 365 * 24 * 60 * 60_000); const latest = new Date(now); const seedRouter = new AssetDataRouter({ ...fallbackProvider, async getPriceHistoryForResolution() { return [ { date: sixYearsAgo, close: 50 }, { date: twoYearsAgo, close: 101 }, { date: latest, close: 110 }, ]; }, }, [], persistence.resources); await seedRouter.getPriceHistoryForResolution("AAPL", "NASDAQ", "ALL", "1wk"); let providerCalls = 0; const clipRouter = new AssetDataRouter({ ...fallbackProvider, async getPriceHistoryForResolution() { providerCalls += 1; return [{ date: latest, close: 999 }]; }, }, [], persistence.resources); const history = await clipRouter.getPriceHistoryForResolution("AAPL", "NASDAQ", "5Y", "1wk"); expect(providerCalls).toBe(0); expect(history.map((point) => point.close)).toEqual([101, 110]); persistence.close(); }); test("revalidates a stale wider cache once for concurrent shorter requests and persists the correction", async () => { const persistence = new AppPersistence(createTempDbPath("stale-wider-chart-cache")); const date = new Date(Date.now() - 24 * 60 * 60_000); const stalePoint = { date, open: 764.08, high: 758.555, low: 757.57, close: 758.15 }; const correctedPoint = { date, open: 758.03, high: 760.11, low: 756.64, close: 757.83 }; let resolveFresh!: (points: typeof stalePoint[]) => void; const fresh = new Promise((resolve) => { resolveFresh = resolve; }); let calls = 0; const provider = { ...fallbackProvider, async getPriceHistoryForResolution(_symbol: string, _exchange: string, range: string) { if (range === "5Y") return [stalePoint]; calls += 1; return fresh; }, }; try { await new AssetDataRouter(provider, [], persistence.resources) .getPriceHistoryForResolution("SPY", "NYSEARCA", "5Y", "1d"); persistence.database.connection.query("UPDATE resource_cache SET stale_at = ?").run(Date.now() - 1); const router = new AssetDataRouter(provider, [], persistence.resources); const initial = await Promise.all(Array.from({ length: 2 }, () => router.getPriceHistoryForResolution("SPY", "NYSEARCA", "1M", "1d"))); expect(initial.map((points) => points[0]?.close)).toEqual([758.15, 758.15]); await Promise.resolve(); expect(calls).toBe(1); resolveFresh([correctedPoint]); await fresh; await new Promise((resolve) => setTimeout(resolve, 20)); const reloaded = await new AssetDataRouter(provider, [], persistence.resources) .getPriceHistoryForResolution("SPY", "NYSEARCA", "1M", "1d"); expect(reloaded.map(({ date: _date, ...point }) => point)).toEqual([ { open: 758.03, high: 760.11, low: 756.64, close: 757.83 }, ]); expect(calls).toBe(1); } finally { resolveFresh?.([]); await fresh; persistence.close(); } }); test("falls back to later providers for fixed-resolution chart history", async () => { const cloudProvider: DataProvider = { ...fallbackProvider, id: "cloud", name: "Cloud", priority: 100, async getPriceHistoryForResolution() { return []; }, }; const yahooProvider: DataProvider = { ...fallbackProvider, id: "yahoo", name: "Yahoo", priority: 1000, async getPriceHistoryForResolution() { return [{ date: new Date("2026-03-28T00:00:00Z"), close: 102 }]; }, }; const router = new AssetDataRouter(yahooProvider, [cloudProvider]); const history = await router.getPriceHistoryForResolution("AAPL", "NASDAQ", "1Y", "1d"); expect(history[0]?.close).toBe(102); }); test("accepts previous-session history without persisted exchange metadata while closed", async () => { const originalDateNow = Date.now; Date.now = () => Date.parse("2026-05-17T12:00:00Z"); try { const cloudProvider: DataProvider = { ...fallbackProvider, id: "cloud", name: "Cloud", priority: 100, async getPriceHistoryForResolution() { return []; }, }; const yahooProvider: DataProvider = { ...fallbackProvider, id: "yahoo", name: "Yahoo", priority: 1000, async getPriceHistoryForResolution() { return [ { date: new Date("2026-05-15T15:15:00Z"), close: 101 }, { date: new Date("2026-05-15T15:30:00Z"), close: 102 }, ]; }, }; const router = new AssetDataRouter(yahooProvider, [cloudProvider]); const history = await router.getPriceHistoryForResolution("AAPL", "", "1M", "15m"); expect(history.map((point) => point.close)).toEqual([101, 102]); } finally { Date.now = originalDateNow; } }); test("returns normalized manual chart resolution capabilities", async () => { const cloudProvider: DataProvider = { ...fallbackProvider, id: "cloud", name: "Cloud", priority: 100, async getChartResolutionCapabilities() { return []; }, }; const yahooProvider: DataProvider = { ...fallbackProvider, id: "yahoo", name: "Yahoo", priority: 1000, async getChartResolutionCapabilities() { return ["1wk", "auto", "1d", "bogus"] as any; }, }; const router = new AssetDataRouter(yahooProvider, [cloudProvider]); expect(await router.getChartResolutionCapabilities("AAPL", "NASDAQ")).toEqual(["1d", "1wk"]); }); test("skips unavailable providers when resolving chart resolution support", async () => { let cloudSupportCalls = 0; const cloudProvider: DataProvider = { ...fallbackProvider, id: "cloud", name: "Cloud", priority: 100, async canProvide() { return false; }, getChartResolutionSupport() { cloudSupportCalls += 1; return [{ resolution: "1m", maxRange: "1W" }]; }, }; const yahooProvider: DataProvider = { ...fallbackProvider, id: "yahoo", name: "Yahoo", priority: 1000, getChartResolutionSupport() { return [{ resolution: "1d", maxRange: "5Y" }]; }, }; const router = new AssetDataRouter(yahooProvider, [cloudProvider]); expect(await router.getChartResolutionSupport("AAPL", "NASDAQ")).toEqual([ { resolution: "1d", maxRange: "5Y" }, ]); expect(cloudSupportCalls).toBe(0); }); test("refreshes each history request type before falling back to its cached value", async () => { const dbPath = createTempDbPath("forced-history-refresh"); const persistence = new AppPersistence(dbPath); const startDate = new Date("2026-03-28T09:30:00Z"); const endDate = new Date("2026-03-28T16:00:00Z"); const seedProvider: DataProvider = { ...fallbackProvider, async getPriceHistory() { return [{ date: endDate, close: 101 }]; }, async getPriceHistoryForResolution() { return [{ date: endDate, close: 102 }]; }, async getDetailedPriceHistory() { return [{ date: endDate, close: 103 }]; }, }; const seedRouter = new AssetDataRouter(seedProvider, [], persistence.resources); await seedRouter.getPriceHistory("AAPL", "NASDAQ", "1Y"); await seedRouter.getPriceHistoryForResolution("AAPL", "NASDAQ", "1Y", "1d"); await seedRouter.getDetailedPriceHistory("AAPL", "NASDAQ", startDate, endDate, "15m"); const calls = { range: 0, resolution: 0, detailed: 0 }; const refreshProvider: DataProvider = { ...fallbackProvider, async getPriceHistory() { calls.range += 1; return []; }, async getPriceHistoryForResolution() { calls.resolution += 1; return []; }, async getDetailedPriceHistory() { calls.detailed += 1; return []; }, }; const refreshRouter = new AssetDataRouter(refreshProvider, [], persistence.resources); const range = await refreshRouter.getPriceHistory("AAPL", "NASDAQ", "1Y", { cacheMode: "refresh" }); const resolution = await refreshRouter.getPriceHistoryForResolution( "AAPL", "NASDAQ", "1Y", "1d", { cacheMode: "refresh" }, ); const detailed = await refreshRouter.getDetailedPriceHistory( "AAPL", "NASDAQ", startDate, endDate, "15m", { cacheMode: "refresh" }, ); expect(calls).toEqual({ range: 1, resolution: 1, detailed: 1 }); expect([range[0]?.close, resolution[0]?.close, detailed[0]?.close]).toEqual([101, 102, 103]); persistence.close(); }); test("preserves each history request type's missing-provider result", async () => { const router = new AssetDataRouter(null); const startDate = new Date("2026-03-28T09:30:00Z"); const endDate = new Date("2026-03-28T16:00:00Z"); await expect(router.getPriceHistory("AAPL", "NASDAQ", "1Y")) .rejects.toThrow("No history provider available for AAPL"); await expect(router.getPriceHistoryForResolution("AAPL", "NASDAQ", "1Y", "1d")) .rejects.toThrow("No resolution-aware history provider available for AAPL"); await expect(router.getDetailedPriceHistory("AAPL", "NASDAQ", startDate, endDate, "15m")) .resolves.toEqual([]); }); test("falls back to later providers when detailed chart history is empty", async () => { const cloudProvider: DataProvider = { ...fallbackProvider, id: "cloud", name: "Cloud", priority: 100, async getDetailedPriceHistory() { return []; }, }; const yahooProvider: DataProvider = { ...fallbackProvider, id: "yahoo", name: "Yahoo", priority: 1000, async getDetailedPriceHistory() { return [{ date: new Date("2026-03-28T10:00:00Z"), close: 102 }]; }, }; const router = new AssetDataRouter(yahooProvider, [cloudProvider]); const history = await router.getDetailedPriceHistory( "AAPL", "NASDAQ", new Date("2026-03-28T09:30:00Z"), new Date("2026-03-28T16:00:00Z"), "15m", ); expect(history[0]?.close).toBe(102); }); });