gloomberb 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/components/chart/chart-renderer.ts +109 -32
  3. package/src/components/chart/chart-types.ts +18 -0
  4. package/src/components/chart/chart.test.ts +152 -5
  5. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  6. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  7. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  8. package/src/components/chart/market-session.ts +187 -0
  9. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  10. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  11. package/src/components/chart/stock-chart.tsx +36 -8
  12. package/src/components/chart/time-axis-label.tsx +56 -2
  13. package/src/components/command-bar/command-bar.test.tsx +1 -1
  14. package/src/components/command-bar/command-bar.tsx +7 -1
  15. package/src/components/command-bar/list-model.ts +8 -4
  16. package/src/components/command-bar/view-model.test.ts +20 -0
  17. package/src/components/command-bar/view-model.ts +15 -3
  18. package/src/components/ticker-list-table.tsx +4 -15
  19. package/src/core/state/session-persistence.ts +25 -0
  20. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  21. package/src/market-data/coordinator.test.ts +122 -2
  22. package/src/market-data/coordinator.ts +192 -55
  23. package/src/market-data/hooks.test.tsx +31 -0
  24. package/src/market-data/hooks.tsx +1 -0
  25. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  26. package/src/plugins/builtin/chat-controller.ts +8 -7
  27. package/src/plugins/builtin/chat.test.tsx +55 -0
  28. package/src/plugins/builtin/chat.tsx +20 -6
  29. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  30. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  31. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  32. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  33. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  34. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  35. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  36. package/src/plugins/builtin/sectors/index.tsx +30 -9
  37. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  38. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  39. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  40. package/src/sources/gloomberb-cloud.test.ts +55 -2
  41. package/src/sources/gloomberb-cloud.ts +180 -29
  42. package/src/sources/provider-router.test.ts +567 -10
  43. package/src/sources/provider-router.ts +323 -93
  44. package/src/sources/yahoo-finance.test.ts +37 -0
  45. package/src/sources/yahoo-finance.ts +48 -2
  46. package/src/state/app-bootstrap.test.ts +50 -23
  47. package/src/state/app-bootstrap.ts +26 -10
  48. package/src/state/session-persistence.test.ts +5 -3
  49. package/src/state/use-inline-tickers.ts +3 -0
  50. package/src/state/use-quote-streaming.ts +11 -0
  51. package/src/types/data-provider.ts +5 -1
  52. package/src/types/financials.ts +1 -0
  53. package/src/utils/api-client.test.ts +120 -0
  54. package/src/utils/api-client.ts +66 -19
  55. package/src/utils/exchanges.test.ts +16 -0
  56. package/src/utils/exchanges.ts +33 -0
  57. package/src/utils/options.ts +1 -1
  58. package/src/utils/price-history.test.ts +28 -1
  59. package/src/utils/price-history.ts +16 -0
  60. package/src/utils/quote-freshness.test.ts +150 -0
  61. package/src/utils/quote-freshness.ts +42 -1
  62. package/src/utils/quote-resolution.ts +2 -0
  63. package/src/utils/quote-time.test.ts +34 -0
  64. package/src/utils/quote-time.ts +18 -5
  65. package/src/version.ts +1 -1
  66. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -83,6 +83,23 @@ function makeArticle(id: string): NewsArticle {
83
83
  }
84
84
 
