gloomberb 0.2.0 → 0.3.1

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 (310) hide show
  1. package/README.md +20 -19
  2. package/bin/gloomberb +3 -0
  3. package/package.json +5 -4
  4. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  5. package/src/app.tsx +1330 -0
  6. package/src/brokers/sync-broker-instance.test.ts +100 -0
  7. package/src/brokers/sync-broker-instance.ts +341 -0
  8. package/src/cli/commands/plugins.ts +173 -0
  9. package/src/cli/commands/portfolio.ts +210 -0
  10. package/src/cli/commands/search.ts +120 -0
  11. package/src/cli/commands/ticker.ts +375 -0
  12. package/src/cli/commands/watchlist.ts +210 -0
  13. package/src/cli/context.ts +62 -0
  14. package/src/cli/errors.ts +13 -0
  15. package/src/cli/helpers.ts +85 -0
  16. package/src/cli/index.ts +143 -0
  17. package/src/cli/ticker-resolution.ts +39 -0
  18. package/src/cli/types.ts +15 -0
  19. package/src/cli.test.ts +361 -0
  20. package/src/components/chart/chart-data.ts +239 -0
  21. package/src/components/chart/chart-renderer.ts +1199 -0
  22. package/src/components/chart/chart-types.ts +82 -0
  23. package/src/components/chart/chart-viewport.ts +21 -0
  24. package/src/components/chart/chart.test.ts +439 -0
  25. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  26. package/src/components/chart/comparison-chart-data.ts +316 -0
  27. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  28. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  29. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  30. package/src/components/chart/cursor-motion.ts +48 -0
  31. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  32. package/src/components/chart/native/kitty-adapter.ts +12 -0
  33. package/src/components/chart/native/kitty-manager.ts +142 -0
  34. package/src/components/chart/native/kitty-protocol.ts +110 -0
  35. package/src/components/chart/native/kitty-support.ts +71 -0
  36. package/src/components/chart/native/kitty.test.ts +620 -0
  37. package/src/components/chart/native/renderer-selection.ts +24 -0
  38. package/src/components/chart/native/surface-manager.ts +225 -0
  39. package/src/components/chart/native/surface-sync.ts +34 -0
  40. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  41. package/src/components/chart/stock-chart.test.ts +107 -0
  42. package/src/components/chart/stock-chart.tsx +1556 -0
  43. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  44. package/src/components/command-bar/command-bar.test.tsx +1524 -0
  45. package/src/components/command-bar/command-bar.tsx +4823 -0
  46. package/src/components/command-bar/command-registry.ts +261 -0
  47. package/src/components/command-bar/helpers.test.ts +224 -0
  48. package/src/components/command-bar/helpers.ts +371 -0
  49. package/src/components/command-bar/root-shortcuts.ts +158 -0
  50. package/src/components/command-bar/view-model.test.ts +152 -0
  51. package/src/components/command-bar/view-model.ts +185 -0
  52. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  53. package/src/components/command-bar/workflow-ops.ts +433 -0
  54. package/src/components/command-bar/workflow-types.ts +127 -0
  55. package/src/components/detail-feed-view.test.tsx +105 -0
  56. package/src/components/detail-feed-view.tsx +295 -0
  57. package/src/components/index.ts +32 -0
  58. package/src/components/layout/floating-pane.tsx +49 -0
  59. package/src/components/layout/header.tsx +129 -0
  60. package/src/components/layout/pane-header.tsx +44 -0
  61. package/src/components/layout/pane.tsx +49 -0
  62. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  63. package/src/components/layout/pixel-mouse.test.ts +40 -0
  64. package/src/components/layout/shell.test.tsx +625 -0
  65. package/src/components/layout/shell.tsx +1200 -0
  66. package/src/components/layout/status-bar.test.tsx +173 -0
  67. package/src/components/layout/status-bar.tsx +138 -0
  68. package/src/components/markdown-editor.tsx +134 -0
  69. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  70. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  71. package/src/components/pane-settings-dialog.tsx +468 -0
  72. package/src/components/pane-template-wizard.test.tsx +52 -0
  73. package/src/components/pane-template-wizard.tsx +199 -0
  74. package/src/components/price-selector-dialog.tsx +195 -0
  75. package/src/components/spinner.tsx +2 -0
  76. package/src/components/tab-bar.tsx +17 -0
  77. package/src/components/ticker-badge-text.test.tsx +160 -0
  78. package/src/components/ticker-badge-text.tsx +58 -0
  79. package/src/components/ticker-badge.tsx +88 -0
  80. package/src/components/ticker-list-table.tsx +188 -0
  81. package/src/components/toggle-list.tsx +74 -0
  82. package/src/components/ui/button.tsx +77 -0
  83. package/src/components/ui/data-table.tsx +210 -0
  84. package/src/components/ui/external-link.tsx +22 -0
  85. package/src/components/ui/fields.tsx +187 -0
  86. package/src/components/ui/frame.tsx +73 -0
  87. package/src/components/ui/index.ts +53 -0
  88. package/src/components/ui/list-view.tsx +166 -0
  89. package/src/components/ui/loading.tsx +70 -0
  90. package/src/components/ui/status.tsx +83 -0
  91. package/src/components/ui/tabs.tsx +47 -0
  92. package/src/components/ui/toggle.tsx +164 -0
  93. package/src/components/ui/ui.test.tsx +134 -0
  94. package/src/data/app-persistence.ts +25 -0
  95. package/src/data/config-store.test.ts +310 -0
  96. package/src/data/config-store.ts +469 -0
  97. package/src/data/plugin-state-store.ts +102 -0
  98. package/src/data/resource-store.ts +323 -0
  99. package/src/data/session-store.ts +52 -0
  100. package/src/data/sqlite-cache.test.ts +103 -0
  101. package/src/data/sqlite-database.ts +62 -0
  102. package/src/data/sqlite-json.ts +19 -0
  103. package/src/data/ticker-repository.ts +45 -0
  104. package/src/data/ticker-store.ts +46 -0
  105. package/src/index.tsx +67 -0
  106. package/src/market-data/coordinator.test.ts +369 -0
  107. package/src/market-data/coordinator.ts +580 -0
  108. package/src/market-data/hooks.tsx +234 -0
  109. package/src/market-data/query-store.ts +28 -0
  110. package/src/market-data/request-types.test.ts +64 -0
  111. package/src/market-data/request-types.ts +70 -0
  112. package/src/market-data/result-types.ts +56 -0
  113. package/src/market-data/selectors.ts +166 -0
  114. package/src/market-data/trace.ts +7 -0
  115. package/src/pane-settings.test.ts +71 -0
  116. package/src/pane-settings.ts +73 -0
  117. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
  118. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  119. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  120. package/src/plugins/builtin/ai/index.tsx +86 -0
  121. package/src/plugins/builtin/ai/providers.ts +99 -0
  122. package/src/plugins/builtin/ai/runner.ts +82 -0
  123. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  124. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  125. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  126. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  127. package/src/plugins/builtin/ai/settings.ts +79 -0
  128. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  129. package/src/plugins/builtin/ai/utils.ts +71 -0
  130. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  131. package/src/plugins/builtin/ask-ai.tsx +9 -0
  132. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  133. package/src/plugins/builtin/chat-controller.ts +340 -0
  134. package/src/plugins/builtin/chat.test.tsx +451 -0
  135. package/src/plugins/builtin/chat.tsx +620 -0
  136. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  137. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  138. package/src/plugins/builtin/data-providers.ts +16 -0
  139. package/src/plugins/builtin/debug.tsx +351 -0
  140. package/src/plugins/builtin/help.test.tsx +110 -0
  141. package/src/plugins/builtin/help.tsx +281 -0
  142. package/src/plugins/builtin/layout-manager.tsx +264 -0
  143. package/src/plugins/builtin/manual-entry.tsx +25 -0
  144. package/src/plugins/builtin/news.tsx +143 -0
  145. package/src/plugins/builtin/notes-files.ts +58 -0
  146. package/src/plugins/builtin/notes.tsx +396 -0
  147. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  148. package/src/plugins/builtin/options-availability.ts +158 -0
  149. package/src/plugins/builtin/options.tsx +364 -0
  150. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  151. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  152. package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
  153. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  154. package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
  155. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  156. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  157. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  158. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  159. package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
  160. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  161. package/src/plugins/builtin/sec.tsx +250 -0
  162. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  163. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  164. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  165. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  166. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  167. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  168. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  169. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  170. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  171. package/src/plugins/builtin/yahoo.ts +18 -0
  172. package/src/plugins/event-bus.ts +37 -0
  173. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  174. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  175. package/src/plugins/ibkr/account-cache.ts +137 -0
  176. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  177. package/src/plugins/ibkr/config.test.ts +68 -0
  178. package/src/plugins/ibkr/config.ts +262 -0
  179. package/src/plugins/ibkr/dialogs.tsx +83 -0
  180. package/src/plugins/ibkr/flex.test.ts +107 -0
  181. package/src/plugins/ibkr/flex.ts +267 -0
  182. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  183. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  184. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  185. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  186. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  187. package/src/plugins/ibkr/index.test.tsx +183 -0
  188. package/src/plugins/ibkr/index.tsx +153 -0
  189. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  190. package/src/plugins/ibkr/instance-selection.ts +36 -0
  191. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  192. package/src/plugins/ibkr/price-normalization.ts +59 -0
  193. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  194. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  195. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  196. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  197. package/src/plugins/ibkr/trade-utils.ts +149 -0
  198. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  199. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  200. package/src/plugins/ibkr/trading-state.ts +339 -0
  201. package/src/plugins/loader.ts +71 -0
  202. package/src/plugins/pane-manager.test.ts +103 -0
  203. package/src/plugins/pane-manager.ts +974 -0
  204. package/src/plugins/plugin-persistence.ts +85 -0
  205. package/src/plugins/plugin-runtime.test.tsx +185 -0
  206. package/src/plugins/plugin-runtime.tsx +175 -0
  207. package/src/plugins/prediction-markets/cache.ts +114 -0
  208. package/src/plugins/prediction-markets/categories.ts +223 -0
  209. package/src/plugins/prediction-markets/chart.tsx +512 -0
  210. package/src/plugins/prediction-markets/columns.ts +133 -0
  211. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  212. package/src/plugins/prediction-markets/controller.ts +725 -0
  213. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  214. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  215. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  216. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  217. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  218. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  219. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  220. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  221. package/src/plugins/prediction-markets/index.tsx +102 -0
  222. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  223. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  224. package/src/plugins/prediction-markets/launch.ts +240 -0
  225. package/src/plugins/prediction-markets/metrics.ts +311 -0
  226. package/src/plugins/prediction-markets/navigation.ts +90 -0
  227. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  228. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  229. package/src/plugins/prediction-markets/pane.tsx +294 -0
  230. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  231. package/src/plugins/prediction-markets/rows.ts +229 -0
  232. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  233. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  234. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  235. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  236. package/src/plugins/prediction-markets/settings.ts +164 -0
  237. package/src/plugins/prediction-markets/status.ts +24 -0
  238. package/src/plugins/prediction-markets/table.tsx +84 -0
  239. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  240. package/src/plugins/prediction-markets/types.ts +186 -0
  241. package/src/plugins/registry.ts +633 -0
  242. package/src/sources/exchange-rates.ts +18 -0
  243. package/src/sources/gloomberb-cloud.test.ts +199 -0
  244. package/src/sources/gloomberb-cloud.ts +326 -0
  245. package/src/sources/provider-errors.ts +19 -0
  246. package/src/sources/provider-router.test.ts +1476 -0
  247. package/src/sources/provider-router.ts +1405 -0
  248. package/src/sources/sec-edgar.test.ts +286 -0
  249. package/src/sources/sec-edgar.ts +526 -0
  250. package/src/sources/yahoo-finance.test.ts +14 -0
  251. package/src/sources/yahoo-finance.ts +1017 -0
  252. package/src/state/app-activity.test.ts +41 -0
  253. package/src/state/app-activity.ts +106 -0
  254. package/src/state/app-bootstrap.test.ts +471 -0
  255. package/src/state/app-bootstrap.ts +308 -0
  256. package/src/state/app-context.test.ts +406 -0
  257. package/src/state/app-context.test.tsx +132 -0
  258. package/src/state/app-context.tsx +969 -0
  259. package/src/state/selectors.ts +67 -0
  260. package/src/state/session-persistence.test.ts +128 -0
  261. package/src/state/session-persistence.ts +161 -0
  262. package/src/state/ticker-refresh-queue.test.ts +46 -0
  263. package/src/state/ticker-refresh-queue.ts +40 -0
  264. package/src/state/use-inline-tickers.ts +174 -0
  265. package/src/state/use-quote-streaming.test.ts +37 -0
  266. package/src/state/use-quote-streaming.test.tsx +70 -0
  267. package/src/state/use-quote-streaming.ts +80 -0
  268. package/src/state/use-session-snapshot.ts +84 -0
  269. package/src/test-support/data-provider.ts +22 -0
  270. package/src/theme/color-utils.ts +67 -0
  271. package/src/theme/colors.ts +128 -0
  272. package/src/theme/themes.test.ts +45 -0
  273. package/src/theme/themes.ts +491 -0
  274. package/src/types/broker.ts +105 -0
  275. package/src/types/config.ts +434 -0
  276. package/src/types/data-provider.ts +81 -0
  277. package/src/types/financials.ts +142 -0
  278. package/src/types/instrument.ts +29 -0
  279. package/src/types/persistence.ts +21 -0
  280. package/src/types/plugin.ts +354 -0
  281. package/src/types/ticker.ts +58 -0
  282. package/src/types/trading.ts +85 -0
  283. package/src/updater.test.ts +162 -0
  284. package/src/updater.ts +247 -0
  285. package/src/utils/api-client.ts +666 -0
  286. package/src/utils/ascii-chart.ts +172 -0
  287. package/src/utils/broker-instances.ts +47 -0
  288. package/src/utils/cli-output.ts +83 -0
  289. package/src/utils/currency-units.test.ts +49 -0
  290. package/src/utils/currency-units.ts +82 -0
  291. package/src/utils/debug-log.ts +133 -0
  292. package/src/utils/exchange-rate-map.ts +8 -0
  293. package/src/utils/format.ts +158 -0
  294. package/src/utils/fuzzy-search.ts +34 -0
  295. package/src/utils/market-status.ts +95 -0
  296. package/src/utils/options.ts +86 -0
  297. package/src/utils/price-history.ts +39 -0
  298. package/src/utils/quote-time.test.ts +22 -0
  299. package/src/utils/quote-time.ts +50 -0
  300. package/src/utils/sec.test.ts +55 -0
  301. package/src/utils/sec.ts +45 -0
  302. package/src/utils/ticker-list.test.ts +22 -0
  303. package/src/utils/ticker-list.ts +35 -0
  304. package/src/utils/ticker-metadata.ts +51 -0
  305. package/src/utils/ticker-search.test.ts +227 -0
  306. package/src/utils/ticker-search.ts +764 -0
  307. package/src/utils/ticker-tokenizer.test.ts +48 -0
  308. package/src/utils/ticker-tokenizer.ts +72 -0
  309. package/src/version.ts +1 -0
  310. package/install.mjs +0 -68
