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,1405 @@
1
+ import type { CachedResourceRecord, ResourceStore } from "../data/resource-store";
2
+ import type { PluginRegistry } from "../plugins/registry";
3
+ import type { BrokerAdapter } from "../types/broker";
4
+ import type { AppConfig } from "../types/config";
5
+ import { createDefaultConfig } from "../types/config";
6
+ import type {
7
+ CachedFinancialsTarget,
8
+ DataProvider,
9
+ MarketDataRequestContext,
10
+ NewsItem,
11
+ QuoteSubscriptionTarget,
12
+ SearchRequestContext,
13
+ SecFilingItem,
14
+ } from "../types/data-provider";
15
+ import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../types/financials";
16
+ import type { BrokerContractRef, InstrumentSearchResult } from "../types/instrument";
17
+ import type { CachePolicy, CachePolicyMap } from "../types/persistence";
18
+ import type { TimeRange } from "../components/chart/chart-types";
19
+ import { hasLikelyQuoteUnitMismatch } from "../utils/currency-units";
20
+ import { debugLog } from "../utils/debug-log";
21
+ import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
22
+ import { isProviderMiss } from "./provider-errors";
23
+
24
+ const providerLog = debugLog.createLogger("provider-router");
25
+
26
+ const BROKER_ATTEMPT_TIMEOUT = 10_000;
27
+ const EXPECTED_PROVIDER_MISS = /No data found|symbol may be delisted|"code":"Not Found"|No history for /i;
28
+ const MARKET_NAMESPACE = "market";
29
+
30
+ const DEFAULT_CACHE_POLICIES: Record<string, CachePolicy> = {
31
+ brokerQuote: { staleMs: 15_000, expireMs: 15 * 60_000 },
32
+ quote: { staleMs: 5 * 60_000, expireMs: 24 * 60 * 60_000 },
33
+ financials: { staleMs: 60 * 60_000, expireMs: 7 * 24 * 60 * 60_000 },
34
+ priceHistoryIntraday: { staleMs: 5 * 60_000, expireMs: 2 * 24 * 60 * 60_000 },
35
+ priceHistoryDaily: { staleMs: 24 * 60 * 60_000, expireMs: 30 * 24 * 60 * 60_000 },
36
+ news: { staleMs: 15 * 60_000, expireMs: 2 * 24 * 60 * 60_000 },
37
+ secFilings: { staleMs: 15 * 60_000, expireMs: 2 * 24 * 60 * 60_000 },
38
+ secFilingContent: { staleMs: 30 * 24 * 60 * 60_000, expireMs: 365 * 24 * 60 * 60_000 },
39
+ articleSummary: { staleMs: 30 * 24 * 60 * 60_000, expireMs: 90 * 24 * 60 * 60_000 },
40
+ optionsChain: { staleMs: 5 * 60_000, expireMs: 2 * 24 * 60 * 60_000 },
41
+ exchangeRate: { staleMs: 60 * 60_000, expireMs: 7 * 24 * 60 * 60_000 },
42
+ };
43
+
44
+ function withBrokerTimeout<T>(promise: Promise<T>): Promise<T | null> {
45
+ return new Promise<T | null>((resolve) => {
46
+ const timer = setTimeout(() => resolve(null), BROKER_ATTEMPT_TIMEOUT);
47
+ promise.then(
48
+ (value) => { clearTimeout(timer); resolve(value); },
49
+ () => { clearTimeout(timer); resolve(null); },
50
+ );
51
+ });
52
+ }
53
+
54
+ function shouldLogProviderError(error: unknown): boolean {
55
+ if (isProviderMiss(error)) return false;
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ return !EXPECTED_PROVIDER_MISS.test(message);
58
+ }
59
+
60
+ function normalizeTicker(ticker: string): string {
61
+ return ticker.trim().toUpperCase();
62
+ }
63
+
64
+ function normalizeExchange(exchange?: string): string {
65
+ return (exchange ?? "").trim().toUpperCase();
66
+ }
67
+
68
+ function compactUrl(url: string): string {
69
+ return url.trim();
70
+ }
71
+
72
+ function compactDate(date: Date): string {
73
+ return date.toISOString().slice(0, 10);
74
+ }
75
+
76
+ function normalizeSearchKeyPart(value?: string): string {
77
+ return (value ?? "").trim().toUpperCase();
78
+ }
79
+
80
+ function buildSearchResultKey(item: InstrumentSearchResult): string {
81
+ return [
82
+ normalizeSearchKeyPart(item.symbol),
83
+ normalizeSearchKeyPart(item.exchange),
84
+ normalizeSearchKeyPart(item.type),
85
+ normalizeSearchKeyPart(item.primaryExchange),
86
+ normalizeSearchKeyPart(item.currency),
87
+ ].join("|");
88
+ }
89
+
90
+ function getSearchResultRichness(item: InstrumentSearchResult, context?: SearchRequestContext): number {
91
+ let score = 0;
92
+ if (item.brokerContract) score += 500;
93
+ if (item.brokerInstanceId) score += 250;
94
+ if (item.brokerLabel) score += 100;
95
+ if (item.name) score += Math.min(80, item.name.length);
96
+ if (context?.brokerInstanceId && item.brokerInstanceId === context.brokerInstanceId) score += 800;
97
+ if (context?.brokerId && item.brokerContract?.brokerId === context.brokerId) score += 400;
98
+ return score;
99
+ }
100
+
101
+ function buildVariantKey(parts: Array<[string, string | number | undefined | null]>): string {
102
+ return parts
103
+ .filter(([, value]) => value !== undefined && value !== null && String(value).length > 0)
104
+ .map(([key, value]) => `${key}=${String(value)}`)
105
+ .join(";");
106
+ }
107
+
108
+ function isIntradayRange(range: TimeRange): boolean {
109
+ return range === "1W" || range === "1M" || range === "3M";
110
+ }
111
+
112
+ function hasMeaningfulFundamentals(data: TickerFinancials | null | undefined): boolean {
113
+ return !!data && Object.keys(data.fundamentals ?? {}).length > 0;
114
+ }
115
+
116
+ function hasMeaningfulProfile(data: TickerFinancials | null | undefined): boolean {
117
+ return !!data && !!(
118
+ data.profile?.description
119
+ || data.profile?.sector
120
+ || data.profile?.industry
121
+ );
122
+ }
123
+
124
+ function hasLikelyPriceUnitMismatch(primary: TickerFinancials, fallback: TickerFinancials): boolean {
125
+ return hasLikelyQuoteUnitMismatch(primary.quote, fallback.quote);
126
+ }
127
+
128
+ function mergeDefinedObject<T extends object>(preferred: T | null | undefined, fallback: T | null | undefined): T | undefined {
129
+ const mergedEntries: Array<[string, unknown]> = [];
130
+
131
+ for (const source of [fallback, preferred]) {
132
+ if (!source) continue;
133
+ for (const [key, value] of Object.entries(source)) {
134
+ if (value !== undefined) {
135
+ mergedEntries.push([key, value]);
136
+ }
137
+ }
138
+ }
139
+
140
+ if (mergedEntries.length === 0) return undefined;
141
+ return Object.fromEntries(mergedEntries) as T;
142
+ }
143
+
144
+ function mergeFinancials(primary: TickerFinancials | null, fallback: TickerFinancials | null): TickerFinancials | null {
145
+ if (!primary || !fallback) {
146
+ const single = primary ?? fallback;
147
+ return single ? normalizeTickerFinancialsPriceHistory(single) : null;
148
+ }
149
+
150
+ const preferFallbackPriceData = hasLikelyPriceUnitMismatch(primary, fallback);
151
+ const dominant = preferFallbackPriceData ? fallback : primary;
152
+ const secondary = preferFallbackPriceData ? primary : fallback;
153
+
154
+ return {
155
+ ...fallback,
156
+ ...primary,
157
+ quote: mergeDefinedObject(dominant.quote, secondary.quote),
158
+ profile: mergeDefinedObject(primary.profile, fallback.profile),
159
+ fundamentals: mergeDefinedObject(primary.fundamentals, fallback.fundamentals),
160
+ priceHistory: normalizePriceHistory(dominant.priceHistory.length > 0 ? dominant.priceHistory : secondary.priceHistory),
161
+ annualStatements: primary.annualStatements.length > 0 ? primary.annualStatements : fallback.annualStatements,
162
+ quarterlyStatements: primary.quarterlyStatements.length > 0 ? primary.quarterlyStatements : fallback.quarterlyStatements,
163
+ };
164
+ }
165
+
166
+ function mergeCachedFinancialRecords(records: CachedResourceRecord<TickerFinancials>[]): {
167
+ value: TickerFinancials | null;
168
+ stale: boolean;
169
+ } {
170
+ const seenSources = new Set<string>();
171
+ let merged: TickerFinancials | null = null;
172
+ let stale = false;
173
+
174
+ for (const record of records) {
175
+ if (seenSources.has(record.sourceKey)) continue;
176
+ seenSources.add(record.sourceKey);
177
+ merged = mergeFinancials(merged, record.value);
178
+ stale = stale || record.stale;
179
+ }
180
+
181
+ return { value: merged, stale };
182
+ }
183
+
184
+ interface CachedFinancialsSelection {
185
+ brokerRecord: CachedResourceRecord<TickerFinancials> | null;
186
+ providerValue: TickerFinancials | null;
187
+ value: TickerFinancials | null;
188
+ stale: boolean;
189
+ }
190
+
191
+ interface BrokerCandidate {
192
+ brokerId: string;
193
+ brokerInstanceId: string;
194
+ brokerLabel: string;
195
+ broker: BrokerAdapter;
196
+ instance: AppConfig["brokerInstances"][number];
197
+ }
198
+
199
+ interface SourceResult<T> {
200
+ sourceKey: string;
201
+ value: T;
202
+ }
203
+
204
+ export class ProviderRouter implements DataProvider {
205
+ readonly id = "provider-router";
206
+ readonly name = "Provider Router";
207
+ readonly priority = Number.MAX_SAFE_INTEGER;
208
+
209
+ private registry: PluginRegistry | null = null;
210
+ private readonly revalidationInFlight = new Map<string, Promise<unknown>>();
211
+ private getConfigFn: () => AppConfig = () => createDefaultConfig("");
212
+
213
+ constructor(
214
+ private readonly fallbackProvider: DataProvider | null = null,
215
+ private readonly extraProviders: DataProvider[] = [],
216
+ private readonly resources?: ResourceStore,
217
+ ) {}
218
+
219
+ attachRegistry(registry: PluginRegistry): void {
220
+ this.registry = registry;
221
+ }
222
+
223
+ setConfigAccessor(getConfig: () => AppConfig): void {
224
+ this.getConfigFn = getConfig;
225
+ }
226
+
227
+ async canProvide(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<boolean> {
228
+ const brokerQuote = await withBrokerTimeout(this.fetchBrokerQuote(ticker, exchange, context));
229
+ if (brokerQuote) return true;
230
+ for (const provider of this.providersInPriorityOrder()) {
231
+ try {
232
+ if (!provider.canProvide || await provider.canProvide(ticker, exchange, context)) {
233
+ return true;
234
+ }
235
+ } catch {
236
+ // continue through provider chain
237
+ }
238
+ }
239
+ return false;
240
+ }
241
+
242
+ getCachedFinancialsForTargets(targets: CachedFinancialsTarget[], options: { allowExpired?: boolean } = {}): Map<string, TickerFinancials> {
243
+ const results = new Map<string, TickerFinancials>();
244
+ for (const target of targets) {
245
+ const cached = this.readCachedMergedFinancials(target.symbol, target.exchange, {
246
+ brokerId: target.brokerId,
247
+ brokerInstanceId: target.brokerInstanceId,
248
+ instrument: target.instrument ?? undefined,
249
+ }, options.allowExpired ?? true);
250
+ if (cached) results.set(target.symbol.toUpperCase(), cached);
251
+ }
252
+ return results;
253
+ }
254
+
255
+ getCachedExchangeRates(currencies: string[], options: { allowExpired?: boolean } = {}): Map<string, number> {
256
+ const results = new Map<string, number>();
257
+ for (const currency of currencies) {
258
+ const cached = this.readCachedExchangeRate(currency, options.allowExpired ?? true);
259
+ if (cached != null) results.set(currency, cached);
260
+ }
261
+ return results;
262
+ }
263
+
264
+ async getTickerFinancials(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<TickerFinancials> {
265
+ const cached = this.readCachedMergedFinancialsSelection(ticker, exchange, context, false);
266
+ const forceRefresh = context?.cacheMode === "refresh";
267
+ if (cached.value && !forceRefresh) {
268
+ if (!cached.stale && hasMeaningfulProfile(cached.value)) {
269
+ return cached.value;
270
+ }
271
+ if (!hasMeaningfulProfile(cached.value) && !cached.stale) {
272
+ const providerResult = await this.fetchProviderFinancials(ticker, exchange, context);
273
+ return mergeFinancials(cached.value, providerResult?.value ?? null) ?? cached.value;
274
+ }
275
+ }
276
+
277
+ if (cached.value) {
278
+ const brokerResult = await withBrokerTimeout(this.fetchBrokerFinancials(ticker, exchange, context));
279
+ const providerResult = await this.fetchProviderFinancials(ticker, exchange, context);
280
+ return mergeFinancials(
281
+ brokerResult?.value ?? cached.brokerRecord?.value ?? null,
282
+ providerResult?.value ?? cached.providerValue ?? null,
283
+ ) ?? cached.value;
284
+ }
285
+
286
+ const brokerResult = await withBrokerTimeout(this.fetchBrokerFinancials(ticker, exchange, context));
287
+ const fallback = await this.fetchProviderFinancials(ticker, exchange, context);
288
+ const merged = mergeFinancials(brokerResult?.value ?? null, fallback?.value ?? null);
289
+ if (!merged) {
290
+ throw new Error(`No provider available for ${ticker}`);
291
+ }
292
+ return merged;
293
+ }
294
+
295
+ async getQuote(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<Quote> {
296
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
297
+ const variantKeys = this.getTickerVariantCandidates(exchange);
298
+ const sourceKeys = [
299
+ ...this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate)),
300
+ ...this.getProviderSourceKeys(),
301
+ ];
302
+ const cached = this.selectCachedResource<Quote>("quote", entityKey, variantKeys, sourceKeys, false);
303
+ const forceRefresh = context?.cacheMode === "refresh";
304
+ if (cached && !forceRefresh && !cached.stale) {
305
+ return cached.value;
306
+ }
307
+
308
+ const brokerQuote = await withBrokerTimeout(this.fetchBrokerQuote(ticker, exchange, context));
309
+ if (brokerQuote) return brokerQuote.value;
310
+
311
+ const providerQuote = await this.fetchProviderQuote(ticker, exchange, context);
312
+ if (providerQuote) {
313
+ return providerQuote.value;
314
+ }
315
+ if (cached) return cached.value;
316
+ throw new Error(`No quote provider available for ${ticker}`);
317
+ }
318
+
319
+ async getExchangeRate(fromCurrency: string): Promise<number> {
320
+ const cached = this.readCachedExchangeRate(fromCurrency, false);
321
+ if (cached != null) {
322
+ this.scheduleRevalidation(`exchange-rate:${fromCurrency.toUpperCase()}`, async () => {
323
+ await this.revalidateExchangeRate(fromCurrency);
324
+ });
325
+ return cached;
326
+ }
327
+
328
+ const result = await this.firstProvider(async (provider) => {
329
+ const rate = await provider.getExchangeRate(fromCurrency);
330
+ return { provider, rate };
331
+ });
332
+ if (!result) {
333
+ throw new Error(`No exchange rate provider available for ${fromCurrency}`);
334
+ }
335
+ this.cacheExchangeRate(fromCurrency, result.value.rate, result.value.provider);
336
+ return result.value.rate;
337
+ }
338
+
339
+ async search(query: string, context?: SearchRequestContext): Promise<InstrumentSearchResult[]> {
340
+ const results: InstrumentSearchResult[] = [];
341
+ const resultIndexByKey = new Map<string, number>();
342
+
343
+ const push = (items: InstrumentSearchResult[]) => {
344
+ for (const item of items) {
345
+ const key = buildSearchResultKey(item);
346
+ const existingIndex = resultIndexByKey.get(key);
347
+ if (existingIndex == null) {
348
+ resultIndexByKey.set(key, results.length);
349
+ results.push(item);
350
+ continue;
351
+ }
352
+
353
+ const existing = results[existingIndex]!;
354
+ if (getSearchResultRichness(item, context) > getSearchResultRichness(existing, context)) {
355
+ results[existingIndex] = item;
356
+ }
357
+ }
358
+ };
359
+
360
+ const searchPromises: Promise<void>[] = [];
361
+
362
+ if (context?.preferBroker !== false) {
363
+ for (const candidate of this.getBrokerCandidates(
364
+ context?.brokerInstanceId,
365
+ context?.brokerId,
366
+ )) {
367
+ if (!candidate.broker.searchInstruments) continue;
368
+ searchPromises.push(
369
+ candidate.broker.searchInstruments(query, candidate.instance)
370
+ .then((items) => push(this.annotateSearchResults(items, candidate)))
371
+ .catch(() => {}),
372
+ );
373
+ }
374
+ }
375
+
376
+ for (const provider of this.providersInPriorityOrder()) {
377
+ searchPromises.push(
378
+ provider.search(query, context)
379
+ .then((items) => push(items))
380
+ .catch(() => {}),
381
+ );
382
+ }
383
+
384
+ const deadline = new Promise<void>((resolve) => setTimeout(resolve, 5_000));
385
+ await Promise.race([Promise.all(searchPromises), deadline]);
386
+ return results;
387
+ }
388
+
389
+ async getNews(ticker: string, count = 15, exchange?: string, context?: MarketDataRequestContext): Promise<NewsItem[]> {
390
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
391
+ const variantKeys = [
392
+ buildVariantKey([["exchange", normalizeExchange(exchange)], ["count", count]]),
393
+ buildVariantKey([["count", count]]),
394
+ "",
395
+ ];
396
+ const cached = this.selectCachedResource<NewsItem[]>("news", entityKey, variantKeys, this.getProviderSourceKeys(), false);
397
+ if (cached) {
398
+ this.scheduleRevalidation(this.makeRevalidationKey("news", ticker, exchange, context, count), async () => {
399
+ await this.revalidateNews(ticker, count, exchange, context);
400
+ });
401
+ return cached.value;
402
+ }
403
+
404
+ const result = await this.firstProvider((provider) => provider.getNews(ticker, count, exchange, context));
405
+ if (!result) {
406
+ throw new Error(`No news provider available for ${ticker}`);
407
+ }
408
+ const provider = this.resolveProviderBySourceKey(result.sourceKey);
409
+ if (provider) {
410
+ this.cacheResource("news", entityKey, variantKeys[0] ?? "", result.sourceKey, result.value, this.resolveProviderPolicy("news", provider));
411
+ }
412
+ return result.value;
413
+ }
414
+
415
+ async getSecFilings(ticker: string, count = 15, exchange?: string, context?: MarketDataRequestContext): Promise<SecFilingItem[]> {
416
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
417
+ const variantKeys = [
418
+ buildVariantKey([["exchange", normalizeExchange(exchange)], ["count", count]]),
419
+ buildVariantKey([["count", count]]),
420
+ "",
421
+ ];
422
+ const cached = this.selectCachedResource<SecFilingItem[]>("sec-filings", entityKey, variantKeys, this.getProviderSourceKeys(), false);
423
+ if (cached) {
424
+ this.scheduleRevalidation(this.makeRevalidationKey("sec-filings", ticker, exchange, context, count), async () => {
425
+ await this.revalidateSecFilings(ticker, count, exchange, context);
426
+ });
427
+ return cached.value;
428
+ }
429
+
430
+ const result = await this.fetchProviderSecFilings(ticker, count, exchange, context);
431
+ if (!result) {
432
+ throw new Error(`No SEC filings provider available for ${ticker}`);
433
+ }
434
+ return result.value;
435
+ }
436
+
437
+ async getSecFilingContent(filing: SecFilingItem): Promise<string | null> {
438
+ const entityKey = compactUrl(filing.primaryDocumentUrl ?? filing.filingUrl);
439
+ const cached = this.selectCachedResource<string | null>("sec-filing-content", entityKey, [""], this.getProviderSourceKeys(), false);
440
+ if (cached) {
441
+ this.scheduleRevalidation(`sec-filing-content:${entityKey}`, async () => {
442
+ await this.revalidateSecFilingContent(filing);
443
+ });
444
+ return cached.value;
445
+ }
446
+
447
+ const result = await this.fetchProviderSecFilingContent(filing);
448
+ if (!result) {
449
+ throw new Error("No SEC filing content provider available");
450
+ }
451
+ return result.value;
452
+ }
453
+
454
+ async getArticleSummary(url: string): Promise<string | null> {
455
+ const entityKey = compactUrl(url);
456
+ const cached = this.selectCachedResource<string>("article-summary", entityKey, [""], this.getProviderSourceKeys(), false);
457
+ if (cached) {
458
+ this.scheduleRevalidation(`article-summary:${entityKey}`, async () => {
459
+ await this.revalidateArticleSummary(url);
460
+ });
461
+ return cached.value;
462
+ }
463
+
464
+ const result = await this.firstProvider((provider) => provider.getArticleSummary(url));
465
+ if (!result) {
466
+ return null;
467
+ }
468
+ const provider = this.resolveProviderBySourceKey(result.sourceKey);
469
+ if (provider && result.value) {
470
+ this.cacheResource("article-summary", entityKey, "", result.sourceKey, result.value, this.resolveProviderPolicy("articleSummary", provider));
471
+ }
472
+ return result.value;
473
+ }
474
+
475
+ async getPriceHistory(ticker: string, exchange: string, range: TimeRange, context?: MarketDataRequestContext): Promise<PricePoint[]> {
476
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
477
+ const variantKeys = [
478
+ buildVariantKey([["exchange", normalizeExchange(exchange)], ["range", range]]),
479
+ buildVariantKey([["range", range]]),
480
+ ];
481
+ const sourceKeys = [
482
+ ...this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate)),
483
+ ...this.getProviderSourceKeys(),
484
+ ];
485
+ const cached = this.selectCachedArrayResource<PricePoint>("price-history", entityKey, variantKeys, sourceKeys, false);
486
+ const forceRefresh = context?.cacheMode === "refresh";
487
+ if (cached && !forceRefresh && !cached.stale) {
488
+ return cached.value;
489
+ }
490
+
491
+ const brokerHistory = await withBrokerTimeout(this.fetchBrokerPriceHistory(ticker, exchange, range, context));
492
+ if (brokerHistory && brokerHistory.value.length > 0) return brokerHistory.value;
493
+
494
+ const providerHistory = await this.fetchProviderPriceHistory(ticker, exchange, range, context);
495
+ if (providerHistory && providerHistory.value.length > 0) {
496
+ return providerHistory.value;
497
+ }
498
+ if (cached) return cached.value;
499
+ if (!providerHistory) {
500
+ throw new Error(`No history provider available for ${ticker}`);
501
+ }
502
+ return providerHistory.value;
503
+ }
504
+
505
+ async getDetailedPriceHistory(
506
+ ticker: string,
507
+ exchange: string,
508
+ startDate: Date,
509
+ endDate: Date,
510
+ barSize: string,
511
+ context?: MarketDataRequestContext,
512
+ ): Promise<PricePoint[]> {
513
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
514
+ const variantKeys = [
515
+ buildVariantKey([["exchange", normalizeExchange(exchange)], ["start", compactDate(startDate)], ["end", compactDate(endDate)], ["bar", barSize]]),
516
+ buildVariantKey([["start", compactDate(startDate)], ["end", compactDate(endDate)], ["bar", barSize]]),
517
+ ];
518
+ const sourceKeys = [
519
+ ...this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate)),
520
+ ...this.getProviderSourceKeys(),
521
+ ];
522
+ const cached = this.selectCachedArrayResource<PricePoint>("detailed-price-history", entityKey, variantKeys, sourceKeys, false);
523
+ const forceRefresh = context?.cacheMode === "refresh";
524
+ if (cached && !forceRefresh && !cached.stale) {
525
+ return cached.value;
526
+ }
527
+
528
+ const brokerResult = await withBrokerTimeout(this.fetchBrokerDetailedPriceHistory(ticker, exchange, startDate, endDate, barSize, context));
529
+ if (brokerResult && brokerResult.value.length > 0) return brokerResult.value;
530
+
531
+ const providerResult = await this.fetchProviderDetailedPriceHistory(ticker, exchange, startDate, endDate, barSize, context);
532
+ if (providerResult && providerResult.value.length > 0) {
533
+ return providerResult.value;
534
+ }
535
+ return cached?.value ?? providerResult?.value ?? [];
536
+ }
537
+
538
+ async getOptionsChain(ticker: string, exchange?: string, expirationDate?: number, context?: MarketDataRequestContext): Promise<OptionsChain> {
539
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
540
+ const variantKeys = [
541
+ buildVariantKey([["exchange", normalizeExchange(exchange)], ["expiration", expirationDate ?? "default"]]),
542
+ buildVariantKey([["expiration", expirationDate ?? "default"]]),
543
+ "",
544
+ ];
545
+ const sourceKeys = [
546
+ ...this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate)),
547
+ ...this.getProviderSourceKeys(),
548
+ ];
549
+ const cached = this.selectCachedResource<OptionsChain>("options-chain", entityKey, variantKeys, sourceKeys, false);
550
+ if (cached) {
551
+ this.scheduleRevalidation(this.makeRevalidationKey("options-chain", ticker, exchange, context, expirationDate ?? "default"), async () => {
552
+ await this.revalidateOptionsChain(ticker, exchange, expirationDate, context);
553
+ });
554
+ return cached.value;
555
+ }
556
+
557
+ const brokerChain = await withBrokerTimeout(this.fetchBrokerOptionsChain(ticker, exchange, expirationDate, context));
558
+ if (brokerChain) return brokerChain.value;
559
+
560
+ const providerChain = await this.fetchProviderOptionsChain(ticker, exchange, expirationDate, context);
561
+ if (!providerChain) {
562
+ throw new Error(`No options provider available for ${ticker}`);
563
+ }
564
+ return providerChain.value;
565
+ }
566
+
567
+ private makeRevalidationKey(kind: string, ticker: string, exchange?: string, context?: MarketDataRequestContext, extra?: string | number): string {
568
+ return [
569
+ kind,
570
+ this.getEntityKey(ticker, exchange, context?.instrument),
571
+ extra != null ? String(extra) : "",
572
+ ].join("|");
573
+ }
574
+
575
+ private scheduleRevalidation(key: string, task: () => Promise<void>): void {
576
+ if (this.revalidationInFlight.has(key)) return;
577
+ const promise = task()
578
+ .catch(() => {})
579
+ .finally(() => {
580
+ this.revalidationInFlight.delete(key);
581
+ });
582
+ this.revalidationInFlight.set(key, promise);
583
+ }
584
+
585
+ private getEntityKey(ticker: string, exchange?: string, instrument?: BrokerContractRef | null): string {
586
+ if (instrument?.conId != null) return `contract:${instrument.conId}`;
587
+ if (instrument?.localSymbol) return `contract:${instrument.localSymbol.toUpperCase()}`;
588
+ if (instrument?.symbol) return `contract:${instrument.symbol.toUpperCase()}`;
589
+ return normalizeTicker(ticker);
590
+ }
591
+
592
+ private getTickerVariantCandidates(exchange?: string): string[] {
593
+ const normalizedExchange = normalizeExchange(exchange);
594
+ return [
595
+ buildVariantKey([["exchange", normalizedExchange]]),
596
+ "",
597
+ ].filter((value, index, array) => value.length > 0 || array.indexOf(value) === index);
598
+ }
599
+
600
+ private providerSourceKey(provider: DataProvider): string {
601
+ return `provider:${provider.id}`;
602
+ }
603
+
604
+ private brokerSourceKey(candidate: BrokerCandidate): string {
605
+ return `broker:${candidate.brokerId}:${candidate.brokerInstanceId}`;
606
+ }
607
+
608
+ private getProviderSourceKeys(): string[] {
609
+ return this.providersInPriorityOrder().map((provider) => this.providerSourceKey(provider));
610
+ }
611
+
612
+ private firstAvailableProvider(): DataProvider | null {
613
+ return this.providersInPriorityOrder()[0] ?? null;
614
+ }
615
+
616
+ private resolvePolicy(overrides: CachePolicyMap | undefined, key: keyof typeof DEFAULT_CACHE_POLICIES): CachePolicy {
617
+ return overrides?.[key] ?? DEFAULT_CACHE_POLICIES[key]!;
618
+ }
619
+
620
+ private resolveProviderPolicy(key: keyof typeof DEFAULT_CACHE_POLICIES, provider: DataProvider): CachePolicy {
621
+ return this.resolvePolicy(provider.cachePolicy, key);
622
+ }
623
+
624
+ private resolveBrokerPolicy(key: keyof typeof DEFAULT_CACHE_POLICIES, broker: BrokerAdapter): CachePolicy {
625
+ return this.resolvePolicy(broker.cachePolicy, key);
626
+ }
627
+
628
+ private cacheResource<T>(kind: string, entityKey: string, variantKey: string, sourceKey: string, value: T, cachePolicy: CachePolicy): void {
629
+ this.resources?.set(
630
+ {
631
+ namespace: MARKET_NAMESPACE,
632
+ kind,
633
+ entityKey,
634
+ variantKey,
635
+ sourceKey,
636
+ },
637
+ value,
638
+ {
639
+ cachePolicy,
640
+ },
641
+ );
642
+ }
643
+
644
+ private listCachedResources<T>(
645
+ kind: string,
646
+ entityKey: string,
647
+ variantKeys: string[],
648
+ sourceKeys: string[],
649
+ allowExpired: boolean,
650
+ ): CachedResourceRecord<T>[] {
651
+ if (!this.resources) return [];
652
+ const records = this.resources.list<T>({
653
+ namespace: MARKET_NAMESPACE,
654
+ kind,
655
+ entityKey,
656
+ }, {
657
+ variantKeys,
658
+ sourceKeys,
659
+ allowExpired,
660
+ });
661
+ if (records.length === 0) return [];
662
+
663
+ const sourceRank = new Map(sourceKeys.map((sourceKey, index) => [sourceKey, index]));
664
+ const variantRank = new Map(variantKeys.map((variantKey, index) => [variantKey, index]));
665
+ return [...records].sort((a, b) => {
666
+ if (a.expired !== b.expired) return a.expired ? 1 : -1;
667
+ if (a.stale !== b.stale) return a.stale ? 1 : -1;
668
+ const sourceDelta = (sourceRank.get(a.sourceKey) ?? Number.MAX_SAFE_INTEGER) - (sourceRank.get(b.sourceKey) ?? Number.MAX_SAFE_INTEGER);
669
+ if (sourceDelta !== 0) return sourceDelta;
670
+ const variantDelta = (variantRank.get(a.variantKey ?? "") ?? Number.MAX_SAFE_INTEGER) - (variantRank.get(b.variantKey ?? "") ?? Number.MAX_SAFE_INTEGER);
671
+ if (variantDelta !== 0) return variantDelta;
672
+ return b.fetchedAt - a.fetchedAt;
673
+ });
674
+ }
675
+
676
+ private selectCachedResource<T>(
677
+ kind: string,
678
+ entityKey: string,
679
+ variantKeys: string[],
680
+ sourceKeys: string[],
681
+ allowExpired: boolean,
682
+ ): CachedResourceRecord<T> | null {
683
+ return this.listCachedResources(kind, entityKey, variantKeys, sourceKeys, allowExpired)[0] ?? null;
684
+ }
685
+
686
+ private selectCachedArrayResource<T>(
687
+ kind: string,
688
+ entityKey: string,
689
+ variantKeys: string[],
690
+ sourceKeys: string[],
691
+ allowExpired: boolean,
692
+ ): CachedResourceRecord<T[]> | null {
693
+ const records = this.listCachedResources<T[]>(kind, entityKey, variantKeys, sourceKeys, allowExpired);
694
+ return records.find((record) => record.value.length > 0) ?? records[0] ?? null;
695
+ }
696
+
697
+ private readCachedMergedFinancials(
698
+ ticker: string,
699
+ exchange?: string,
700
+ context?: MarketDataRequestContext,
701
+ allowExpired = false,
702
+ ): TickerFinancials | null {
703
+ return this.readCachedMergedFinancialsSelection(ticker, exchange, context, allowExpired).value;
704
+ }
705
+
706
+ private readCachedMergedFinancialsSelection(
707
+ ticker: string,
708
+ exchange?: string,
709
+ context?: MarketDataRequestContext,
710
+ allowExpired = false,
711
+ ): CachedFinancialsSelection {
712
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
713
+ const variantKeys = this.getTickerVariantCandidates(exchange);
714
+ const brokerSourceKeys = this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate));
715
+ const brokerRecord = brokerSourceKeys.length > 0
716
+ ? this.selectCachedResource<TickerFinancials>("financials", entityKey, variantKeys, brokerSourceKeys, allowExpired)
717
+ : null;
718
+ const providerSelection = mergeCachedFinancialRecords(
719
+ this.listCachedResources<TickerFinancials>(
720
+ "financials",
721
+ entityKey,
722
+ variantKeys,
723
+ this.getProviderSourceKeys(),
724
+ allowExpired,
725
+ ),
726
+ );
727
+ return {
728
+ brokerRecord,
729
+ providerValue: providerSelection.value,
730
+ value: mergeFinancials(brokerRecord?.value ?? null, providerSelection.value),
731
+ stale: (brokerRecord?.stale ?? false) || providerSelection.stale,
732
+ };
733
+ }
734
+
735
+ private readCachedExchangeRate(currency: string, allowExpired = false): number | null {
736
+ const normalizedCurrency = currency.toUpperCase();
737
+ if (normalizedCurrency === "USD") return 1;
738
+ const entityKey = `${normalizedCurrency}/USD`;
739
+ const cached = this.selectCachedResource<{ rate: number }>("exchange-rate", entityKey, [""], this.getProviderSourceKeys(), allowExpired);
740
+ return cached?.value.rate ?? null;
741
+ }
742
+
743
+ private cacheExchangeRate(currency: string, rate: number, provider: DataProvider): void {
744
+ const normalizedCurrency = currency.toUpperCase();
745
+ if (normalizedCurrency === "USD") return;
746
+ this.cacheResource(
747
+ "exchange-rate",
748
+ `${normalizedCurrency}/USD`,
749
+ "",
750
+ this.providerSourceKey(provider),
751
+ { rate },
752
+ this.resolveProviderPolicy("exchangeRate", provider),
753
+ );
754
+ }
755
+
756
+ private getBrokerCandidates(
757
+ preferredBrokerInstanceId?: string,
758
+ preferredBrokerId?: string,
759
+ includeFallbackInstances = true,
760
+ ): BrokerCandidate[] {
761
+ if (!this.registry) return [];
762
+ const config = this.getConfigFn();
763
+ const candidates: BrokerCandidate[] = [];
764
+
765
+ const pushCandidate = (instance: AppConfig["brokerInstances"][number]) => {
766
+ if (instance.enabled === false) return;
767
+ if (preferredBrokerId && instance.brokerType !== preferredBrokerId && instance.id !== preferredBrokerInstanceId) return;
768
+ const broker = this.registry?.brokers.get(instance.brokerType);
769
+ if (!broker) return;
770
+ candidates.push({
771
+ brokerId: instance.brokerType,
772
+ brokerInstanceId: instance.id,
773
+ brokerLabel: instance.label,
774
+ broker,
775
+ instance,
776
+ });
777
+ };
778
+
779
+ const preferredInstance = preferredBrokerInstanceId
780
+ ? config.brokerInstances.find((instance) => instance.id === preferredBrokerInstanceId)
781
+ : undefined;
782
+ if (preferredInstance) {
783
+ pushCandidate(preferredInstance);
784
+ }
785
+
786
+ if (!includeFallbackInstances && preferredInstance) {
787
+ return candidates;
788
+ }
789
+
790
+ for (const instance of config.brokerInstances) {
791
+ if (instance.id === preferredBrokerInstanceId) continue;
792
+ pushCandidate(instance);
793
+ }
794
+
795
+ return candidates;
796
+ }
797
+
798
+ private getBrokerCandidatesForContext(
799
+ context?: MarketDataRequestContext,
800
+ includeFallbackInstances = true,
801
+ ): BrokerCandidate[] {
802
+ return this.getBrokerCandidates(
803
+ context?.instrument?.brokerInstanceId ?? context?.brokerInstanceId,
804
+ context?.instrument?.brokerId ?? context?.brokerId,
805
+ includeFallbackInstances,
806
+ );
807
+ }
808
+
809
+ private getStreamingBrokerCandidate(target: QuoteSubscriptionTarget): BrokerCandidate | null {
810
+ const hasBrokerContext = !!(
811
+ target.context?.brokerId
812
+ || target.context?.brokerInstanceId
813
+ || target.context?.instrument?.brokerId
814
+ || target.context?.instrument?.brokerInstanceId
815
+ );
816
+ if (!hasBrokerContext) return null;
817
+ return this.getBrokerCandidatesForContext(target.context, false)
818
+ .find((candidate) => typeof candidate.broker.subscribeQuotes === "function") ?? null;
819
+ }
820
+
821
+ private annotateSearchResults(items: InstrumentSearchResult[], candidate: BrokerCandidate): InstrumentSearchResult[] {
822
+ return items.map((item) => ({
823
+ ...item,
824
+ brokerInstanceId: item.brokerInstanceId ?? candidate.brokerInstanceId,
825
+ brokerLabel: item.brokerLabel ?? candidate.brokerLabel,
826
+ brokerContract: item.brokerContract
827
+ ? {
828
+ ...item.brokerContract,
829
+ brokerId: item.brokerContract.brokerId || candidate.brokerId,
830
+ brokerInstanceId: item.brokerContract.brokerInstanceId ?? candidate.brokerInstanceId,
831
+ }
832
+ : undefined,
833
+ }));
834
+ }
835
+
836
+ private sortedProviders(): DataProvider[] {
837
+ const providers = [...this.extraProviders];
838
+ if (this.registry) {
839
+ const registryProviders = typeof this.registry.getEnabledDataProviders === "function"
840
+ ? this.registry.getEnabledDataProviders()
841
+ : [...this.registry.dataProviders.values()];
842
+ providers.push(...registryProviders);
843
+ }
844
+ return providers
845
+ .filter((provider) => provider.id !== this.id && provider.id !== this.fallbackProvider?.id)
846
+ .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
847
+ }
848
+
849
+ private providersInPriorityOrder(): DataProvider[] {
850
+ const providers = [...this.sortedProviders()];
851
+ if (this.fallbackProvider && !providers.some((provider) => provider.id === this.fallbackProvider?.id)) {
852
+ providers.push(this.fallbackProvider);
853
+ }
854
+ return providers;
855
+ }
856
+
857
+ private async firstProvider<T>(fn: (provider: DataProvider) => Promise<T | null | undefined>): Promise<SourceResult<T> | null> {
858
+ for (const provider of this.providersInPriorityOrder()) {
859
+ try {
860
+ const result = await fn(provider);
861
+ if (result != null) return { sourceKey: this.providerSourceKey(provider), value: result };
862
+ } catch (err) {
863
+ if (shouldLogProviderError(err)) {
864
+ providerLog.error(`${provider.id} failed: ${err}`);
865
+ }
866
+ }
867
+ }
868
+ return null;
869
+ }
870
+
871
+ private async fetchBrokerFinancials(
872
+ ticker: string,
873
+ exchange?: string,
874
+ context?: MarketDataRequestContext,
875
+ ): Promise<SourceResult<TickerFinancials> | null> {
876
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
877
+ const variantKey = this.getTickerVariantCandidates(exchange)[0] ?? "";
878
+ for (const candidate of this.getBrokerCandidatesForContext(context, false)) {
879
+ if (!candidate.broker.getTickerFinancials) continue;
880
+ try {
881
+ const result = normalizeTickerFinancialsPriceHistory(await candidate.broker.getTickerFinancials(
882
+ ticker,
883
+ candidate.instance,
884
+ exchange,
885
+ context?.instrument ?? null,
886
+ ));
887
+ this.cacheResource(
888
+ "financials",
889
+ entityKey,
890
+ variantKey,
891
+ this.brokerSourceKey(candidate),
892
+ result,
893
+ this.resolveBrokerPolicy("financials", candidate.broker),
894
+ );
895
+ return { sourceKey: this.brokerSourceKey(candidate), value: result };
896
+ } catch {
897
+ // continue
898
+ }
899
+ }
900
+ return null;
901
+ }
902
+
903
+ private async fetchProviderFinancials(
904
+ ticker: string,
905
+ exchange?: string,
906
+ context?: MarketDataRequestContext,
907
+ ): Promise<SourceResult<TickerFinancials> | null> {
908
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
909
+ const variantKey = this.getTickerVariantCandidates(exchange)[0] ?? "";
910
+ let merged: TickerFinancials | null = null;
911
+ let firstSourceKey: string | null = null;
912
+
913
+ for (const provider of this.providersInPriorityOrder()) {
914
+ try {
915
+ const value = normalizeTickerFinancialsPriceHistory(await provider.getTickerFinancials(ticker, exchange, context));
916
+ const sourceKey = this.providerSourceKey(provider);
917
+ this.cacheResource(
918
+ "financials",
919
+ entityKey,
920
+ variantKey,
921
+ sourceKey,
922
+ value,
923
+ this.resolveProviderPolicy("financials", provider),
924
+ );
925
+ merged = mergeFinancials(merged, value);
926
+ firstSourceKey ??= sourceKey;
927
+ } catch (error) {
928
+ if (shouldLogProviderError(error)) {
929
+ providerLog.error(`${provider.id} failed: ${error}`);
930
+ }
931
+ }
932
+ }
933
+
934
+ return merged && firstSourceKey
935
+ ? { sourceKey: firstSourceKey, value: merged }
936
+ : null;
937
+ }
938
+
939
+ private async fetchBrokerQuote(
940
+ ticker: string,
941
+ exchange?: string,
942
+ context?: MarketDataRequestContext,
943
+ ): Promise<SourceResult<Quote> | null> {
944
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
945
+ const variantKey = this.getTickerVariantCandidates(exchange)[0] ?? "";
946
+ for (const candidate of this.getBrokerCandidatesForContext(context, false)) {
947
+ if (!candidate.broker.getQuote) continue;
948
+ try {
949
+ const result = await candidate.broker.getQuote(
950
+ ticker,
951
+ candidate.instance,
952
+ exchange,
953
+ context?.instrument ?? null,
954
+ );
955
+ this.cacheResource(
956
+ "quote",
957
+ entityKey,
958
+ variantKey,
959
+ this.brokerSourceKey(candidate),
960
+ result,
961
+ this.resolveBrokerPolicy("brokerQuote", candidate.broker),
962
+ );
963
+ return { sourceKey: this.brokerSourceKey(candidate), value: result };
964
+ } catch {
965
+ // continue
966
+ }
967
+ }
968
+ return null;
969
+ }
970
+
971
+ private async fetchProviderQuote(
972
+ ticker: string,
973
+ exchange?: string,
974
+ context?: MarketDataRequestContext,
975
+ ): Promise<SourceResult<Quote> | null> {
976
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
977
+ const variantKey = this.getTickerVariantCandidates(exchange)[0] ?? "";
978
+ const result = await this.firstProvider((provider) => provider.getQuote(ticker, exchange, context));
979
+ if (!result) return null;
980
+ const provider = this.resolveProviderBySourceKey(result.sourceKey);
981
+ if (provider) {
982
+ this.cacheResource("quote", entityKey, variantKey, result.sourceKey, result.value, this.resolveProviderPolicy("quote", provider));
983
+ }
984
+ return result;
985
+ }
986
+
987
+ private async fetchBrokerPriceHistory(
988
+ ticker: string,
989
+ exchange: string,
990
+ range: TimeRange,
991
+ context?: MarketDataRequestContext,
992
+ ): Promise<SourceResult<PricePoint[]> | null> {
993
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
994
+ const variantKey = buildVariantKey([["exchange", normalizeExchange(exchange)], ["range", range]]);
995
+ for (const candidate of this.getBrokerCandidatesForContext(context, false)) {
996
+ if (!candidate.broker.getPriceHistory) continue;
997
+ try {
998
+ const result = normalizePriceHistory(await candidate.broker.getPriceHistory(
999
+ ticker,
1000
+ candidate.instance,
1001
+ exchange,
1002
+ range,
1003
+ context?.instrument ?? null,
1004
+ ));
1005
+ this.cacheResource(
1006
+ "price-history",
1007
+ entityKey,
1008
+ variantKey,
1009
+ this.brokerSourceKey(candidate),
1010
+ result,
1011
+ this.resolveBrokerPolicy(isIntradayRange(range) ? "priceHistoryIntraday" : "priceHistoryDaily", candidate.broker),
1012
+ );
1013
+ return { sourceKey: this.brokerSourceKey(candidate), value: result };
1014
+ } catch {
1015
+ // continue
1016
+ }
1017
+ }
1018
+ return null;
1019
+ }
1020
+
1021
+ private async fetchProviderPriceHistory(
1022
+ ticker: string,
1023
+ exchange: string,
1024
+ range: TimeRange,
1025
+ context?: MarketDataRequestContext,
1026
+ ): Promise<SourceResult<PricePoint[]> | null> {
1027
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
1028
+ const variantKey = buildVariantKey([["exchange", normalizeExchange(exchange)], ["range", range]]);
1029
+ let firstEmptyResult: SourceResult<PricePoint[]> | null = null;
1030
+
1031
+ for (const provider of this.providersInPriorityOrder()) {
1032
+ try {
1033
+ const value = normalizePriceHistory(await provider.getPriceHistory(ticker, exchange, range, context));
1034
+ const sourceKey = this.providerSourceKey(provider);
1035
+ this.cacheResource(
1036
+ "price-history",
1037
+ entityKey,
1038
+ variantKey,
1039
+ sourceKey,
1040
+ value,
1041
+ this.resolveProviderPolicy(isIntradayRange(range) ? "priceHistoryIntraday" : "priceHistoryDaily", provider),
1042
+ );
1043
+ if (value.length > 0) {
1044
+ return { sourceKey, value };
1045
+ }
1046
+ firstEmptyResult ??= { sourceKey, value };
1047
+ } catch (error) {
1048
+ if (shouldLogProviderError(error)) {
1049
+ providerLog.error(`${provider.id} failed: ${error}`);
1050
+ }
1051
+ }
1052
+ }
1053
+
1054
+ return firstEmptyResult;
1055
+ }
1056
+
1057
+ private async fetchBrokerDetailedPriceHistory(
1058
+ ticker: string,
1059
+ exchange: string,
1060
+ startDate: Date,
1061
+ endDate: Date,
1062
+ barSize: string,
1063
+ context?: MarketDataRequestContext,
1064
+ ): Promise<SourceResult<PricePoint[]> | null> {
1065
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
1066
+ const variantKey = buildVariantKey([["exchange", normalizeExchange(exchange)], ["start", compactDate(startDate)], ["end", compactDate(endDate)], ["bar", barSize]]);
1067
+ for (const candidate of this.getBrokerCandidatesForContext(context, false)) {
1068
+ if (!candidate.broker.getDetailedPriceHistory) continue;
1069
+ try {
1070
+ const result = normalizePriceHistory(await candidate.broker.getDetailedPriceHistory(
1071
+ ticker,
1072
+ candidate.instance,
1073
+ exchange,
1074
+ startDate,
1075
+ endDate,
1076
+ barSize,
1077
+ context?.instrument ?? null,
1078
+ ));
1079
+ this.cacheResource(
1080
+ "detailed-price-history",
1081
+ entityKey,
1082
+ variantKey,
1083
+ this.brokerSourceKey(candidate),
1084
+ result,
1085
+ this.resolveBrokerPolicy("priceHistoryIntraday", candidate.broker),
1086
+ );
1087
+ return { sourceKey: this.brokerSourceKey(candidate), value: result };
1088
+ } catch {
1089
+ // continue
1090
+ }
1091
+ }
1092
+ return null;
1093
+ }
1094
+
1095
+ private async fetchProviderDetailedPriceHistory(
1096
+ ticker: string,
1097
+ exchange: string,
1098
+ startDate: Date,
1099
+ endDate: Date,
1100
+ barSize: string,
1101
+ context?: MarketDataRequestContext,
1102
+ ): Promise<SourceResult<PricePoint[]> | null> {
1103
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
1104
+ const variantKey = buildVariantKey([["exchange", normalizeExchange(exchange)], ["start", compactDate(startDate)], ["end", compactDate(endDate)], ["bar", barSize]]);
1105
+ let firstEmptyResult: SourceResult<PricePoint[]> | null = null;
1106
+
1107
+ for (const provider of this.providersInPriorityOrder()) {
1108
+ if (!provider.getDetailedPriceHistory) continue;
1109
+ try {
1110
+ const value = normalizePriceHistory(await provider.getDetailedPriceHistory(ticker, exchange, startDate, endDate, barSize, context));
1111
+ const sourceKey = this.providerSourceKey(provider);
1112
+ this.cacheResource(
1113
+ "detailed-price-history",
1114
+ entityKey,
1115
+ variantKey,
1116
+ sourceKey,
1117
+ value,
1118
+ this.resolveProviderPolicy("priceHistoryIntraday", provider),
1119
+ );
1120
+ if (value.length > 0) {
1121
+ return { sourceKey, value };
1122
+ }
1123
+ firstEmptyResult ??= { sourceKey, value };
1124
+ } catch (error) {
1125
+ if (shouldLogProviderError(error)) {
1126
+ providerLog.error(`${provider.id} failed: ${error}`);
1127
+ }
1128
+ }
1129
+ }
1130
+
1131
+ return firstEmptyResult;
1132
+ }
1133
+
1134
+ private async fetchBrokerOptionsChain(
1135
+ ticker: string,
1136
+ exchange?: string,
1137
+ expirationDate?: number,
1138
+ context?: MarketDataRequestContext,
1139
+ ): Promise<SourceResult<OptionsChain> | null> {
1140
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
1141
+ const variantKey = buildVariantKey([["exchange", normalizeExchange(exchange)], ["expiration", expirationDate ?? "default"]]);
1142
+ for (const candidate of this.getBrokerCandidatesForContext(context, false)) {
1143
+ if (!candidate.broker.getOptionsChain) continue;
1144
+ try {
1145
+ const result = await candidate.broker.getOptionsChain(
1146
+ ticker,
1147
+ candidate.instance,
1148
+ exchange,
1149
+ expirationDate,
1150
+ context?.instrument ?? null,
1151
+ );
1152
+ this.cacheResource(
1153
+ "options-chain",
1154
+ entityKey,
1155
+ variantKey,
1156
+ this.brokerSourceKey(candidate),
1157
+ result,
1158
+ this.resolveBrokerPolicy("optionsChain", candidate.broker),
1159
+ );
1160
+ return { sourceKey: this.brokerSourceKey(candidate), value: result };
1161
+ } catch {
1162
+ // continue
1163
+ }
1164
+ }
1165
+ return null;
1166
+ }
1167
+
1168
+ private async fetchProviderOptionsChain(
1169
+ ticker: string,
1170
+ exchange?: string,
1171
+ expirationDate?: number,
1172
+ context?: MarketDataRequestContext,
1173
+ ): Promise<SourceResult<OptionsChain> | null> {
1174
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
1175
+ const variantKey = buildVariantKey([["exchange", normalizeExchange(exchange)], ["expiration", expirationDate ?? "default"]]);
1176
+ const result = await this.firstProvider(async (provider) => {
1177
+ if (!provider.getOptionsChain) return null;
1178
+ return provider.getOptionsChain(ticker, exchange, expirationDate, context);
1179
+ });
1180
+ if (!result) return null;
1181
+ const provider = this.resolveProviderBySourceKey(result.sourceKey);
1182
+ if (provider) {
1183
+ this.cacheResource("options-chain", entityKey, variantKey, result.sourceKey, result.value, this.resolveProviderPolicy("optionsChain", provider));
1184
+ }
1185
+ return result;
1186
+ }
1187
+
1188
+ private async fetchProviderSecFilings(
1189
+ ticker: string,
1190
+ count = 15,
1191
+ exchange?: string,
1192
+ context?: MarketDataRequestContext,
1193
+ ): Promise<SourceResult<SecFilingItem[]> | null> {
1194
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
1195
+ const variantKey = buildVariantKey([["exchange", normalizeExchange(exchange)], ["count", count]]);
1196
+ let lastError: unknown = null;
1197
+
1198
+ for (const provider of this.providersInPriorityOrder()) {
1199
+ if (!provider.getSecFilings) continue;
1200
+ try {
1201
+ const value = await provider.getSecFilings(ticker, count, exchange, context);
1202
+ const sourceKey = this.providerSourceKey(provider);
1203
+ this.cacheResource("sec-filings", entityKey, variantKey, sourceKey, value, this.resolveProviderPolicy("secFilings", provider));
1204
+ return { sourceKey, value };
1205
+ } catch (error) {
1206
+ lastError = error;
1207
+ if (shouldLogProviderError(error)) {
1208
+ providerLog.error(`${provider.id} failed: ${error}`);
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ if (lastError) throw lastError;
1214
+ return null;
1215
+ }
1216
+
1217
+ private async fetchProviderSecFilingContent(filing: SecFilingItem): Promise<SourceResult<string | null> | null> {
1218
+ const entityKey = compactUrl(filing.primaryDocumentUrl ?? filing.filingUrl);
1219
+ let lastError: unknown = null;
1220
+
1221
+ for (const provider of this.providersInPriorityOrder()) {
1222
+ if (!provider.getSecFilingContent) continue;
1223
+ try {
1224
+ const value = await provider.getSecFilingContent(filing);
1225
+ const sourceKey = this.providerSourceKey(provider);
1226
+ this.cacheResource("sec-filing-content", entityKey, "", sourceKey, value, this.resolveProviderPolicy("secFilingContent", provider));
1227
+ return { sourceKey, value };
1228
+ } catch (error) {
1229
+ lastError = error;
1230
+ if (shouldLogProviderError(error)) {
1231
+ providerLog.error(`${provider.id} failed: ${error}`);
1232
+ }
1233
+ }
1234
+ }
1235
+
1236
+ if (lastError) throw lastError;
1237
+ return null;
1238
+ }
1239
+
1240
+ private resolveProviderBySourceKey(sourceKey: string): DataProvider | null {
1241
+ for (const provider of this.providersInPriorityOrder()) {
1242
+ if (this.providerSourceKey(provider) === sourceKey) return provider;
1243
+ }
1244
+ return null;
1245
+ }
1246
+
1247
+ private async revalidateFinancials(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<void> {
1248
+ const brokerResult = await withBrokerTimeout(this.fetchBrokerFinancials(ticker, exchange, context));
1249
+ const needsProvider = !brokerResult || !hasMeaningfulFundamentals(brokerResult.value) || !hasMeaningfulProfile(brokerResult.value);
1250
+ if (needsProvider) {
1251
+ await this.fetchProviderFinancials(ticker, exchange, context);
1252
+ }
1253
+ }
1254
+
1255
+ private async revalidateQuote(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<void> {
1256
+ const brokerQuote = await withBrokerTimeout(this.fetchBrokerQuote(ticker, exchange, context));
1257
+ if (!brokerQuote) {
1258
+ await this.fetchProviderQuote(ticker, exchange, context);
1259
+ }
1260
+ }
1261
+
1262
+ private async revalidateExchangeRate(fromCurrency: string): Promise<void> {
1263
+ const result = await this.firstProvider(async (provider) => {
1264
+ const rate = await provider.getExchangeRate(fromCurrency);
1265
+ return { provider, rate };
1266
+ });
1267
+ if (!result) return;
1268
+ this.cacheExchangeRate(fromCurrency, result.value.rate, result.value.provider);
1269
+ }
1270
+
1271
+ private async revalidateNews(ticker: string, count = 15, exchange?: string, context?: MarketDataRequestContext): Promise<void> {
1272
+ const result = await this.firstProvider((provider) => provider.getNews(ticker, count, exchange, context));
1273
+ if (!result) return;
1274
+ const provider = this.resolveProviderBySourceKey(result.sourceKey);
1275
+ if (!provider) return;
1276
+ this.cacheResource(
1277
+ "news",
1278
+ this.getEntityKey(ticker, exchange, context?.instrument),
1279
+ buildVariantKey([["exchange", normalizeExchange(exchange)], ["count", count]]),
1280
+ result.sourceKey,
1281
+ result.value,
1282
+ this.resolveProviderPolicy("news", provider),
1283
+ );
1284
+ }
1285
+
1286
+ private async revalidateSecFilings(ticker: string, count = 15, exchange?: string, context?: MarketDataRequestContext): Promise<void> {
1287
+ await this.fetchProviderSecFilings(ticker, count, exchange, context);
1288
+ }
1289
+
1290
+ private async revalidateSecFilingContent(filing: SecFilingItem): Promise<void> {
1291
+ await this.fetchProviderSecFilingContent(filing);
1292
+ }
1293
+
1294
+ private async revalidateArticleSummary(url: string): Promise<void> {
1295
+ const result = await this.firstProvider((provider) => provider.getArticleSummary(url));
1296
+ if (!result || !result.value) return;
1297
+ const provider = this.resolveProviderBySourceKey(result.sourceKey);
1298
+ if (provider) {
1299
+ this.cacheResource("article-summary", compactUrl(url), "", result.sourceKey, result.value, this.resolveProviderPolicy("articleSummary", provider));
1300
+ }
1301
+ }
1302
+
1303
+ private async revalidatePriceHistory(ticker: string, exchange: string, range: TimeRange, context?: MarketDataRequestContext): Promise<void> {
1304
+ const brokerHistory = await withBrokerTimeout(this.fetchBrokerPriceHistory(ticker, exchange, range, context));
1305
+ if (!brokerHistory || brokerHistory.value.length === 0) {
1306
+ await this.fetchProviderPriceHistory(ticker, exchange, range, context);
1307
+ }
1308
+ }
1309
+
1310
+ private async revalidateDetailedPriceHistory(
1311
+ ticker: string,
1312
+ exchange: string,
1313
+ startDate: Date,
1314
+ endDate: Date,
1315
+ barSize: string,
1316
+ context?: MarketDataRequestContext,
1317
+ ): Promise<void> {
1318
+ const brokerHistory = await withBrokerTimeout(this.fetchBrokerDetailedPriceHistory(ticker, exchange, startDate, endDate, barSize, context));
1319
+ if (!brokerHistory || brokerHistory.value.length === 0) {
1320
+ await this.fetchProviderDetailedPriceHistory(ticker, exchange, startDate, endDate, barSize, context);
1321
+ }
1322
+ }
1323
+
1324
+ private async revalidateOptionsChain(
1325
+ ticker: string,
1326
+ exchange?: string,
1327
+ expirationDate?: number,
1328
+ context?: MarketDataRequestContext,
1329
+ ): Promise<void> {
1330
+ const brokerChain = await withBrokerTimeout(this.fetchBrokerOptionsChain(ticker, exchange, expirationDate, context));
1331
+ if (!brokerChain) {
1332
+ await this.fetchProviderOptionsChain(ticker, exchange, expirationDate, context);
1333
+ }
1334
+ }
1335
+
1336
+ subscribeQuotes(
1337
+ targets: QuoteSubscriptionTarget[],
1338
+ onQuote: (target: QuoteSubscriptionTarget, quote: Quote) => void,
1339
+ ): () => void {
1340
+ const streamingProvider = this.providersInPriorityOrder().find((provider) => typeof provider.subscribeQuotes === "function") ?? null;
1341
+ const brokerGroups = new Map<string, { candidate: BrokerCandidate; targets: QuoteSubscriptionTarget[] }>();
1342
+ const providerTargets: QuoteSubscriptionTarget[] = [];
1343
+ const addBrokerTarget = (brokerCandidate: BrokerCandidate, target: QuoteSubscriptionTarget) => {
1344
+ const key = this.brokerSourceKey(brokerCandidate);
1345
+ const group = brokerGroups.get(key) ?? { candidate: brokerCandidate, targets: [] };
1346
+ group.targets.push(target);
1347
+ brokerGroups.set(key, group);
1348
+ };
1349
+
1350
+ for (const target of targets) {
1351
+ if (target.route === "provider") {
1352
+ const brokerCandidate = this.getStreamingBrokerCandidate(target);
1353
+ if (streamingProvider) {
1354
+ providerTargets.push(target);
1355
+ } else if (brokerCandidate) {
1356
+ addBrokerTarget(brokerCandidate, target);
1357
+ }
1358
+ continue;
1359
+ }
1360
+ const brokerCandidate = this.getStreamingBrokerCandidate(target);
1361
+ if (target.route === "broker" && brokerCandidate) {
1362
+ addBrokerTarget(brokerCandidate, target);
1363
+ continue;
1364
+ }
1365
+ if (!brokerCandidate || streamingProvider) {
1366
+ providerTargets.push(target);
1367
+ continue;
1368
+ }
1369
+
1370
+ addBrokerTarget(brokerCandidate, target);
1371
+ }
1372
+
1373
+ const unsubscribers: Array<() => void> = [];
1374
+
1375
+ for (const { candidate, targets: brokerTargets } of brokerGroups.values()) {
1376
+ providerLog.info("Delegating broker quote stream", {
1377
+ brokerId: candidate.brokerId,
1378
+ brokerInstanceId: candidate.brokerInstanceId,
1379
+ targetCount: brokerTargets.length,
1380
+ });
1381
+ unsubscribers.push(candidate.broker.subscribeQuotes!(candidate.instance, brokerTargets, onQuote));
1382
+ }
1383
+
1384
+ if (providerTargets.length > 0 && streamingProvider?.subscribeQuotes) {
1385
+ providerLog.info("Delegating provider quote stream", {
1386
+ providerId: streamingProvider.id,
1387
+ targetCount: providerTargets.length,
1388
+ });
1389
+ unsubscribers.push(streamingProvider.subscribeQuotes(providerTargets, onQuote));
1390
+ }
1391
+
1392
+ if (unsubscribers.length === 0) {
1393
+ providerLog.warn("No provider supports quote streaming", {
1394
+ targetCount: targets.length,
1395
+ });
1396
+ return () => {};
1397
+ }
1398
+
1399
+ return () => {
1400
+ for (const unsubscribe of unsubscribers) {
1401
+ unsubscribe();
1402
+ }
1403
+ };
1404
+ }
1405
+ }