85
85
  describe("AssetDataRouter", () => {
86
+ test("serves USD exchange rate locally without provider revalidation", async () => {
87
+ let providerCalls = 0;
88
+ const router = new AssetDataRouter({
89
+ ...fallbackProvider,
90
+ async getExchangeRate() {
91
+ providerCalls += 1;
92
+ return 1;
93
+ },
94
+ });
95
+
96
+ const rate = await router.getExchangeRate("usd");
97
+ await Promise.resolve();
98
+
99
+ expect(rate).toBe(1);
100
+ expect(providerCalls).toBe(0);
101
+ });
102
+
86
103
  test("routes market calls only through sources with market capability", async () => {
87
104
  const newsOnlySource: CapabilityRouteSource = {
88
105
  id: "news-only",
@@ -378,6 +395,96 @@ describe("AssetDataRouter", () => {
378
395
  expect(quote.price).toBe(123.45);
379
396
  });
380
397
 
398
+ test("falls back to provider quotes when a broker mark is stale", async () => {
399
+ const fixedNow = Date.parse("2026-05-13T21:00:00Z");
400
+ const realDateNow = Date.now;
401
+ Date.now = () => fixedNow;
402
+ try {
403
+ const yahooProvider: DataProvider = {
404
+ ...fallbackProvider,
405
+ id: "yahoo",
406
+ name: "Yahoo",
407
+ async getQuote(symbol) {
408
+ return {
409
+ symbol,
410
+ providerId: "yahoo",
411
+ price: 50_500,
412
+ currency: "JPY",
413
+ change: 4400,
414
+ changePercent: 9.54,
415
+ lastUpdated: Date.parse("2026-05-13T06:24:00Z"),
416
+ listingExchangeName: "JPX",
417
+ marketState: "CLOSED",
418
+ dataSource: "delayed",
419
+ };
420
+ },
421
+ };
422
+ const router = new AssetDataRouter(yahooProvider);
423
+ const broker: BrokerAdapter = {
424
+ id: "ibkr",
425
+ name: "IBKR",
426
+ configSchema: [],
427
+ async validate() {
428
+ return true;
429
+ },
430
+ async importPositions() {
431
+ return [];
432
+ },
433
+ async getQuote() {
434
+ return {
435
+ symbol: "285A.T",
436
+ providerId: "ibkr",
437
+ price: 46_100,
438
+ currency: "JPY",
439
+ change: 0,
440
+ changePercent: 0,
441
+ lastUpdated: Date.parse("2026-05-08T06:00:00Z"),
442
+ dataSource: "delayed",
443
+ };
444
+ },
445
+ };
446
+
447
+ router.attachRegistry({
448
+ brokers: new Map([["ibkr", broker]]),
449
+ getEnabledCapabilities: () => [],
450
+ } as any);
451
+ router.setConfigAccessor(() => ({
452
+ dataDir: "",
453
+ configVersion: CURRENT_CONFIG_VERSION,
454
+ baseCurrency: "USD",
455
+ refreshIntervalMinutes: 30,
456
+ portfolios: [],
457
+ watchlists: [],
458
+ columns: [],
459
+ layout: cloneLayout(DEFAULT_LAYOUT),
460
+ layouts: [{ name: "Default", layout: cloneLayout(DEFAULT_LAYOUT) }],
461
+ activeLayoutIndex: 0,
462
+ brokerInstances: [{
463
+ id: "ibkr-work",
464
+ brokerType: "ibkr",
465
+ label: "Work",
466
+ connectionMode: "gateway",
467
+ config: { connectionMode: "gateway", gateway: { host: "127.0.0.1", port: 4002, clientId: 1 } },
468
+ enabled: true,
469
+ }],
470
+ plugins: [],
471
+ disabledPlugins: [],
472
+ theme: "amber",
473
+ chartPreferences: {
474
+ defaultRenderMode: "area",
475
+ renderer: "auto",
476
+ },
477
+ recentTickers: [],
478
+ }));
479
+
480
+ const quote = await router.getQuote("285A.T", "TSEJ", { brokerId: "ibkr", brokerInstanceId: "ibkr-work" });
481
+ expect(quote.providerId).toBe("yahoo");
482
+ expect(quote.price).toBe(50_500);
483
+ } finally {
484
+ Date.now = realDateNow;
485
+ }
486
+ });
487
+
381
488
  test("prefers the requested broker instance in search results", async () => {
382
489
  const router = new AssetDataRouter(fallbackProvider);
383
490
  const broker: BrokerAdapter = {
@@ -551,6 +658,38 @@ describe("AssetDataRouter", () => {
551
658
  expect(quote.providerId).toBeUndefined();
552
659
  });
553
660
 
661
+ test("does not search fallback providers when the preferred provider returns results", async () => {
662
+ let cloudCalls = 0;
663
+ let yahooCalls = 0;
664
+ const cloudProvider: DataProvider = {
665
+ ...fallbackProvider,
666
+ id: "cloud",
667
+ name: "Cloud",
668
+ priority: 100,
669
+ async search() {
670
+ cloudCalls += 1;
671
+ return [{ providerId: "cloud", symbol: "SEC0", name: "iShares ETF", exchange: "XETRA", type: "ETF" }];
672
+ },
673
+ };
674
+ const yahooProvider: DataProvider = {
675
+ ...fallbackProvider,
676
+ id: "yahoo",
677
+ name: "Yahoo",
678
+ priority: 1000,
679
+ async search() {
680
+ yahooCalls += 1;
681
+ return [{ providerId: "yahoo", symbol: "SEC0", name: "iShares ETF", exchange: "XETRA", type: "ETF" }];
682
+ },
683
+ };
684
+
685
+ const router = new AssetDataRouter(yahooProvider, [cloudProvider]);
686
+ const results = await router.search("SEC0");
687
+
688
+ expect(results[0]?.providerId).toBe("cloud");
689
+ expect(cloudCalls).toBe(1);
690
+ expect(yahooCalls).toBe(0);
691
+ });
692
+
554
693
  test("routes through registered asset-data capabilities", async () => {
555
694
  const cloudProvider: DataProvider = {
556
695
  ...fallbackProvider,
@@ -582,6 +721,81 @@ describe("AssetDataRouter", () => {
582
721
  expect(quote.providerId).toBe("cloud");
583
722
  });
584
723
 
724
+ test("falls back when the preferred provider returns a stale quote", async () => {
725
+ const fixedNow = Date.parse("2026-05-13T21:00:00Z");
726
+ const realDateNow = Date.now;
727
+ Date.now = () => fixedNow;
728
+ try {
729
+ const yahooProvider: DataProvider = {
730
+ ...fallbackProvider,
731
+ id: "yahoo",
732
+ name: "Yahoo",
733
+ async getQuote(symbol) {
734
+ return {
735
+ symbol,
736
+ providerId: "yahoo",
737
+ price: 168,
738
+ currency: "TWD",
739
+ change: 1,
740
+ changePercent: 0.6,
741
+ lastUpdated: Date.parse("2026-05-13T05:30:00Z"),
742
+ listingExchangeName: "TWSE",
743
+ marketState: "CLOSED",
744
+ dataSource: "delayed",
745
+ };
746
+ },
747
+ };
748
+ const cloudProvider: DataProvider = {
749
+ ...fallbackProvider,
750
+ id: "gloomberb-cloud",
751
+ name: "Cloud",
752
+ priority: 100,
753
+ async getQuote(symbol) {
754
+ return {
755
+ symbol,
756
+ providerId: "gloomberb-cloud",
757
+ price: 150,
758
+ currency: "TWD",
759
+ change: 0,
760
+ changePercent: 0,
761
+ lastUpdated: Date.parse("2026-05-08T06:00:00Z"),
762
+ listingExchangeName: "TWSE",
763
+ marketState: "CLOSED",
764
+ dataSource: "delayed",
765
+ };
766
+ },
767
+ async getQuotesBatch(targets) {
768
+ return targets.map((target) => ({
769
+ target,
770
+ quote: {
771
+ symbol: target.symbol,
772
+ providerId: "gloomberb-cloud",
773
+ price: 150,
774
+ currency: "TWD",
775
+ change: 0,
776
+ changePercent: 0,
777
+ lastUpdated: Date.parse("2026-05-08T06:00:00Z"),
778
+ listingExchangeName: "TWSE",
779
+ marketState: "CLOSED",
780
+ dataSource: "delayed",
781
+ },
782
+ }));
783
+ },
784
+ };
785
+ const router = new AssetDataRouter(yahooProvider, [cloudProvider]);
786
+
787
+ const quote = await router.getQuote("2337", "TWSE");
788
+ expect(quote.providerId).toBe("yahoo");
789
+ expect(quote.price).toBe(168);
790
+
791
+ const [batch] = await router.getQuotesBatch([{ symbol: "2337", exchange: "TWSE" }], { forceRefresh: true });
792
+ expect(batch?.quote?.providerId).toBe("yahoo");
793
+ expect(batch?.quote?.price).toBe(168);
794
+ } finally {
795
+ Date.now = realDateNow;
796
+ }
797
+ });
798
+
585
799
  test("prefers provider quote streams when one is available", () => {
586
800
  const brokerTargets: QuoteSubscriptionTarget[] = [];
587
801
  const providerTargets: QuoteSubscriptionTarget[] = [];
@@ -794,6 +1008,37 @@ describe("AssetDataRouter", () => {
794
1008
  unsubscribe();
795
1009
  });
796
1010
 
1011
+ test("returns quote-only financials for option tickers when snapshots are unavailable", async () => {
1012
+ const router = new AssetDataRouter({
1013
+ ...fallbackProvider,
1014
+ async getTickerFinancials() {
1015
+ return { annualStatements: [], quarterlyStatements: [], priceHistory: [] };
1016
+ },
1017
+ async getQuote(ticker) {
1018
+ return {
1019
+ symbol: ticker,
1020
+ price: 252.375,
1021
+ currency: "USD",
1022
+ change: 1.5,
1023
+ changePercent: 0.6,
1024
+ bid: 250.25,
1025
+ ask: 254.5,
1026
+ mark: 252.375,
1027
+ lastUpdated: Date.now(),
1028
+ providerId: "gloomberb-cloud",
1029
+ dataSource: "delayed",
1030
+ };
1031
+ },
1032
+ });
1033
+
1034
+ const financials = await router.getTickerFinancials("AMD 270917C00230000");
1035
+
1036
+ expect(financials.quote?.symbol).toBe("AMD 270917C00230000");
1037
+ expect(financials.quote?.mark).toBe(252.375);
1038
+ expect(financials.annualStatements).toEqual([]);
1039
+ expect(financials.priceHistory).toEqual([]);
1040
+ });
1041
+
797
1042
  test("merges cached broker financials with cached fallback fundamentals", async () => {
798
1043
  const dbPath = createTempDbPath("cache-merge");
799
1044
  const persistence = new AppPersistence(dbPath);
@@ -1066,7 +1311,7 @@ describe("AssetDataRouter", () => {
1066
1311
  expect(merged.profile?.sector).toBe("Technology");
1067
1312
  });
1068
1313
 
1069
- test("backfills quote and fundamental fields from fallback providers", async () => {
1314
+ test("uses the preferred provider financials without merging fallback providers", async () => {
1070
1315
  const cloudProvider: DataProvider = {
1071
1316
  ...fallbackProvider,
1072
1317
  id: "cloud",
@@ -1091,12 +1336,14 @@ describe("AssetDataRouter", () => {
1091
1336
  };
1092
1337
  },
1093
1338
  };
1339
+ let yahooCalls = 0;
1094
1340
  const yahooProvider: DataProvider = {
1095
1341
  ...fallbackProvider,
1096
1342
  id: "yahoo",
1097
1343
  name: "Yahoo",
1098
1344
  priority: 1000,
1099
1345
  async getTickerFinancials() {
1346
+ yahooCalls += 1;
1100
1347
  return {
1101
1348
  annualStatements: [],
1102
1349
  quarterlyStatements: [],
@@ -1121,14 +1368,15 @@ describe("AssetDataRouter", () => {
1121
1368
  const merged = await router.getTickerFinancials("AAPL", "NASDAQ");
1122
1369
 
1123
1370
  expect(merged.quote?.price).toBe(125);
1124
- expect(merged.quote?.marketCap).toBe(2_000_000_000);
1371
+ expect(merged.quote?.marketCap).toBeUndefined();
1125
1372
  expect(merged.fundamentals?.trailingPE).toBe(25);
1126
- expect(merged.fundamentals?.forwardPE).toBe(22);
1127
- expect(merged.priceHistory[0]?.close).toBe(124);
1373
+ expect(merged.fundamentals?.forwardPE).toBeUndefined();
1374
+ expect(merged.priceHistory).toEqual([]);
1375
+ expect(yahooCalls).toBe(0);
1128
1376
  });
1129
1377
 
1130
- test("merges cached financial snapshots across multiple providers", async () => {
1131
- const dbPath = createTempDbPath("cached-provider-financial-merge");
1378
+ test("caches the preferred provider financial snapshot without merging fallback snapshots", async () => {
1379
+ const dbPath = createTempDbPath("cached-provider-financial-preferred");
1132
1380
  const persistence = new AppPersistence(dbPath);
1133
1381
 
1134
1382
  const cloudProvider: DataProvider = {
@@ -1186,7 +1434,8 @@ describe("AssetDataRouter", () => {
1186
1434
 
1187
1435
  const seedRouter = new AssetDataRouter(yahooProvider, [cloudProvider], persistence.resources);
1188
1436
  const seeded = await seedRouter.getTickerFinancials("AAPL", "NASDAQ");
1189
- expect(seeded.quote?.marketCap).toBe(2_000_000_000);
1437
+ expect(seeded.quote?.price).toBe(125);
1438
+ expect(seeded.quote?.marketCap).toBeUndefined();
1190
1439
 
1191
1440
  let cloudCalls = 0;
1192
1441
  let yahooCalls = 0;
@@ -1209,11 +1458,11 @@ describe("AssetDataRouter", () => {
1209
1458
  expect(cloudCalls).toBe(0);
1210
1459
  expect(yahooCalls).toBe(0);
1211
1460
  expect(cached.quote?.price).toBe(125);
1212
- expect(cached.quote?.marketCap).toBe(2_000_000_000);
1461
+ expect(cached.quote?.marketCap).toBeUndefined();
1213
1462
  expect(cached.fundamentals?.trailingPE).toBe(25);
1214
- expect(cached.fundamentals?.forwardPE).toBe(22);
1463
+ expect(cached.fundamentals?.forwardPE).toBeUndefined();
1215
1464
  expect(cached.profile?.sector).toBe("Technology");
1216
- expect(cached.priceHistory[0]?.close).toBe(124);
1465
+ expect(cached.priceHistory).toEqual([]);
1217
1466
 
1218
1467
  persistence.close();
1219
1468
  });
@@ -1755,6 +2004,314 @@ describe("AssetDataRouter", () => {
1755
2004
  persistence.close();
1756
2005
  });
1757
2006
 
2007
+ test("keeps stale cached quotes only for startup cache priming", () => {
2008
+ const dbPath = createTempDbPath("startup-stale-quote-prime");
2009
+ const persistence = new AppPersistence(dbPath);
2010
+ const now = Date.now();
2011
+
2012
+ persistence.resources.set(
2013
+ {
2014
+ namespace: "market",
2015
+ kind: "financials",
2016
+ entityKey: "OLD",
2017
+ variantKey: "exchange=NASDAQ",
2018
+ sourceKey: "provider:gloomberb-cloud",
2019
+ },
2020
+ {
2021
+ annualStatements: [],
2022
+ quarterlyStatements: [],
2023
+ priceHistory: [],
2024
+ quote: {
2025
+ symbol: "OLD",
2026
+ price: 42,
2027
+ currency: "USD",
2028
+ change: -1,
2029
+ changePercent: -2.33,
2030
+ lastUpdated: Date.UTC(2020, 0, 2),
2031
+ marketState: "REGULAR",
2032
+ exchangeName: "NASDAQ",
2033
+ listingExchangeName: "NASDAQ",
2034
+ providerId: "gloomberb-cloud",
2035
+ dataSource: "delayed",
2036
+ },
2037
+ fundamentals: {
2038
+ trailingPE: 12,
2039
+ },
2040
+ },
2041
+ {
2042
+ cachePolicy: { staleMs: 60_000, expireMs: 60_000 },
2043
+ fetchedAt: now,
2044
+ },
2045
+ );
2046
+
2047
+ const router = new AssetDataRouter({
2048
+ ...fallbackProvider,
2049
+ id: "gloomberb-cloud",
2050
+ name: "Cloud",
2051
+ async getTickerFinancials() {
2052
+ throw new Error("should not fetch financials");
2053
+ },
2054
+ }, [], persistence.resources);
2055
+
2056
+ const normal = router.getCachedFinancialsForTargets([{ symbol: "OLD", exchange: "NASDAQ" }], { allowExpired: true });
2057
+ expect(normal.get("OLD")?.quote).toBeUndefined();
2058
+ expect(normal.get("OLD")?.fundamentals?.trailingPE).toBe(12);
2059
+
2060
+ const startup = router.getCachedFinancialsForTargets(
2061
+ [{ symbol: "OLD", exchange: "NASDAQ" }],
2062
+ { allowExpired: true, includeStaleQuotes: true },
2063
+ );
2064
+ expect(startup.get("OLD")?.quote?.changePercent).toBe(-2.33);
2065
+ expect(startup.get("OLD")?.fundamentals?.trailingPE).toBe(12);
2066
+
2067
+ persistence.close();
2068
+ });
2069
+
2070
+ test("derives cached market cap from quote price and shares outstanding", () => {
2071
+ const dbPath = createTempDbPath("cached-derived-market-cap");
2072
+ const persistence = new AppPersistence(dbPath);
2073
+ const now = Date.now();
2074
+
2075
+ persistence.resources.set(
2076
+ {
2077
+ namespace: "market",
2078
+ kind: "financials",
2079
+ entityKey: "AMD",
2080
+ variantKey: "exchange=NASDAQ",
2081
+ sourceKey: "provider:gloomberb-cloud",
2082
+ },
2083
+ {
2084
+ annualStatements: [],
2085
+ quarterlyStatements: [],
2086
+ priceHistory: [],
2087
+ quote: {
2088
+ symbol: "AMD",
2089
+ price: 445.38,
2090
+ currency: "USD",
2091
+ change: -2.91,
2092
+ changePercent: -0.65,
2093
+ lastUpdated: now,
2094
+ marketState: "REGULAR",
2095
+ exchangeName: "NASDAQ",
2096
+ listingExchangeName: "NASDAQ",
2097
+ providerId: "gloomberb-cloud",
2098
+ dataSource: "delayed",
2099
+ },
2100
+ fundamentals: {
2101
+ sharesOutstanding: 1_630_000_000,
2102
+ },
2103
+ },
2104
+ {
2105
+ cachePolicy: { staleMs: 60_000, expireMs: 60_000 },
2106
+ fetchedAt: now,
2107
+ },
2108
+ );
2109
+
2110
+ const router = new AssetDataRouter({
2111
+ ...fallbackProvider,
2112
+ id: "gloomberb-cloud",
2113
+ name: "Cloud",
2114
+ async getTickerFinancials() {
2115
+ throw new Error("should not fetch financials");
2116
+ },
2117
+ }, [], persistence.resources);
2118
+
2119
+ const cached = router.getCachedFinancialsForTargets([{ symbol: "AMD", exchange: "NASDAQ" }], {
2120
+ allowExpired: true,
2121
+ includeStaleQuotes: true,
2122
+ });
2123
+ expect(cached.get("AMD")?.quote?.marketCap).toBeCloseTo(725_969_400_000);
2124
+
2125
+ persistence.close();
2126
+ });
2127
+
2128
+ test("uses symbol provider cache for broker-linked startup targets", () => {
2129
+ const dbPath = createTempDbPath("cached-symbol-fallback-for-contract");
2130
+ const persistence = new AppPersistence(dbPath);
2131
+ const now = Date.now();
2132
+ const old = now - 3 * 24 * 60 * 60_000;
2133
+
2134
+ persistence.resources.set(
2135
+ {
2136
+ namespace: "market",
2137
+ kind: "financials",
2138
+ entityKey: "contract:14015423",
2139
+ variantKey: "exchange=JPX",
2140
+ sourceKey: "provider:yahoo",
2141
+ },
2142
+ {
2143
+ annualStatements: [],
2144
+ quarterlyStatements: [],
2145
+ priceHistory: [],
2146
+ quote: {
2147
+ symbol: "4092.T",
2148
+ providerId: "yahoo",
2149
+ price: 3770,
2150
+ currency: "JPY",
2151
+ change: 145,
2152
+ changePercent: 4,
2153
+ lastUpdated: old,
2154
+ marketState: "CLOSED",
2155
+ exchangeName: "JPX",
2156
+ listingExchangeName: "JPX",
2157
+ },
2158
+ },
2159
+ {
2160
+ cachePolicy: { staleMs: 60_000, expireMs: 7 * 24 * 60 * 60_000 },
2161
+ fetchedAt: old,
2162
+ },
2163
+ );
2164
+ persistence.resources.set(
2165
+ {
2166
+ namespace: "market",
2167
+ kind: "financials",
2168
+ entityKey: "4092.T",
2169
+ variantKey: "exchange=JPX",
2170
+ sourceKey: "provider:yahoo",
2171
+ },
2172
+ {
2173
+ annualStatements: [],
2174
+ quarterlyStatements: [],
2175
+ priceHistory: [],
2176
+ quote: {
2177
+ symbol: "4092.T",
2178
+ providerId: "yahoo",
2179
+ price: 3925,
2180
+ currency: "JPY",
2181
+ change: 20,
2182
+ changePercent: 0.51,
2183
+ lastUpdated: now,
2184
+ marketState: "CLOSED",
2185
+ exchangeName: "JPX",
2186
+ listingExchangeName: "JPX",
2187
+ },
2188
+ },
2189
+ {
2190
+ cachePolicy: { staleMs: 60_000, expireMs: 7 * 24 * 60 * 60_000 },
2191
+ fetchedAt: now,
2192
+ },
2193
+ );
2194
+
2195
+ const router = new AssetDataRouter({
2196
+ ...fallbackProvider,
2197
+ id: "yahoo",
2198
+ name: "Yahoo",
2199
+ async getTickerFinancials() {
2200
+ throw new Error("should not fetch financials");
2201
+ },
2202
+ }, [], persistence.resources);
2203
+
2204
+ const cached = router.getCachedFinancialsForTargets([{
2205
+ symbol: "4092.T",
2206
+ exchange: "JPX",
2207
+ instrument: {
2208
+ brokerId: "ibkr",
2209
+ brokerInstanceId: "ibkr-live",
2210
+ conId: 14015423,
2211
+ symbol: "4092.T",
2212
+ },
2213
+ }], { allowExpired: true, includeStaleQuotes: true });
2214
+
2215
+ expect(cached.get("4092.T")?.quote?.price).toBe(3925);
2216
+ expect(cached.get("4092.T")?.quote?.changePercent).toBe(0.51);
2217
+
2218
+ persistence.close();
2219
+ });
2220
+
2221
+ test("overlays standalone quote cache on stale financial snapshots during startup priming", () => {
2222
+ const dbPath = createTempDbPath("cached-quote-over-financial-snapshot");
2223
+ const persistence = new AppPersistence(dbPath);
2224
+ const now = Date.parse("2026-05-13T21:00:00Z");
2225
+ const old = Date.parse("2026-05-09T14:13:30Z");
2226
+
2227
+ persistence.resources.set(
2228
+ {
2229
+ namespace: "market",
2230
+ kind: "financials",
2231
+ entityKey: "contract:14016494",
2232
+ variantKey: "exchange=JPX",
2233
+ sourceKey: "provider:yahoo",
2234
+ },
2235
+ {
2236
+ annualStatements: [],
2237
+ quarterlyStatements: [],
2238
+ priceHistory: [],
2239
+ quote: {
2240
+ symbol: "6315.T",
2241
+ providerId: "yahoo",
2242
+ price: 3380,
2243
+ currency: "JPY",
2244
+ change: 75,
2245
+ changePercent: 2.27,
2246
+ lastUpdated: old,
2247
+ marketState: "CLOSED",
2248
+ exchangeName: "JPX",
2249
+ listingExchangeName: "JPX",
2250
+ },
2251
+ fundamentals: {
2252
+ sharesOutstanding: 75_000_462,
2253
+ trailingPE: 37.2,
2254
+ },
2255
+ },
2256
+ {
2257
+ cachePolicy: { staleMs: 60_000, expireMs: 7 * 24 * 60 * 60_000 },
2258
+ fetchedAt: old,
2259
+ },
2260
+ );
2261
+ persistence.resources.set(
2262
+ {
2263
+ namespace: "market",
2264
+ kind: "quote",
2265
+ entityKey: "contract:14016494",
2266
+ variantKey: "exchange=JPX",
2267
+ sourceKey: "provider:yahoo",
2268
+ },
2269
+ {
2270
+ symbol: "6315.T",
2271
+ providerId: "yahoo",
2272
+ price: 2688,
2273
+ currency: "JPY",
2274
+ change: 13,
2275
+ changePercent: 0.49,
2276
+ lastUpdated: now,
2277
+ marketState: "CLOSED",
2278
+ exchangeName: "JPX",
2279
+ listingExchangeName: "JPX",
2280
+ },
2281
+ {
2282
+ cachePolicy: { staleMs: 60_000, expireMs: 7 * 24 * 60 * 60_000 },
2283
+ fetchedAt: now,
2284
+ },
2285
+ );
2286
+
2287
+ const router = new AssetDataRouter({
2288
+ ...fallbackProvider,
2289
+ id: "yahoo",
2290
+ name: "Yahoo",
2291
+ async getTickerFinancials() {
2292
+ throw new Error("should not fetch financials");
2293
+ },
2294
+ }, [], persistence.resources);
2295
+
2296
+ const cached = router.getCachedFinancialsForTargets([{
2297
+ symbol: "6315.T",
2298
+ exchange: "JPX",
2299
+ instrument: {
2300
+ brokerId: "ibkr",
2301
+ brokerInstanceId: "ibkr-live",
2302
+ conId: 14016494,
2303
+ symbol: "6315.T",
2304
+ },
2305
+ }], { allowExpired: true, includeStaleQuotes: true });
2306
+
2307
+ expect(cached.get("6315.T")?.quote?.price).toBe(2688);
2308
+ expect(cached.get("6315.T")?.quote?.changePercent).toBe(0.49);
2309
+ expect(cached.get("6315.T")?.quote?.marketCap).toBe(201_601_241_856);
2310
+ expect(cached.get("6315.T")?.fundamentals?.trailingPE).toBe(37.2);
2311
+
2312
+ persistence.close();
2313
+ });
2314
+
1758
2315
  test("does not log expected provider misses for missing chart data", async () => {
1759
2316
  const noisyProvider: DataProvider = {
1760
2317
  ...fallbackProvider,