@@ -0,0 +1,1476 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { AppPersistence } from "../data/app-persistence";
6
+ import { ProviderRouter } from "./provider-router";
7
+ import type { BrokerAdapter } from "../types/broker";
8
+ import type { DataProvider, QuoteSubscriptionTarget } from "../types/data-provider";
9
+ import { cloneLayout, CURRENT_CONFIG_VERSION, DEFAULT_LAYOUT } from "../types/config";
10
+
11
+ const originalConsoleError = console.error;
12
+ const tempPaths: string[] = [];
13
+
14
+ function createTempDbPath(name: string): string {
15
+ const path = join(tmpdir(), `gloomberb-provider-router-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
16
+ tempPaths.push(path);
17
+ return path;
18
+ }
19
+
20
+ afterEach(() => {
21
+ console.error = originalConsoleError;
22
+ for (const path of tempPaths.splice(0)) {
23
+ if (existsSync(path)) rmSync(path, { force: true });
24
+ }
25
+ });
26
+
27
+ const fallbackProvider: DataProvider = {
28
+ id: "fallback",
29
+ name: "Fallback",
30
+ async getTickerFinancials() {
31
+ return { annualStatements: [], quarterlyStatements: [], priceHistory: [] };
32
+ },
33
+ async getQuote() {
34
+ return {
35
+ symbol: "AAPL",
36
+ price: 100,
37
+ currency: "USD",
38
+ change: 0,
39
+ changePercent: 0,
40
+ lastUpdated: Date.now(),
41
+ };
42
+ },
43
+ async getExchangeRate() {
44
+ return 1;
45
+ },
46
+ async search() {
47
+ return [];
48
+ },
49
+ async getNews() {
50
+ return [];
51
+ },
52
+ async getArticleSummary() {
53
+ return null;
54
+ },
55
+ async getPriceHistory() {
56
+ return [];
57
+ },
58
+ };
59
+
60
+ describe("ProviderRouter", () => {
61
+ test("prefers broker quotes over fallback quotes", async () => {
62
+ const router = new ProviderRouter(fallbackProvider);
63
+ const broker: BrokerAdapter = {
64
+ id: "ibkr",
65
+ name: "IBKR",
66
+ configSchema: [],
67
+ async validate() {
68
+ return true;
69
+ },
70
+ async importPositions() {
71
+ return [];
72
+ },
73
+ async getQuote() {
74
+ return {
75
+ symbol: "AAPL",
76
+ price: 123.45,
77
+ currency: "USD",
78
+ change: 1,
79
+ changePercent: 0.8,
80
+ lastUpdated: Date.now(),
81
+ };
82
+ },
83
+ };
84
+
85
+ router.attachRegistry({
86
+ brokers: new Map([["ibkr", broker]]),
87
+ dataProviders: new Map(),
88
+ } as any);
89
+ router.setConfigAccessor(() => ({
90
+ dataDir: "",
91
+ configVersion: CURRENT_CONFIG_VERSION,
92
+ baseCurrency: "USD",
93
+ refreshIntervalMinutes: 30,
94
+ portfolios: [],
95
+ watchlists: [],
96
+ columns: [],
97
+ layout: cloneLayout(DEFAULT_LAYOUT),
98
+ layouts: [{ name: "Default", layout: cloneLayout(DEFAULT_LAYOUT) }],
99
+ activeLayoutIndex: 0,
100
+ brokerInstances: [{
101
+ id: "ibkr-work",
102
+ brokerType: "ibkr",
103
+ label: "Work",
104
+ connectionMode: "gateway",
105
+ config: { connectionMode: "gateway", gateway: { host: "127.0.0.1", port: 4002, clientId: 1 } },
106
+ enabled: true,
107
+ }],
108
+ plugins: [],
109
+ disabledPlugins: [],
110
+ theme: "amber",
111
+ chartPreferences: {
112
+ defaultRenderMode: "area",
113
+ renderer: "auto",
114
+ },
115
+ recentTickers: [],
116
+ }));
117
+
118
+ const quote = await router.getQuote("AAPL", "NASDAQ", { brokerId: "ibkr", brokerInstanceId: "ibkr-work" });
119
+ expect(quote.price).toBe(123.45);
120
+ });
121
+
122
+ test("prefers the requested broker instance in search results", async () => {
123
+ const router = new ProviderRouter(fallbackProvider);
124
+ const broker: BrokerAdapter = {
125
+ id: "ibkr",
126
+ name: "IBKR",
127
+ configSchema: [],
128
+ async validate() {
129
+ return true;
130
+ },
131
+ async importPositions() {
132
+ return [];
133
+ },
134
+ async searchInstruments(_query, instance) {
135
+ return [{
136
+ providerId: "ibkr",
137
+ symbol: instance.label === "Work" ? "AAPL" : "MSFT",
138
+ name: `${instance.label} contract`,
139
+ exchange: "NASDAQ",
140
+ type: "STK",
141
+ brokerContract: {
142
+ brokerId: "ibkr",
143
+ symbol: instance.label === "Work" ? "AAPL" : "MSFT",
144
+ exchange: "SMART",
145
+ },
146
+ }];
147
+ },
148
+ };
149
+
150
+ router.attachRegistry({
151
+ brokers: new Map([["ibkr", broker]]),
152
+ dataProviders: new Map(),
153
+ } as any);
154
+ router.setConfigAccessor(() => ({
155
+ dataDir: "",
156
+ configVersion: CURRENT_CONFIG_VERSION,
157
+ baseCurrency: "USD",
158
+ refreshIntervalMinutes: 30,
159
+ portfolios: [],
160
+ watchlists: [],
161
+ columns: [],
162
+ layout: cloneLayout(DEFAULT_LAYOUT),
163
+ layouts: [{ name: "Default", layout: cloneLayout(DEFAULT_LAYOUT) }],
164
+ activeLayoutIndex: 0,
165
+ brokerInstances: [
166
+ {
167
+ id: "ibkr-work",
168
+ brokerType: "ibkr",
169
+ label: "Work",
170
+ connectionMode: "gateway",
171
+ config: { connectionMode: "gateway", gateway: { host: "127.0.0.1", port: 4002, clientId: 1 } },
172
+ enabled: true,
173
+ },
174
+ {
175
+ id: "ibkr-personal",
176
+ brokerType: "ibkr",
177
+ label: "Personal",
178
+ connectionMode: "gateway",
179
+ config: { connectionMode: "gateway", gateway: { host: "127.0.0.1", port: 4003, clientId: 2 } },
180
+ enabled: true,
181
+ },
182
+ ],
183
+ plugins: [],
184
+ disabledPlugins: [],
185
+ theme: "amber",
186
+ chartPreferences: {
187
+ defaultRenderMode: "area",
188
+ renderer: "auto",
189
+ },
190
+ recentTickers: [],
191
+ }));
192
+
193
+ const results = await router.search("AAPL", { preferBroker: true, brokerInstanceId: "ibkr-work" });
194
+ expect(results[0]?.brokerInstanceId).toBe("ibkr-work");
195
+ expect(results[0]?.brokerLabel).toBe("Work");
196
+ expect(results[0]?.brokerContract?.brokerInstanceId).toBe("ibkr-work");
197
+ });
198
+
199
+ test("keeps the richer search result when providers return the same instrument", async () => {
200
+ const broker: BrokerAdapter = {
201
+ id: "ibkr",
202
+ name: "IBKR",
203
+ configSchema: [],
204
+ async validate() {
205
+ return true;
206
+ },
207
+ async importPositions() {
208
+ return [];
209
+ },
210
+ async searchInstruments() {
211
+ return [{
212
+ providerId: "ibkr",
213
+ symbol: "AAPL",
214
+ name: "Apple Inc",
215
+ exchange: "NASDAQ",
216
+ type: "STK",
217
+ brokerLabel: "Work",
218
+ brokerContract: {
219
+ brokerId: "ibkr",
220
+ brokerInstanceId: "ibkr-work",
221
+ symbol: "AAPL",
222
+ localSymbol: "AAPL",
223
+ exchange: "SMART",
224
+ },
225
+ }];
226
+ },
227
+ };
228
+ const provider: DataProvider = {
229
+ ...fallbackProvider,
230
+ id: "yahoo",
231
+ name: "Yahoo",
232
+ async search() {
233
+ return [{
234
+ providerId: "yahoo",
235
+ symbol: "AAPL",
236
+ name: "Apple",
237
+ exchange: "NASDAQ",
238
+ type: "STK",
239
+ }];
240
+ },
241
+ };
242
+ const router = new ProviderRouter(fallbackProvider, [provider]);
243
+
244
+ router.attachRegistry({
245
+ brokers: new Map([["ibkr", broker]]),
246
+ dataProviders: new Map(),
247
+ } as any);
248
+ router.setConfigAccessor(() => ({
249
+ dataDir: "",
250
+ configVersion: CURRENT_CONFIG_VERSION,
251
+ baseCurrency: "USD",
252
+ refreshIntervalMinutes: 30,
253
+ portfolios: [],
254
+ watchlists: [],
255
+ columns: [],
256
+ layout: cloneLayout(DEFAULT_LAYOUT),
257
+ layouts: [{ name: "Default", layout: cloneLayout(DEFAULT_LAYOUT) }],
258
+ activeLayoutIndex: 0,
259
+ brokerInstances: [{
260
+ id: "ibkr-work",
261
+ brokerType: "ibkr",
262
+ label: "Work",
263
+ connectionMode: "gateway",
264
+ config: { connectionMode: "gateway", gateway: { host: "127.0.0.1", port: 4002, clientId: 1 } },
265
+ enabled: true,
266
+ }],
267
+ plugins: [],
268
+ disabledPlugins: [],
269
+ theme: "amber",
270
+ chartPreferences: {
271
+ defaultRenderMode: "area",
272
+ renderer: "auto",
273
+ },
274
+ recentTickers: [],
275
+ }));
276
+
277
+ const results = await router.search("AAPL", { preferBroker: true, brokerInstanceId: "ibkr-work" });
278
+ expect(results).toHaveLength(1);
279
+ expect(results[0]?.providerId).toBe("ibkr");
280
+ expect(results[0]?.brokerContract?.localSymbol).toBe("AAPL");
281
+ });
282
+
283
+ test("ignores disabled plugin providers when the registry filters them out", async () => {
284
+ const disabledProvider: DataProvider = {
285
+ id: "gloomberb-cloud",
286
+ name: "Cloud",
287
+ priority: 10,
288
+ async getTickerFinancials() {
289
+ throw new Error("disabled provider should not be used");
290
+ },
291
+ async getQuote() {
292
+ throw new Error("disabled provider should not be used");
293
+ },
294
+ async getExchangeRate() {
295
+ throw new Error("disabled provider should not be used");
296
+ },
297
+ async search() {
298
+ throw new Error("disabled provider should not be used");
299
+ },
300
+ async getNews() {
301
+ throw new Error("disabled provider should not be used");
302
+ },
303
+ async getArticleSummary() {
304
+ throw new Error("disabled provider should not be used");
305
+ },
306
+ async getPriceHistory() {
307
+ throw new Error("disabled provider should not be used");
308
+ },
309
+ };
310
+
311
+ const router = new ProviderRouter(fallbackProvider);
312
+ router.attachRegistry({
313
+ brokers: new Map(),
314
+ dataProviders: new Map([["gloomberb-cloud", disabledProvider]]),
315
+ getEnabledDataProviders() {
316
+ return [];
317
+ },
318
+ } as any);
319
+
320
+ const quote = await router.getQuote("AAPL", "NASDAQ");
321
+ expect(quote.price).toBe(100);
322
+ expect(quote.providerId).toBeUndefined();
323
+ });
324
+
325
+ test("prefers provider quote streams when one is available", () => {
326
+ const brokerTargets: QuoteSubscriptionTarget[] = [];
327
+ const providerTargets: QuoteSubscriptionTarget[] = [];
328
+ let brokerUnsubscribed = 0;
329
+ let providerUnsubscribed = 0;
330
+
331
+ const broker: BrokerAdapter = {
332
+ id: "ibkr",
333
+ name: "IBKR",
334
+ configSchema: [],
335
+ async validate() {
336
+ return true;
337
+ },
338
+ async importPositions() {
339
+ return [];
340
+ },
341
+ subscribeQuotes(_instance, targets, onQuote) {
342
+ brokerTargets.push(...targets);
343
+ onQuote(targets[0]!, {
344
+ symbol: "AAPL",
345
+ price: 123.45,
346
+ currency: "USD",
347
+ change: 1,
348
+ changePercent: 0.8,
349
+ lastUpdated: Date.now(),
350
+ });
351
+ return () => {
352
+ brokerUnsubscribed += 1;
353
+ };
354
+ },
355
+ };
356
+ const streamingProvider: DataProvider = {
357
+ ...fallbackProvider,
358
+ id: "cloud",
359
+ name: "Cloud",
360
+ subscribeQuotes(targets, onQuote) {
361
+ providerTargets.push(...targets);
362
+ onQuote(targets[0]!, {
363
+ symbol: "MSFT",
364
+ price: 456.78,
365
+ currency: "USD",
366
+ change: 2,
367
+ changePercent: 0.4,
368
+ lastUpdated: Date.now(),
369
+ });
370
+ return () => {
371
+ providerUnsubscribed += 1;
372
+ };
373
+ },
374
+ };
375
+
376
+ const router = new ProviderRouter(fallbackProvider, [streamingProvider]);
377
+ router.attachRegistry({
378
+ brokers: new Map([["ibkr", broker]]),
379
+ dataProviders: new Map(),
380
+ } as any);
381
+ router.setConfigAccessor(() => ({
382
+ dataDir: "",
383
+ configVersion: CURRENT_CONFIG_VERSION,
384
+ baseCurrency: "USD",
385
+ refreshIntervalMinutes: 30,
386
+ portfolios: [],
387
+ watchlists: [],
388
+ columns: [],
389
+ layout: cloneLayout(DEFAULT_LAYOUT),
390
+ layouts: [{ name: "Default", layout: cloneLayout(DEFAULT_LAYOUT) }],
391
+ activeLayoutIndex: 0,
392
+ brokerInstances: [{
393
+ id: "ibkr-work",
394
+ brokerType: "ibkr",
395
+ label: "Work",
396
+ connectionMode: "gateway",
397
+ config: {},
398
+ enabled: true,
399
+ }],
400
+ plugins: [],
401
+ disabledPlugins: [],
402
+ theme: "amber",
403
+ chartPreferences: {
404
+ defaultRenderMode: "area",
405
+ renderer: "auto",
406
+ },
407
+ recentTickers: [],
408
+ }));
409
+
410
+ const seenSymbols: string[] = [];
411
+ const unsubscribe = router.subscribeQuotes([
412
+ {
413
+ symbol: "AAPL",
414
+ exchange: "NASDAQ",
415
+ context: {
416
+ brokerId: "ibkr",
417
+ brokerInstanceId: "ibkr-work",
418
+ },
419
+ },
420
+ {
421
+ symbol: "MSFT",
422
+ exchange: "NASDAQ",
423
+ },
424
+ ], (target) => {
425
+ seenSymbols.push(target.symbol);
426
+ });
427
+
428
+ expect(brokerTargets).toEqual([]);
429
+ expect(providerTargets).toEqual([
430
+ {
431
+ symbol: "AAPL",
432
+ exchange: "NASDAQ",
433
+ context: {
434
+ brokerId: "ibkr",
435
+ brokerInstanceId: "ibkr-work",
436
+ },
437
+ },
438
+ {
439
+ symbol: "MSFT",
440
+ exchange: "NASDAQ",
441
+ },
442
+ ]);
443
+ expect(seenSymbols).toEqual(["AAPL"]);
444
+
445
+ unsubscribe();
446
+ expect(brokerUnsubscribed).toBe(0);
447
+ expect(providerUnsubscribed).toBe(1);
448
+ });
449
+
450
+ test("can force broker-linked targets onto provider streaming", () => {
451
+ const brokerTargets: QuoteSubscriptionTarget[] = [];
452
+ const providerTargets: QuoteSubscriptionTarget[] = [];
453
+
454
+ const broker: BrokerAdapter = {
455
+ id: "ibkr",
456
+ name: "IBKR",
457
+ configSchema: [],
458
+ async validate() {
459
+ return true;
460
+ },
461
+ async importPositions() {
462
+ return [];
463
+ },
464
+ subscribeQuotes(_instance, targets) {
465
+ brokerTargets.push(...targets);
466
+ return () => {};
467
+ },
468
+ };
469
+ const streamingProvider: DataProvider = {
470
+ ...fallbackProvider,
471
+ id: "cloud",
472
+ name: "Cloud",
473
+ subscribeQuotes(targets) {
474
+ providerTargets.push(...targets);
475
+ return () => {};
476
+ },
477
+ };
478
+
479
+ const router = new ProviderRouter(fallbackProvider, [streamingProvider]);
480
+ router.attachRegistry({
481
+ brokers: new Map([["ibkr", broker]]),
482
+ dataProviders: new Map(),
483
+ } as any);
484
+ router.setConfigAccessor(() => ({
485
+ dataDir: "",
486
+ configVersion: CURRENT_CONFIG_VERSION,
487
+ baseCurrency: "USD",
488
+ refreshIntervalMinutes: 30,
489
+ portfolios: [],
490
+ watchlists: [],
491
+ columns: [],
492
+ layout: cloneLayout(DEFAULT_LAYOUT),
493
+ layouts: [{ name: "Default", layout: cloneLayout(DEFAULT_LAYOUT) }],
494
+ activeLayoutIndex: 0,
495
+ brokerInstances: [{
496
+ id: "ibkr-work",
497
+ brokerType: "ibkr",
498
+ label: "Work",
499
+ connectionMode: "gateway",
500
+ config: {},
501
+ enabled: true,
502
+ }],
503
+ plugins: [],
504
+ disabledPlugins: [],
505
+ theme: "amber",
506
+ chartPreferences: {
507
+ defaultRenderMode: "area",
508
+ renderer: "auto",
509
+ },
510
+ recentTickers: [],
511
+ }));
512
+
513
+ const unsubscribe = router.subscribeQuotes([{
514
+ symbol: "AAPL",
515
+ exchange: "NASDAQ",
516
+ route: "provider",
517
+ context: {
518
+ brokerId: "ibkr",
519
+ brokerInstanceId: "ibkr-work",
520
+ },
521
+ }], () => {});
522
+
523
+ expect(brokerTargets).toEqual([]);
524
+ expect(providerTargets).toEqual([{
525
+ symbol: "AAPL",
526
+ exchange: "NASDAQ",
527
+ route: "provider",
528
+ context: {
529
+ brokerId: "ibkr",
530
+ brokerInstanceId: "ibkr-work",
531
+ },
532
+ }]);
533
+
534
+ unsubscribe();
535
+ });
536
+
537
+ test("merges cached broker financials with cached fallback fundamentals", async () => {
538
+ const dbPath = createTempDbPath("cache-merge");
539
+ const persistence = new AppPersistence(dbPath);
540
+ const providerCalls = { broker: 0, fallback: 0 };
541
+ const router = new ProviderRouter({
542
+ ...fallbackProvider,
543
+ async getTickerFinancials() {
544
+ providerCalls.fallback += 1;
545
+ return {
546
+ annualStatements: [{ date: "2025-12-31", totalRevenue: 1000 }],
547
+ quarterlyStatements: [{ date: "2025-12-31", totalRevenue: 250 }],
548
+ priceHistory: [],
549
+ fundamentals: { revenue: 1000, netIncome: 200 },
550
+ };
551
+ },
552
+ }, [], persistence.resources);
553
+ const broker: BrokerAdapter = {
554
+ id: "ibkr",
555
+ name: "IBKR",
556
+ configSchema: [],
557
+ async validate() {
558
+ return true;
559
+ },
560
+ async importPositions() {
561
+ return [];
562
+ },
563
+ async getTickerFinancials() {
564
+ providerCalls.broker += 1;
565
+ return {
566
+ annualStatements: [],
567
+ quarterlyStatements: [],
568
+ priceHistory: [],
569
+ quote: {
570
+ symbol: "AAPL",
571
+ price: 125,
572
+ currency: "USD",
573
+ change: 2,
574
+ changePercent: 1.6,
575
+ lastUpdated: Date.now(),
576
+ },
577
+ fundamentals: {},
578
+ };
579
+ },
580
+ };
581
+
582
+ router.attachRegistry({
583
+ brokers: new Map([["ibkr", broker]]),
584
+ dataProviders: new Map(),
585
+ } as any);
586
+ router.setConfigAccessor(() => ({
587
+ dataDir: "",
588
+ configVersion: CURRENT_CONFIG_VERSION,
589
+ baseCurrency: "USD",
590
+ refreshIntervalMinutes: 30,
591
+ portfolios: [],
592
+ watchlists: [],
593
+ columns: [],
594
+ layout: cloneLayout(DEFAULT_LAYOUT),
595
+ layouts: [{ name: "Default", layout: cloneLayout(DEFAULT_LAYOUT) }],
596
+ activeLayoutIndex: 0,
597
+ brokerInstances: [{
598
+ id: "ibkr-work",
599
+ brokerType: "ibkr",
600
+ label: "Work",
601
+ connectionMode: "gateway",
602
+ config: {},
603
+ enabled: true,
604
+ }],
605
+ plugins: [],
606
+ disabledPlugins: [],
607
+ theme: "amber",
608
+ chartPreferences: {
609
+ defaultRenderMode: "area",
610
+ renderer: "auto",
611
+ },
612
+ recentTickers: [],
613
+ }));
614
+
615
+ const merged = await router.getTickerFinancials("AAPL", "NASDAQ", {
616
+ brokerId: "ibkr",
617
+ brokerInstanceId: "ibkr-work",
618
+ });
619
+ expect(merged.quote?.price).toBe(125);
620
+ expect(merged.fundamentals?.revenue).toBe(1000);
621
+ expect(providerCalls.broker).toBe(1);
622
+ expect(providerCalls.fallback).toBe(1);
623
+
624
+ const cached = router.getCachedFinancialsForTargets([{
625
+ symbol: "AAPL",
626
+ exchange: "NASDAQ",
627
+ brokerId: "ibkr",
628
+ brokerInstanceId: "ibkr-work",
629
+ }], { allowExpired: true });
630
+ expect(cached.get("AAPL")?.quote?.price).toBe(125);
631
+ expect(cached.get("AAPL")?.fundamentals?.revenue).toBe(1000);
632
+ persistence.close();
633
+ });
634
+
635
+ test("prefers fallback price data when broker and provider differ by a 100x unit mismatch", async () => {
636
+ const router = new ProviderRouter({
637
+ ...fallbackProvider,
638
+ async getTickerFinancials() {
639
+ return {
640
+ annualStatements: [],
641
+ quarterlyStatements: [],
642
+ priceHistory: [{ date: new Date("2026-03-28T00:00:00Z"), close: 0.245 }],
643
+ quote: {
644
+ symbol: "IQE.L",
645
+ price: 0.245,
646
+ currency: "GBP",
647
+ change: -0.021,
648
+ changePercent: -7.89,
649
+ lastUpdated: Date.now(),
650
+ },
651
+ fundamentals: { revenue: 1000 },
652
+ };
653
+ },
654
+ });
655
+ const broker: BrokerAdapter = {
656
+ id: "ibkr",
657
+ name: "IBKR",
658
+ configSchema: [],
659
+ async validate() {
660
+ return true;
661
+ },
662
+ async importPositions() {
663
+ return [];
664
+ },
665
+ async getTickerFinancials() {
666
+ return {
667
+ annualStatements: [],
668
+ quarterlyStatements: [],
669
+ priceHistory: [{ date: new Date("2026-03-28T00:00:00Z"), close: 24.5 }],
670
+ quote: {
671
+ symbol: "IQE",
672
+ price: 24.5,
673
+ currency: "GBP",
674
+ change: -2.1,
675
+ changePercent: -7.89,
676
+ lastUpdated: Date.now(),
677
+ },
678
+ fundamentals: { netIncome: 200 },
679
+ };
680
+ },
681
+ };
682
+
683
+ router.attachRegistry({
684
+ brokers: new Map([["ibkr", broker]]),
685
+ dataProviders: new Map(),
686
+ } as any);
687
+ router.setConfigAccessor(() => ({
688
+ dataDir: "",
689
+ configVersion: CURRENT_CONFIG_VERSION,
690
+ baseCurrency: "USD",
691
+ refreshIntervalMinutes: 30,
692
+ portfolios: [],
693
+ watchlists: [],
694
+ columns: [],
695
+ layout: cloneLayout(DEFAULT_LAYOUT),
696
+ layouts: [{ name: "Default", layout: cloneLayout(DEFAULT_LAYOUT) }],
697
+ activeLayoutIndex: 0,
698
+ brokerInstances: [{
699
+ id: "ibkr-work",
700
+ brokerType: "ibkr",
701
+ label: "Work",
702
+ connectionMode: "gateway",
703
+ config: {},
704
+ enabled: true,
705
+ }],
706
+ plugins: [],
707
+ disabledPlugins: [],
708
+ theme: "amber",
709
+ chartPreferences: {
710
+ defaultRenderMode: "area",
711
+ renderer: "auto",
712
+ },
713
+ recentTickers: [],
714
+ }));
715
+
716
+ const merged = await router.getTickerFinancials("IQE", "LSE", {
717
+ brokerId: "ibkr",
718
+ brokerInstanceId: "ibkr-work",
719
+ });
720
+
721
+ expect(merged.quote?.price).toBe(0.245);
722
+ expect(merged.priceHistory[0]?.close).toBe(0.245);
723
+ expect(merged.fundamentals?.netIncome).toBe(200);
724
+ });
725
+
726
+ test("preserves fallback profile data when broker already has fundamentals", async () => {
727
+ const router = new ProviderRouter({
728
+ ...fallbackProvider,
729
+ async getTickerFinancials() {
730
+ return {
731
+ annualStatements: [],
732
+ quarterlyStatements: [],
733
+ priceHistory: [],
734
+ profile: {
735
+ description: "Builds hardware and software.",
736
+ sector: "Technology",
737
+ industry: "Consumer Electronics",
738
+ },
739
+ };
740
+ },
741
+ });
742
+ const broker: BrokerAdapter = {
743
+ id: "ibkr",
744
+ name: "IBKR",
745
+ configSchema: [],
746
+ async validate() {
747
+ return true;
748
+ },
749
+ async importPositions() {
750
+ return [];
751
+ },
752
+ async getTickerFinancials() {
753
+ return {
754
+ annualStatements: [],
755
+ quarterlyStatements: [],
756
+ priceHistory: [],
757
+ fundamentals: { revenue: 1000, netIncome: 200 },
758
+ };
759
+ },
760
+ };
761
+
762
+ router.attachRegistry({
763
+ brokers: new Map([["ibkr", broker]]),
764
+ dataProviders: new Map(),
765
+ } as any);
766
+ router.setConfigAccessor(() => ({
767
+ dataDir: "",
768
+ configVersion: CURRENT_CONFIG_VERSION,
769
+ baseCurrency: "USD",
770
+ refreshIntervalMinutes: 30,
771
+ portfolios: [],
772
+ watchlists: [],
773
+ columns: [],
774
+ layout: cloneLayout(DEFAULT_LAYOUT),
775
+ layouts: [{ name: "Default", layout: cloneLayout(DEFAULT_LAYOUT) }],
776
+ activeLayoutIndex: 0,
777
+ brokerInstances: [{
778
+ id: "ibkr-work",
779
+ brokerType: "ibkr",
780
+ label: "Work",
781
+ connectionMode: "gateway",
782
+ config: {},
783
+ enabled: true,
784
+ }],
785
+ plugins: [],
786
+ disabledPlugins: [],
787
+ theme: "amber",
788
+ chartPreferences: {
789
+ defaultRenderMode: "area",
790
+ renderer: "auto",
791
+ },
792
+ recentTickers: [],
793
+ }));
794
+
795
+ const merged = await router.getTickerFinancials("AAPL", "NASDAQ", {
796
+ brokerId: "ibkr",
797
+ brokerInstanceId: "ibkr-work",
798
+ });
799
+ expect(merged.fundamentals?.revenue).toBe(1000);
800
+ expect(merged.profile?.description).toBe("Builds hardware and software.");
801
+ expect(merged.profile?.sector).toBe("Technology");
802
+ });
803
+
804
+ test("backfills quote and fundamental fields from fallback providers", async () => {
805
+ const cloudProvider: DataProvider = {
806
+ ...fallbackProvider,
807
+ id: "cloud",
808
+ name: "Cloud",
809
+ priority: 100,
810
+ async getTickerFinancials() {
811
+ return {
812
+ annualStatements: [],
813
+ quarterlyStatements: [],
814
+ priceHistory: [],
815
+ quote: {
816
+ symbol: "AAPL",
817
+ price: 125,
818
+ currency: "USD",
819
+ change: 2,
820
+ changePercent: 1.6,
821
+ lastUpdated: Date.now(),
822
+ },
823
+ fundamentals: {
824
+ trailingPE: 25,
825
+ },
826
+ };
827
+ },
828
+ };
829
+ const yahooProvider: DataProvider = {
830
+ ...fallbackProvider,
831
+ id: "yahoo",
832
+ name: "Yahoo",
833
+ priority: 1000,
834
+ async getTickerFinancials() {
835
+ return {
836
+ annualStatements: [],
837
+ quarterlyStatements: [],
838
+ priceHistory: [{ date: new Date("2026-03-28T00:00:00Z"), close: 124 }],
839
+ quote: {
840
+ symbol: "AAPL",
841
+ price: 124,
842
+ currency: "USD",
843
+ change: 1,
844
+ changePercent: 0.8,
845
+ marketCap: 2_000_000_000,
846
+ lastUpdated: Date.now(),
847
+ },
848
+ fundamentals: {
849
+ forwardPE: 22,
850
+ },
851
+ };
852
+ },
853
+ };
854
+
855
+ const router = new ProviderRouter(yahooProvider, [cloudProvider]);
856
+ const merged = await router.getTickerFinancials("AAPL", "NASDAQ");
857
+
858
+ expect(merged.quote?.price).toBe(125);
859
+ expect(merged.quote?.marketCap).toBe(2_000_000_000);
860
+ expect(merged.fundamentals?.trailingPE).toBe(25);
861
+ expect(merged.fundamentals?.forwardPE).toBe(22);
862
+ expect(merged.priceHistory[0]?.close).toBe(124);
863
+ });
864
+
865
+ test("merges cached financial snapshots across multiple providers", async () => {
866
+ const dbPath = createTempDbPath("cached-provider-financial-merge");
867
+ const persistence = new AppPersistence(dbPath);
868
+
869
+ const cloudProvider: DataProvider = {
870
+ ...fallbackProvider,
871
+ id: "cloud",
872
+ name: "Cloud",
873
+ priority: 100,
874
+ async getTickerFinancials() {
875
+ return {
876
+ annualStatements: [],
877
+ quarterlyStatements: [],
878
+ priceHistory: [],
879
+ quote: {
880
+ symbol: "AAPL",
881
+ price: 125,
882
+ currency: "USD",
883
+ change: 2,
884
+ changePercent: 1.6,
885
+ lastUpdated: Date.now(),
886
+ },
887
+ fundamentals: {
888
+ trailingPE: 25,
889
+ },
890
+ profile: {
891
+ sector: "Technology",
892
+ },
893
+ };
894
+ },
895
+ };
896
+ const yahooProvider: DataProvider = {
897
+ ...fallbackProvider,
898
+ id: "yahoo",
899
+ name: "Yahoo",
900
+ priority: 1000,
901
+ async getTickerFinancials() {
902
+ return {
903
+ annualStatements: [],
904
+ quarterlyStatements: [],
905
+ priceHistory: [{ date: new Date("2026-03-28T00:00:00Z"), close: 124 }],
906
+ quote: {
907
+ symbol: "AAPL",
908
+ price: 124,
909
+ currency: "USD",
910
+ change: 1,
911
+ changePercent: 0.8,
912
+ marketCap: 2_000_000_000,
913
+ lastUpdated: Date.now(),
914
+ },
915
+ fundamentals: {
916
+ forwardPE: 22,
917
+ },
918
+ };
919
+ },
920
+ };
921
+
922
+ const seedRouter = new ProviderRouter(yahooProvider, [cloudProvider], persistence.resources);
923
+ const seeded = await seedRouter.getTickerFinancials("AAPL", "NASDAQ");
924
+ expect(seeded.quote?.marketCap).toBe(2_000_000_000);
925
+
926
+ let cloudCalls = 0;
927
+ let yahooCalls = 0;
928
+ const cachedRouter = new ProviderRouter({
929
+ ...yahooProvider,
930
+ async getTickerFinancials() {
931
+ yahooCalls += 1;
932
+ throw new Error("expected cached yahoo financials");
933
+ },
934
+ }, [{
935
+ ...cloudProvider,
936
+ async getTickerFinancials() {
937
+ cloudCalls += 1;
938
+ throw new Error("expected cached cloud financials");
939
+ },
940
+ }], persistence.resources);
941
+
942
+ const cached = await cachedRouter.getTickerFinancials("AAPL", "NASDAQ");
943
+
944
+ expect(cloudCalls).toBe(0);
945
+ expect(yahooCalls).toBe(0);
946
+ expect(cached.quote?.price).toBe(125);
947
+ expect(cached.quote?.marketCap).toBe(2_000_000_000);
948
+ expect(cached.fundamentals?.trailingPE).toBe(25);
949
+ expect(cached.fundamentals?.forwardPE).toBe(22);
950
+ expect(cached.profile?.sector).toBe("Technology");
951
+ expect(cached.priceHistory[0]?.close).toBe(124);
952
+
953
+ persistence.close();
954
+ });
955
+
956
+ test("refreshes missing profile data even when cached financials exist", async () => {
957
+ const dbPath = createTempDbPath("cache-profile-refresh");
958
+ const persistence = new AppPersistence(dbPath);
959
+
960
+ const broker: BrokerAdapter = {
961
+ id: "ibkr",
962
+ name: "IBKR",
963
+ configSchema: [],
964
+ async validate() {
965
+ return true;
966
+ },
967
+ async importPositions() {
968
+ return [];
969
+ },
970
+ async getTickerFinancials() {
971
+ return {
972
+ annualStatements: [],
973
+ quarterlyStatements: [],
974
+ priceHistory: [],
975
+ fundamentals: { revenue: 1000, netIncome: 200 },
976
+ };
977
+ },
978
+ };
979
+
980
+ const config = {
981
+ dataDir: "",
982
+ configVersion: CURRENT_CONFIG_VERSION,
983
+ baseCurrency: "USD",
984
+ refreshIntervalMinutes: 30,
985
+ portfolios: [],
986
+ watchlists: [],
987
+ columns: [],
988
+ layout: cloneLayout(DEFAULT_LAYOUT),
989
+ layouts: [{ name: "Default", layout: cloneLayout(DEFAULT_LAYOUT) }],
990
+ activeLayoutIndex: 0,
991
+ brokerInstances: [{
992
+ id: "ibkr-work",
993
+ brokerType: "ibkr",
994
+ label: "Work",
995
+ connectionMode: "gateway",
996
+ config: {},
997
+ enabled: true,
998
+ }],
999
+ plugins: [],
1000
+ disabledPlugins: [],
1001
+ theme: "amber",
1002
+ chartPreferences: {
1003
+ defaultRenderMode: "area",
1004
+ renderer: "auto",
1005
+ },
1006
+ recentTickers: [],
1007
+ };
1008
+
1009
+ const seedRouter = new ProviderRouter({
1010
+ ...fallbackProvider,
1011
+ async getTickerFinancials() {
1012
+ return {
1013
+ annualStatements: [],
1014
+ quarterlyStatements: [],
1015
+ priceHistory: [],
1016
+ };
1017
+ },
1018
+ }, [], persistence.resources);
1019
+ seedRouter.attachRegistry({
1020
+ brokers: new Map([["ibkr", broker]]),
1021
+ dataProviders: new Map(),
1022
+ } as any);
1023
+ seedRouter.setConfigAccessor(() => config);
1024
+
1025
+ const seeded = await seedRouter.getTickerFinancials("PSTG", "NYSE", {
1026
+ brokerId: "ibkr",
1027
+ brokerInstanceId: "ibkr-work",
1028
+ });
1029
+ expect(seeded.profile).toBeUndefined();
1030
+
1031
+ const refreshedRouter = new ProviderRouter({
1032
+ ...fallbackProvider,
1033
+ async getTickerFinancials() {
1034
+ return {
1035
+ annualStatements: [],
1036
+ quarterlyStatements: [],
1037
+ priceHistory: [],
1038
+ profile: {
1039
+ description: "Provides enterprise data storage platforms.",
1040
+ sector: "Technology",
1041
+ industry: "Computer Hardware",
1042
+ },
1043
+ };
1044
+ },
1045
+ }, [], persistence.resources);
1046
+ refreshedRouter.attachRegistry({
1047
+ brokers: new Map([["ibkr", broker]]),
1048
+ dataProviders: new Map(),
1049
+ } as any);
1050
+ refreshedRouter.setConfigAccessor(() => config);
1051
+
1052
+ const refreshed = await refreshedRouter.getTickerFinancials("PSTG", "NYSE", {
1053
+ brokerId: "ibkr",
1054
+ brokerInstanceId: "ibkr-work",
1055
+ });
1056
+ expect(refreshed.fundamentals?.revenue).toBe(1000);
1057
+ expect(refreshed.profile?.description).toBe("Provides enterprise data storage platforms.");
1058
+
1059
+ persistence.close();
1060
+ });
1061
+
1062
+ test("does not reuse another broker instance's financials when a specific instance is requested", async () => {
1063
+ const dbPath = createTempDbPath("instance-scoped-financials");
1064
+ const persistence = new AppPersistence(dbPath);
1065
+ const router = new ProviderRouter({
1066
+ ...fallbackProvider,
1067
+ async getTickerFinancials() {
1068
+ return {
1069
+ annualStatements: [],
1070
+ quarterlyStatements: [],
1071
+ priceHistory: [],
1072
+ quote: {
1073
+ symbol: "IQE.L",
1074
+ price: 0.245,
1075
+ currency: "GBP",
1076
+ change: 0,
1077
+ changePercent: 0,
1078
+ lastUpdated: Date.now(),
1079
+ },
1080
+ };
1081
+ },
1082
+ }, [], persistence.resources);
1083
+ const broker: BrokerAdapter = {
1084
+ id: "ibkr",
1085
+ name: "IBKR",
1086
+ configSchema: [],
1087
+ async validate() {
1088
+ return true;
1089
+ },
1090
+ async importPositions() {
1091
+ return [];
1092
+ },
1093
+ async getTickerFinancials(_ticker, instance) {
1094
+ if (instance.id === "ibkr-flex") {
1095
+ throw new Error("Gateway mode is required for broker market data");
1096
+ }
1097
+ return {
1098
+ annualStatements: [],
1099
+ quarterlyStatements: [],
1100
+ priceHistory: [],
1101
+ quote: {
1102
+ symbol: "IQE",
1103
+ price: 24.5,
1104
+ currency: "GBP",
1105
+ change: 0,
1106
+ changePercent: 0,
1107
+ lastUpdated: Date.now(),
1108
+ },
1109
+ };
1110
+ },
1111
+ };
1112
+
1113
+ router.attachRegistry({
1114
+ brokers: new Map([["ibkr", broker]]),
1115
+ dataProviders: new Map(),
1116
+ } as any);
1117
+ router.setConfigAccessor(() => ({
1118
+ dataDir: "",
1119
+ configVersion: CURRENT_CONFIG_VERSION,
1120
+ baseCurrency: "USD",
1121
+ refreshIntervalMinutes: 30,
1122
+ portfolios: [],
1123
+ watchlists: [],
1124
+ columns: [],
1125
+ layout: cloneLayout(DEFAULT_LAYOUT),
1126
+ layouts: [{ name: "Default", layout: cloneLayout(DEFAULT_LAYOUT) }],
1127
+ activeLayoutIndex: 0,
1128
+ brokerInstances: [
1129
+ {
1130
+ id: "ibkr-flex",
1131
+ brokerType: "ibkr",
1132
+ label: "Flex",
1133
+ connectionMode: "flex",
1134
+ config: {},
1135
+ enabled: true,
1136
+ },
1137
+ {
1138
+ id: "ibkr-live",
1139
+ brokerType: "ibkr",
1140
+ label: "Live",
1141
+ connectionMode: "gateway",
1142
+ config: {},
1143
+ enabled: true,
1144
+ },
1145
+ ],
1146
+ plugins: [],
1147
+ disabledPlugins: [],
1148
+ theme: "amber",
1149
+ chartPreferences: {
1150
+ defaultRenderMode: "area",
1151
+ renderer: "auto",
1152
+ },
1153
+ recentTickers: [],
1154
+ }));
1155
+
1156
+ const live = await router.getTickerFinancials("IQE", "LSE", {
1157
+ brokerId: "ibkr",
1158
+ brokerInstanceId: "ibkr-live",
1159
+ });
1160
+ expect(live.quote?.price).toBe(0.245);
1161
+
1162
+ const flex = await router.getTickerFinancials("IQE", "LSE", {
1163
+ brokerId: "ibkr",
1164
+ brokerInstanceId: "ibkr-flex",
1165
+ });
1166
+ expect(flex.quote?.price).toBe(0.245);
1167
+
1168
+ const cached = router.getCachedFinancialsForTargets([{
1169
+ symbol: "IQE",
1170
+ exchange: "LSE",
1171
+ brokerId: "ibkr",
1172
+ brokerInstanceId: "ibkr-flex",
1173
+ }], { allowExpired: true });
1174
+ expect(cached.get("IQE")?.quote?.price).toBe(0.245);
1175
+
1176
+ persistence.close();
1177
+ });
1178
+
1179
+ test("returns merged cached provider financials on cold start when sub-unit quotes exist", async () => {
1180
+ const dbPath = createTempDbPath("cached-sub-unit-financials");
1181
+ const persistence = new AppPersistence(dbPath);
1182
+ const now = Date.now();
1183
+
1184
+ persistence.resources.set(
1185
+ {
1186
+ namespace: "market",
1187
+ kind: "financials",
1188
+ entityKey: "contract:14075064",
1189
+ variantKey: "exchange=LSE",
1190
+ sourceKey: "provider:gloomberb-cloud",
1191
+ },
1192
+ {
1193
+ annualStatements: [],
1194
+ quarterlyStatements: [],
1195
+ priceHistory: [],
1196
+ quote: {
1197
+ symbol: "IQE",
1198
+ price: 23.1,
1199
+ currency: "GBp",
1200
+ change: -1.4,
1201
+ changePercent: -5.71,
1202
+ previousClose: 24.5,
1203
+ lastUpdated: now,
1204
+ dataSource: "delayed",
1205
+ },
1206
+ profile: {
1207
+ description: "Cloud profile",
1208
+ },
1209
+ },
1210
+ {
1211
+ cachePolicy: { staleMs: 60_000, expireMs: 60_000 },
1212
+ fetchedAt: now,
1213
+ },
1214
+ );
1215
+ persistence.resources.set(
1216
+ {
1217
+ namespace: "market",
1218
+ kind: "financials",
1219
+ entityKey: "contract:14075064",
1220
+ variantKey: "exchange=LSE",
1221
+ sourceKey: "provider:yahoo",
1222
+ },
1223
+ {
1224
+ annualStatements: [],
1225
+ quarterlyStatements: [],
1226
+ priceHistory: [],
1227
+ quote: {
1228
+ symbol: "IQE.L",
1229
+ price: 0.231,
1230
+ currency: "GBP",
1231
+ change: -0.014,
1232
+ changePercent: -5.71,
1233
+ previousClose: 0.245,
1234
+ lastUpdated: now,
1235
+ dataSource: "yahoo",
1236
+ },
1237
+ fundamentals: {
1238
+ revenue: 1000,
1239
+ },
1240
+ },
1241
+ {
1242
+ cachePolicy: { staleMs: 60_000, expireMs: 60_000 },
1243
+ fetchedAt: now,
1244
+ },
1245
+ );
1246
+
1247
+ const yahooProvider: DataProvider = {
1248
+ ...fallbackProvider,
1249
+ id: "yahoo",
1250
+ name: "Yahoo",
1251
+ priority: 200,
1252
+ async getTickerFinancials() {
1253
+ throw new Error("should not fetch yahoo financials");
1254
+ },
1255
+ };
1256
+ const cloudProvider: DataProvider = {
1257
+ ...fallbackProvider,
1258
+ id: "gloomberb-cloud",
1259
+ name: "Cloud",
1260
+ priority: 100,
1261
+ async getTickerFinancials() {
1262
+ throw new Error("should not fetch cloud financials");
1263
+ },
1264
+ };
1265
+ const router = new ProviderRouter(yahooProvider, [cloudProvider], persistence.resources);
1266
+
1267
+ const financials = await router.getTickerFinancials("IQE", "LSE", {
1268
+ instrument: {
1269
+ brokerId: "ibkr",
1270
+ brokerInstanceId: "ibkr-personal",
1271
+ conId: 14075064,
1272
+ symbol: "IQE",
1273
+ },
1274
+ });
1275
+
1276
+ expect(financials.quote?.price).toBe(0.231);
1277
+ expect(financials.quote?.currency).toBe("GBP");
1278
+ expect(financials.fundamentals?.revenue).toBe(1000);
1279
+ expect(financials.profile?.description).toBe("Cloud profile");
1280
+
1281
+ persistence.close();
1282
+ });
1283
+
1284
+ test("does not log expected provider misses for missing chart data", async () => {
1285
+ const noisyProvider: DataProvider = {
1286
+ ...fallbackProvider,
1287
+ id: "yahoo",
1288
+ name: "Yahoo",
1289
+ async getPriceHistory() {
1290
+ throw new Error('[404] {"chart":{"result":null,"error":{"code":"Not Found","description":"No data found, symbol may be delisted"}}}');
1291
+ },
1292
+ };
1293
+ const router = new ProviderRouter(fallbackProvider, [noisyProvider]);
1294
+ const logged: unknown[][] = [];
1295
+ console.error = (...args: unknown[]) => {
1296
+ logged.push(args);
1297
+ };
1298
+
1299
+ const history = await router.getPriceHistory("BAD", "NASDAQ", "1Y");
1300
+
1301
+ expect(history).toEqual([]);
1302
+ expect(logged).toHaveLength(0);
1303
+ });
1304
+
1305
+ test("falls back to later providers when the preferred chart source is empty", async () => {
1306
+ const dbPath = createTempDbPath("chart-fallback");
1307
+ const persistence = new AppPersistence(dbPath);
1308
+
1309
+ const cloudProvider: DataProvider = {
1310
+ ...fallbackProvider,
1311
+ id: "cloud",
1312
+ name: "Cloud",
1313
+ priority: 100,
1314
+ async getPriceHistory() {
1315
+ return [];
1316
+ },
1317
+ };
1318
+ const yahooProvider: DataProvider = {
1319
+ ...fallbackProvider,
1320
+ id: "yahoo",
1321
+ name: "Yahoo",
1322
+ priority: 1000,
1323
+ async getPriceHistory() {
1324
+ return [{ date: new Date("2026-03-28T00:00:00Z"), close: 101 }];
1325
+ },
1326
+ };
1327
+
1328
+ const seedRouter = new ProviderRouter(yahooProvider, [cloudProvider], persistence.resources);
1329
+ const seeded = await seedRouter.getPriceHistory("AAPL", "NASDAQ", "1Y");
1330
+ expect(seeded[0]?.close).toBe(101);
1331
+
1332
+ const cachedRouter = new ProviderRouter(yahooProvider, [cloudProvider], persistence.resources);
1333
+ const cached = await cachedRouter.getPriceHistory("AAPL", "NASDAQ", "1Y");
1334
+ expect(cached[0]?.close).toBe(101);
1335
+
1336
+ persistence.close();
1337
+ });
1338
+
1339
+ test("sorts reversed chart history into chronological order", async () => {
1340
+ const router = new ProviderRouter({
1341
+ ...fallbackProvider,
1342
+ id: "cloud",
1343
+ name: "Cloud",
1344
+ async getPriceHistory() {
1345
+ return [
1346
+ { date: new Date("2026-03-29T00:00:00Z"), close: 103 },
1347
+ { date: new Date("2026-03-27T00:00:00Z"), close: 101 },
1348
+ { date: new Date("2026-03-28T00:00:00Z"), close: 102 },
1349
+ ];
1350
+ },
1351
+ });
1352
+
1353
+ const history = await router.getPriceHistory("AAPL", "NASDAQ", "1Y");
1354
+
1355
+ expect(history.map((point) => point.close)).toEqual([101, 102, 103]);
1356
+ });
1357
+
1358
+ test("bypasses cached financials on explicit refresh requests", async () => {
1359
+ const dbPath = createTempDbPath("forced-financial-refresh");
1360
+ const persistence = new AppPersistence(dbPath);
1361
+
1362
+ const seedRouter = new ProviderRouter({
1363
+ ...fallbackProvider,
1364
+ async getTickerFinancials() {
1365
+ return {
1366
+ annualStatements: [],
1367
+ quarterlyStatements: [],
1368
+ priceHistory: [{ date: new Date("2026-03-27T00:00:00Z"), close: 101 }],
1369
+ quote: {
1370
+ symbol: "AAPL",
1371
+ price: 101,
1372
+ currency: "USD",
1373
+ change: 1,
1374
+ changePercent: 1,
1375
+ lastUpdated: Date.now(),
1376
+ },
1377
+ };
1378
+ },
1379
+ }, [], persistence.resources);
1380
+ await seedRouter.getTickerFinancials("AAPL", "NASDAQ");
1381
+
1382
+ let providerCalls = 0;
1383
+ const refreshRouter = new ProviderRouter({
1384
+ ...fallbackProvider,
1385
+ async getTickerFinancials() {
1386
+ providerCalls += 1;
1387
+ return {
1388
+ annualStatements: [],
1389
+ quarterlyStatements: [],
1390
+ priceHistory: [{ date: new Date("2026-03-28T00:00:00Z"), close: 202 }],
1391
+ quote: {
1392
+ symbol: "AAPL",
1393
+ price: 202,
1394
+ currency: "USD",
1395
+ change: 2,
1396
+ changePercent: 1,
1397
+ lastUpdated: Date.now(),
1398
+ },
1399
+ };
1400
+ },
1401
+ }, [], persistence.resources);
1402
+
1403
+ const refreshed = await refreshRouter.getTickerFinancials("AAPL", "NASDAQ", { cacheMode: "refresh" });
1404
+
1405
+ expect(providerCalls).toBe(1);
1406
+ expect(refreshed.quote?.price).toBe(202);
1407
+ expect(refreshed.priceHistory[0]?.close).toBe(202);
1408
+
1409
+ persistence.close();
1410
+ });
1411
+
1412
+ test("refreshes stale cached chart history before falling back to cache", async () => {
1413
+ const dbPath = createTempDbPath("stale-chart-refresh");
1414
+ const persistence = new AppPersistence(dbPath);
1415
+
1416
+ const seedRouter = new ProviderRouter({
1417
+ ...fallbackProvider,
1418
+ async getPriceHistory() {
1419
+ return [{ date: new Date("2026-03-27T00:00:00Z"), close: 101 }];
1420
+ },
1421
+ }, [], persistence.resources);
1422
+ await seedRouter.getPriceHistory("AAPL", "NASDAQ", "1Y");
1423
+
1424
+ persistence.database.connection
1425
+ .query("UPDATE resource_cache SET stale_at = ? WHERE namespace = ? AND kind = ? AND entity_key = ?")
1426
+ .run(Date.now() - 1, "market", "price-history", "AAPL");
1427
+
1428
+ let providerCalls = 0;
1429
+ const refreshRouter = new ProviderRouter({
1430
+ ...fallbackProvider,
1431
+ async getPriceHistory() {
1432
+ providerCalls += 1;
1433
+ return [{ date: new Date("2026-03-28T00:00:00Z"), close: 202 }];
1434
+ },
1435
+ }, [], persistence.resources);
1436
+
1437
+ const history = await refreshRouter.getPriceHistory("AAPL", "NASDAQ", "1Y");
1438
+
1439
+ expect(providerCalls).toBe(1);
1440
+ expect(history[0]?.close).toBe(202);
1441
+
1442
+ persistence.close();
1443
+ });
1444
+
1445
+ test("falls back to later providers when detailed chart history is empty", async () => {
1446
+ const cloudProvider: DataProvider = {
1447
+ ...fallbackProvider,
1448
+ id: "cloud",
1449
+ name: "Cloud",
1450
+ priority: 100,
1451
+ async getDetailedPriceHistory() {
1452
+ return [];
1453
+ },
1454
+ };
1455
+ const yahooProvider: DataProvider = {
1456
+ ...fallbackProvider,
1457
+ id: "yahoo",
1458
+ name: "Yahoo",
1459
+ priority: 1000,
1460
+ async getDetailedPriceHistory() {
1461
+ return [{ date: new Date("2026-03-28T10:00:00Z"), close: 102 }];
1462
+ },
1463
+ };
1464
+
1465
+ const router = new ProviderRouter(yahooProvider, [cloudProvider]);
1466
+ const history = await router.getDetailedPriceHistory(
1467
+ "AAPL",
1468
+ "NASDAQ",
1469
+ new Date("2026-03-28T09:30:00Z"),
1470
+ new Date("2026-03-28T16:00:00Z"),
1471
+ "15m",
1472
+ );
1473
+
1474
+ expect(history[0]?.close).toBe(102);
1475
+ });
1476
+ });