gloomberb 0.2.0 → 0.3.2

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 (311) 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 +1365 -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 +1546 -0
  45. package/src/components/command-bar/command-bar.tsx +4841 -0
  46. package/src/components/command-bar/command-registry.ts +269 -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 +151 -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 +651 -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/markdown-text.tsx +218 -0
  70. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  71. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  72. package/src/components/pane-settings-dialog.tsx +468 -0
  73. package/src/components/pane-template-wizard.test.tsx +52 -0
  74. package/src/components/pane-template-wizard.tsx +199 -0
  75. package/src/components/price-selector-dialog.tsx +195 -0
  76. package/src/components/spinner.tsx +2 -0
  77. package/src/components/tab-bar.tsx +17 -0
  78. package/src/components/ticker-badge-text.test.tsx +160 -0
  79. package/src/components/ticker-badge-text.tsx +58 -0
  80. package/src/components/ticker-badge.tsx +88 -0
  81. package/src/components/ticker-list-table.tsx +188 -0
  82. package/src/components/toggle-list.tsx +74 -0
  83. package/src/components/ui/button.tsx +77 -0
  84. package/src/components/ui/data-table.tsx +210 -0
  85. package/src/components/ui/external-link.tsx +22 -0
  86. package/src/components/ui/fields.tsx +187 -0
  87. package/src/components/ui/frame.tsx +73 -0
  88. package/src/components/ui/index.ts +53 -0
  89. package/src/components/ui/list-view.tsx +166 -0
  90. package/src/components/ui/loading.tsx +70 -0
  91. package/src/components/ui/status.tsx +83 -0
  92. package/src/components/ui/tabs.tsx +47 -0
  93. package/src/components/ui/toggle.tsx +164 -0
  94. package/src/components/ui/ui.test.tsx +134 -0
  95. package/src/data/app-persistence.ts +25 -0
  96. package/src/data/config-store.test.ts +347 -0
  97. package/src/data/config-store.ts +507 -0
  98. package/src/data/plugin-state-store.ts +102 -0
  99. package/src/data/resource-store.ts +323 -0
  100. package/src/data/session-store.ts +52 -0
  101. package/src/data/sqlite-cache.test.ts +103 -0
  102. package/src/data/sqlite-database.ts +62 -0
  103. package/src/data/sqlite-json.ts +19 -0
  104. package/src/data/ticker-repository.ts +45 -0
  105. package/src/data/ticker-store.ts +46 -0
  106. package/src/index.tsx +67 -0
  107. package/src/market-data/coordinator.test.ts +369 -0
  108. package/src/market-data/coordinator.ts +580 -0
  109. package/src/market-data/hooks.tsx +234 -0
  110. package/src/market-data/query-store.ts +28 -0
  111. package/src/market-data/request-types.test.ts +64 -0
  112. package/src/market-data/request-types.ts +70 -0
  113. package/src/market-data/result-types.ts +56 -0
  114. package/src/market-data/selectors.ts +166 -0
  115. package/src/market-data/trace.ts +7 -0
  116. package/src/pane-settings.test.ts +55 -0
  117. package/src/pane-settings.ts +73 -0
  118. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +350 -0
  119. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  120. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  121. package/src/plugins/builtin/ai/index.tsx +86 -0
  122. package/src/plugins/builtin/ai/providers.ts +99 -0
  123. package/src/plugins/builtin/ai/runner.ts +82 -0
  124. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  125. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  126. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  127. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  128. package/src/plugins/builtin/ai/settings.ts +79 -0
  129. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  130. package/src/plugins/builtin/ai/utils.ts +71 -0
  131. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  132. package/src/plugins/builtin/ask-ai.tsx +9 -0
  133. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  134. package/src/plugins/builtin/chat-controller.ts +340 -0
  135. package/src/plugins/builtin/chat.test.tsx +451 -0
  136. package/src/plugins/builtin/chat.tsx +620 -0
  137. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  138. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  139. package/src/plugins/builtin/data-providers.ts +16 -0
  140. package/src/plugins/builtin/debug.tsx +351 -0
  141. package/src/plugins/builtin/help.test.tsx +110 -0
  142. package/src/plugins/builtin/help.tsx +281 -0
  143. package/src/plugins/builtin/layout-manager.tsx +264 -0
  144. package/src/plugins/builtin/manual-entry.tsx +25 -0
  145. package/src/plugins/builtin/news.tsx +143 -0
  146. package/src/plugins/builtin/notes-files.ts +58 -0
  147. package/src/plugins/builtin/notes.tsx +396 -0
  148. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  149. package/src/plugins/builtin/options-availability.ts +158 -0
  150. package/src/plugins/builtin/options.tsx +364 -0
  151. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  152. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  153. package/src/plugins/builtin/portfolio-list/metrics.ts +460 -0
  154. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  155. package/src/plugins/builtin/portfolio-list/settings.ts +270 -0
  156. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  157. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  158. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  159. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  160. package/src/plugins/builtin/portfolio-metrics.test.ts +224 -0
  161. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  162. package/src/plugins/builtin/sec.tsx +250 -0
  163. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  164. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  165. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  166. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  167. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  168. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  169. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  170. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  171. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  172. package/src/plugins/builtin/yahoo.ts +18 -0
  173. package/src/plugins/event-bus.ts +37 -0
  174. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  175. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  176. package/src/plugins/ibkr/account-cache.ts +137 -0
  177. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  178. package/src/plugins/ibkr/config.test.ts +68 -0
  179. package/src/plugins/ibkr/config.ts +262 -0
  180. package/src/plugins/ibkr/dialogs.tsx +83 -0
  181. package/src/plugins/ibkr/flex.test.ts +107 -0
  182. package/src/plugins/ibkr/flex.ts +267 -0
  183. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  184. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  185. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  186. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  187. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  188. package/src/plugins/ibkr/index.test.tsx +183 -0
  189. package/src/plugins/ibkr/index.tsx +153 -0
  190. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  191. package/src/plugins/ibkr/instance-selection.ts +36 -0
  192. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  193. package/src/plugins/ibkr/price-normalization.ts +59 -0
  194. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  195. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  196. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  197. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  198. package/src/plugins/ibkr/trade-utils.ts +149 -0
  199. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  200. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  201. package/src/plugins/ibkr/trading-state.ts +339 -0
  202. package/src/plugins/loader.ts +71 -0
  203. package/src/plugins/pane-manager.test.ts +103 -0
  204. package/src/plugins/pane-manager.ts +974 -0
  205. package/src/plugins/plugin-persistence.ts +85 -0
  206. package/src/plugins/plugin-runtime.test.tsx +185 -0
  207. package/src/plugins/plugin-runtime.tsx +175 -0
  208. package/src/plugins/prediction-markets/cache.ts +114 -0
  209. package/src/plugins/prediction-markets/categories.ts +223 -0
  210. package/src/plugins/prediction-markets/chart.tsx +512 -0
  211. package/src/plugins/prediction-markets/columns.ts +133 -0
  212. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  213. package/src/plugins/prediction-markets/controller.ts +725 -0
  214. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  215. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  216. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  217. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  218. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  219. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  220. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  221. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  222. package/src/plugins/prediction-markets/index.tsx +102 -0
  223. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  224. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  225. package/src/plugins/prediction-markets/launch.ts +240 -0
  226. package/src/plugins/prediction-markets/metrics.ts +311 -0
  227. package/src/plugins/prediction-markets/navigation.ts +90 -0
  228. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  229. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  230. package/src/plugins/prediction-markets/pane.tsx +294 -0
  231. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  232. package/src/plugins/prediction-markets/rows.ts +229 -0
  233. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  234. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  235. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  236. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  237. package/src/plugins/prediction-markets/settings.ts +164 -0
  238. package/src/plugins/prediction-markets/status.ts +24 -0
  239. package/src/plugins/prediction-markets/table.tsx +84 -0
  240. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  241. package/src/plugins/prediction-markets/types.ts +186 -0
  242. package/src/plugins/registry.ts +633 -0
  243. package/src/sources/exchange-rates.ts +18 -0
  244. package/src/sources/gloomberb-cloud.test.ts +199 -0
  245. package/src/sources/gloomberb-cloud.ts +326 -0
  246. package/src/sources/provider-errors.ts +19 -0
  247. package/src/sources/provider-router.test.ts +1476 -0
  248. package/src/sources/provider-router.ts +1405 -0
  249. package/src/sources/sec-edgar.test.ts +286 -0
  250. package/src/sources/sec-edgar.ts +526 -0
  251. package/src/sources/yahoo-finance.test.ts +14 -0
  252. package/src/sources/yahoo-finance.ts +1017 -0
  253. package/src/state/app-activity.test.ts +41 -0
  254. package/src/state/app-activity.ts +106 -0
  255. package/src/state/app-bootstrap.test.ts +471 -0
  256. package/src/state/app-bootstrap.ts +308 -0
  257. package/src/state/app-context.test.ts +406 -0
  258. package/src/state/app-context.test.tsx +162 -0
  259. package/src/state/app-context.tsx +981 -0
  260. package/src/state/selectors.ts +67 -0
  261. package/src/state/session-persistence.test.ts +128 -0
  262. package/src/state/session-persistence.ts +161 -0
  263. package/src/state/ticker-refresh-queue.test.ts +46 -0
  264. package/src/state/ticker-refresh-queue.ts +40 -0
  265. package/src/state/use-inline-tickers.ts +174 -0
  266. package/src/state/use-quote-streaming.test.ts +37 -0
  267. package/src/state/use-quote-streaming.test.tsx +70 -0
  268. package/src/state/use-quote-streaming.ts +80 -0
  269. package/src/state/use-session-snapshot.ts +84 -0
  270. package/src/test-support/data-provider.ts +22 -0
  271. package/src/theme/color-utils.ts +67 -0
  272. package/src/theme/colors.ts +128 -0
  273. package/src/theme/themes.test.ts +45 -0
  274. package/src/theme/themes.ts +491 -0
  275. package/src/types/broker.ts +105 -0
  276. package/src/types/config.ts +444 -0
  277. package/src/types/data-provider.ts +81 -0
  278. package/src/types/financials.ts +142 -0
  279. package/src/types/instrument.ts +29 -0
  280. package/src/types/persistence.ts +21 -0
  281. package/src/types/plugin.ts +354 -0
  282. package/src/types/ticker.ts +58 -0
  283. package/src/types/trading.ts +85 -0
  284. package/src/updater.test.ts +282 -0
  285. package/src/updater.ts +303 -0
  286. package/src/utils/api-client.ts +666 -0
  287. package/src/utils/ascii-chart.ts +172 -0
  288. package/src/utils/broker-instances.ts +47 -0
  289. package/src/utils/cli-output.ts +83 -0
  290. package/src/utils/currency-units.test.ts +49 -0
  291. package/src/utils/currency-units.ts +82 -0
  292. package/src/utils/debug-log.ts +133 -0
  293. package/src/utils/exchange-rate-map.ts +8 -0
  294. package/src/utils/format.ts +158 -0
  295. package/src/utils/fuzzy-search.ts +34 -0
  296. package/src/utils/market-status.ts +95 -0
  297. package/src/utils/options.ts +86 -0
  298. package/src/utils/price-history.ts +39 -0
  299. package/src/utils/quote-time.test.ts +22 -0
  300. package/src/utils/quote-time.ts +50 -0
  301. package/src/utils/sec.test.ts +55 -0
  302. package/src/utils/sec.ts +45 -0
  303. package/src/utils/ticker-list.test.ts +22 -0
  304. package/src/utils/ticker-list.ts +35 -0
  305. package/src/utils/ticker-metadata.ts +51 -0
  306. package/src/utils/ticker-search.test.ts +227 -0
  307. package/src/utils/ticker-search.ts +764 -0
  308. package/src/utils/ticker-tokenizer.test.ts +48 -0
  309. package/src/utils/ticker-tokenizer.ts +72 -0
  310. package/src/version.ts +1 -0
  311. package/install.mjs +0 -68
@@ -0,0 +1,1017 @@
1
+ import type { Quote, Fundamentals, FinancialStatement, PricePoint, TickerFinancials, MarketState, OptionContract, OptionsChain, CompanyProfile } from "../types/financials";
2
+ import type { DataProvider, MarketDataRequestContext, NewsItem, SecFilingItem } from "../types/data-provider";
3
+ import type { TimeRange } from "../components/chart/chart-types";
4
+ import type { InstrumentSearchResult } from "../types/instrument";
5
+ import { SecEdgarClient } from "./sec-edgar";
6
+
7
+ // Exchange suffix mapping for Yahoo Finance ticker symbols
8
+ // Includes both canonical codes and common IBKR listing exchange aliases
9
+ const EXCHANGE_SUFFIX_MAP: Record<string, string> = {
10
+ // US exchanges
11
+ NASDAQ: "", NMS: "", NYSE: "", AMEX: "", ARCA: "", NYSEArca: "", BATS: "", BYX: "", IEX: "", PINK: "", OTC: "",
12
+ // Canada
13
+ TSX: ".TO", VENTURE: ".V", CSE2: ".CN", CNSX: ".CN",
14
+ // Japan (note: TSE is ambiguous — handled via EXCHANGE_FALLBACKS)
15
+ TYO: ".T", JPX: ".T", TSEJ: ".T",
16
+ // Hong Kong
17
+ HKEX: ".HK", SEHK: ".HK", HKG: ".HK",
18
+ // China
19
+ SSE: ".SS", SHG: ".SS", SZSE: ".SZ", SHE: ".SZ",
20
+ // Taiwan
21
+ TWSE: ".TW", TPE: ".TW", TPEX: ".TWO",
22
+ // Korea
23
+ KRX: ".KS", KSE: ".KS", KOSDAQ: ".KQ",
24
+ // Singapore
25
+ SGX: ".SI", SES: ".SI",
26
+ // Indonesia
27
+ IDX: ".JK",
28
+ // India
29
+ NSE: ".NS", BSE: ".BO",
30
+ // Australia & New Zealand
31
+ ASX: ".AX", NZE: ".NZ",
32
+ // Thailand, Malaysia, Philippines, Vietnam
33
+ SET: ".BK", BKK: ".BK", KLSE: ".KL", MYX: ".KL", PSE: ".PS", HOSE: ".VN", HNX: ".VN",
34
+ // UK
35
+ LSE: ".L", LSEETF: ".L",
36
+ // Germany
37
+ XETRA: ".DE", XETR: ".DE", IBIS: ".DE", IBIS2: ".DE", FWB: ".F", FWB2: ".F", GETTEX: ".DE", TGATE: ".DE", SWB: ".SG",
38
+ // France, Netherlands, Belgium, Portugal (Euronext)
39
+ EURONEXT: ".AS", AEB: ".AS", SBF: ".PA", "ENEXT.BE": ".BR", BVL: ".LS",
40
+ // Italy & Spain
41
+ BVME: ".MI", BM: ".MC",
42
+ // Switzerland
43
+ SIX: ".SW", EBS: ".SW", SWX: ".SW",
44
+ // Nordics
45
+ SFB: ".ST", Stockholm: ".ST", OMX: ".ST", CPH: ".CO", HEX: ".HE", OSE: ".OL", OMXNO: ".OL", ICEX: ".IC",
46
+ // Central/Eastern Europe
47
+ VSE: ".VI", WSE: ".WA", PRA: ".PR", BUX: ".BD", ATHEX: ".AT", BVB: ".RO", BIST: ".IS",
48
+ // Israel
49
+ TASE: ".TA",
50
+ // South Africa
51
+ JSE: ".JO",
52
+ // Brazil & Latin America
53
+ BVMF: ".SA", MEXI: ".MX", BYMA: ".BA", BCS: ".SN",
54
+ // Middle East
55
+ TADAWUL: ".SAU", QSE: ".QA", DFM: ".AE",
56
+ };
57
+
58
+ const EXCHANGE_FALLBACKS: Record<string, string[]> = {
59
+ // TSE is ambiguous: Toronto (.TO) vs Tokyo (.T) — try both
60
+ TSE: [".TO", ".T"],
61
+ KRX: [".KS", ".KQ"], KSE: [".KS", ".KQ"],
62
+ TWSE: [".TW", ".TWO"], TPE: [".TW", ".TWO"], TPEX: [".TWO", ".TW"],
63
+ FWB2: [".F", ".DE"],
64
+ EURONEXT: [".AS", ".PA", ".BR"], AEB: [".AS", ".PA", ".BR"], SBF: [".PA", ".AS", ".BR"],
65
+ };
66
+
67
+ const GENERIC_SUFFIX_FALLBACKS = [
68
+ "", ".HK", ".T", ".TO", ".KS", ".KQ", ".TW", ".TWO", ".SS", ".SZ",
69
+ ".AS", ".PA", ".BR", ".DE", ".F", ".L", ".MI", ".MC", ".SW", ".AX",
70
+ ".SI", ".JK", ".OL", ".ST", ".CO", ".HE", ".NS", ".BO", ".SA",
71
+ ".BK", ".KL", ".NZ", ".JO", ".TA", ".WA", ".VI",
72
+ ];
73
+
74
+ const MAX_RETRIES = 3;
75
+ const RETRY_BASE_MS = 1500;
76
+ const FETCH_TIMEOUT_MS = 20_000;
77
+ const RETRYABLE_ERROR = /429|403|401|Too Many Requests|Forbidden|Unauthorized|ECONNRESET|ETIMEDOUT|ENOTFOUND|fetch failed|Failed to get crumb|socket hang up|503|502|504/i;
78
+
79
+ const TIMESERIES_TYPES = {
80
+ annual: [
81
+ // Income Statement
82
+ "annualTotalRevenue", "annualCostOfRevenue", "annualGrossProfit",
83
+ "annualSellingGeneralAndAdministration", "annualResearchAndDevelopment",
84
+ "annualOperatingExpense", "annualOperatingIncome",
85
+ "annualInterestExpense", "annualTaxProvision",
86
+ "annualNetIncome", "annualEBITDA",
87
+ "annualBasicEPS", "annualDilutedEPS", "annualDilutedAverageShares",
88
+ // Cash Flow
89
+ "annualOperatingCashFlow", "annualCapitalExpenditure", "annualFreeCashFlow",
90
+ "annualInvestingCashFlow", "annualFinancingCashFlow",
91
+ "annualIssuanceOfDebt", "annualRepurchaseOfCapitalStock", "annualCashDividendsPaid",
92
+ // Balance Sheet
93
+ "annualTotalAssets", "annualCurrentAssets", "annualCashAndCashEquivalents",
94
+ "annualTotalLiabilitiesNetMinorityInterest", "annualCurrentLiabilities",
95
+ "annualLongTermDebt", "annualTotalDebt",
96
+ "annualStockholdersEquity", "annualRetainedEarnings",
97
+ ],
98
+ quarterly: [
99
+ // Income Statement
100
+ "quarterlyTotalRevenue", "quarterlyCostOfRevenue", "quarterlyGrossProfit",
101
+ "quarterlySellingGeneralAndAdministration", "quarterlyResearchAndDevelopment",
102
+ "quarterlyOperatingExpense", "quarterlyOperatingIncome",
103
+ "quarterlyInterestExpense", "quarterlyTaxProvision",
104
+ "quarterlyNetIncome", "quarterlyEBITDA",
105
+ "quarterlyBasicEPS", "quarterlyDilutedEPS", "quarterlyDilutedAverageShares",
106
+ // Cash Flow
107
+ "quarterlyOperatingCashFlow", "quarterlyCapitalExpenditure", "quarterlyFreeCashFlow",
108
+ "quarterlyInvestingCashFlow", "quarterlyFinancingCashFlow",
109
+ "quarterlyIssuanceOfDebt", "quarterlyRepurchaseOfCapitalStock", "quarterlyCashDividendsPaid",
110
+ // Balance Sheet
111
+ "quarterlyTotalAssets", "quarterlyCurrentAssets", "quarterlyCashAndCashEquivalents",
112
+ "quarterlyTotalLiabilitiesNetMinorityInterest", "quarterlyCurrentLiabilities",
113
+ "quarterlyLongTermDebt", "quarterlyTotalDebt",
114
+ "quarterlyStockholdersEquity", "quarterlyRetainedEarnings",
115
+ ],
116
+ trailing: [
117
+ "trailingMarketCap", "trailingPeRatio", "trailingForwardPeRatio",
118
+ "trailingPegRatio", "trailingEnterpriseValue", "trailingOperatingCashFlow",
119
+ "trailingFreeCashFlow", "trailingDividendYield",
120
+ ],
121
+ };
122
+
123
+ type TradingPeriod = { start?: number; end?: number; gmtoffset?: number; timezone?: string };
124
+
125
+ type ChartResult = {
126
+ meta?: {
127
+ currency?: string; longName?: string; shortName?: string;
128
+ regularMarketPrice?: number; chartPreviousClose?: number;
129
+ fiftyTwoWeekHigh?: number; fiftyTwoWeekLow?: number;
130
+ exchangeName?: string; fullExchangeName?: string;
131
+ regularMarketTime?: number;
132
+ currentTradingPeriod?: { pre?: TradingPeriod; regular?: TradingPeriod; post?: TradingPeriod };
133
+ preMarketPrice?: number; postMarketPrice?: number;
134
+ };
135
+ timestamp?: number[];
136
+ indicators?: { quote?: Array<{ open?: (number | null)[]; high?: (number | null)[]; low?: (number | null)[]; close?: (number | null)[]; volume?: (number | null)[] }> };
137
+ };
138
+
139
+ type ChartResponse = { chart?: { result?: ChartResult[]; error?: { description?: string } | null } };
140
+ type TimeseriesResponse = { timeseries?: { result?: Array<Record<string, any>>; error?: { description?: string } | null } };
141
+ type QuoteSummaryResponse = {
142
+ quoteSummary?: {
143
+ result?: Array<{
144
+ assetProfile?: {
145
+ longBusinessSummary?: string;
146
+ sector?: string;
147
+ industry?: string;
148
+ };
149
+ summaryDetail?: {
150
+ bid?: { raw?: number } | number | null;
151
+ ask?: { raw?: number } | number | null;
152
+ bidSize?: { raw?: number } | number | null;
153
+ askSize?: { raw?: number } | number | null;
154
+ previousClose?: { raw?: number } | number | null;
155
+ open?: { raw?: number } | number | null;
156
+ dayHigh?: { raw?: number } | number | null;
157
+ dayLow?: { raw?: number } | number | null;
158
+ };
159
+ }>;
160
+ error?: { description?: string } | null;
161
+ };
162
+ };
163
+
164
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
165
+
166
+ /**
167
+ * Sub-unit currency normalization.
168
+ * Yahoo Finance returns prices for some exchanges in sub-units (e.g. pence instead of pounds).
169
+ * IBKR and most brokers report in the main currency unit, so we normalize here to avoid mismatches.
170
+ */
171
+ const SUB_UNIT_CURRENCIES: Record<string, { main: string; divisor: number }> = {
172
+ GBp: { main: "GBP", divisor: 100 },
173
+ GBX: { main: "GBP", divisor: 100 },
174
+ ILA: { main: "ILS", divisor: 100 },
175
+ ZAc: { main: "ZAR", divisor: 100 },
176
+ };
177
+
178
+ function normalizeSubUnitCurrency(currency: string): { currency: string; divisor: number } {
179
+ const sub = SUB_UNIT_CURRENCIES[currency];
180
+ if (sub) return { currency: sub.main, divisor: sub.divisor };
181
+ return { currency, divisor: 1 };
182
+ }
183
+
184
+ function financeRawNumber(value: unknown): number | undefined {
185
+ if (typeof value === "number" && Number.isFinite(value)) return value;
186
+ if (value && typeof value === "object") {
187
+ const raw = (value as { raw?: unknown }).raw;
188
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
189
+ }
190
+ return undefined;
191
+ }
192
+
193
+ function normalizePositiveMarketValue(value: number | undefined, divisor = 1): number | undefined {
194
+ if (value == null || !Number.isFinite(value) || value <= 0) return undefined;
195
+ return value / divisor;
196
+ }
197
+
198
+ function normalizeMarketValue(value: number | undefined, divisor = 1): number | undefined {
199
+ if (value == null || !Number.isFinite(value)) return undefined;
200
+ return value / divisor;
201
+ }
202
+
203
+ function deriveMarketState(meta: NonNullable<ChartResult["meta"]>): MarketState {
204
+ const ctp = meta.currentTradingPeriod;
205
+ if (!ctp) return "CLOSED";
206
+ const now = Math.floor(Date.now() / 1000);
207
+ if (ctp.regular?.start && ctp.regular?.end && now >= ctp.regular.start && now < ctp.regular.end) return "REGULAR";
208
+ if (ctp.pre?.start && ctp.pre?.end && now >= ctp.pre.start && now < ctp.pre.end) return "PRE";
209
+ if (ctp.post?.start && ctp.post?.end && now >= ctp.post.start && now < ctp.post.end) return "POST";
210
+ return "CLOSED";
211
+ }
212
+
213
+ function computeExtendedHoursChange(extPrice: number | undefined, regularPrice: number | undefined): { change?: number; changePct?: number } {
214
+ if (extPrice == null || regularPrice == null || regularPrice === 0) return {};
215
+ const change = extPrice - regularPrice;
216
+ return { change, changePct: (change / regularPrice) * 100 };
217
+ }
218
+
219
+ type ExtendedHoursData = {
220
+ preMarketPrice?: number;
221
+ preMarketChange?: number;
222
+ preMarketChangePercent?: number;
223
+ postMarketPrice?: number;
224
+ postMarketChange?: number;
225
+ postMarketChangePercent?: number;
226
+ };
227
+
228
+ /**
229
+ * Extract extended hours prices from a 1d intraday chart with includePrePost=true.
230
+ * Pre/post market data points sit outside the regular trading period timestamps.
231
+ */
232
+ function extractExtendedHoursPrices(
233
+ meta: NonNullable<ChartResult["meta"]>,
234
+ timestamps: number[],
235
+ closes: (number | null)[],
236
+ marketState: MarketState,
237
+ ): ExtendedHoursData {
238
+ const ctp = meta.currentTradingPeriod;
239
+ if (!ctp || !timestamps.length) return {};
240
+
241
+ const regStart = ctp.regular?.start ?? 0;
242
+ const regEnd = ctp.regular?.end ?? Infinity;
243
+ const regularClose = meta.regularMarketPrice ?? meta.chartPreviousClose;
244
+
245
+ if (marketState === "PRE") {
246
+ // Find the last pre-market data point (before regular open)
247
+ for (let i = timestamps.length - 1; i >= 0; i--) {
248
+ if (timestamps[i]! < regStart && closes[i] != null) {
249
+ const ext = computeExtendedHoursChange(closes[i]!, regularClose);
250
+ return { preMarketPrice: closes[i]!, preMarketChange: ext.change, preMarketChangePercent: ext.changePct };
251
+ }
252
+ }
253
+ } else if (marketState === "POST") {
254
+ // Find the last post-market data point (after regular close)
255
+ for (let i = timestamps.length - 1; i >= 0; i--) {
256
+ if (timestamps[i]! >= regEnd && closes[i] != null) {
257
+ const ext = computeExtendedHoursChange(closes[i]!, regularClose);
258
+ return { postMarketPrice: closes[i]!, postMarketChange: ext.change, postMarketChangePercent: ext.changePct };
259
+ }
260
+ }
261
+ }
262
+ return {};
263
+ }
264
+
265
+ // Cache TTLs
266
+ const QUOTE_TTL = 5 * 60_000; // 5 min
267
+ const FUNDAMENTALS_TTL = 60 * 60_000; // 1 hour
268
+ const HISTORY_TTL = 24 * 60 * 60_000; // 24 hours
269
+ const NEWS_TTL = 15 * 60_000; // 15 min
270
+ const OPTIONS_TTL = 5 * 60_000; // 5 min
271
+ const INTRADAY_HISTORY_TTL = 5 * 60_000; // 5 min for intraday ranges
272
+
273
+ // Maps TimeRange → Yahoo API { range, interval } for optimal granularity
274
+ const RANGE_PARAMS: Record<TimeRange, { range: string; interval: string; ttl: number }> = {
275
+ "1W": { range: "5d", interval: "5m", ttl: INTRADAY_HISTORY_TTL },
276
+ "1M": { range: "1mo", interval: "15m", ttl: INTRADAY_HISTORY_TTL },
277
+ "3M": { range: "3mo", interval: "1h", ttl: QUOTE_TTL },
278
+ "6M": { range: "6mo", interval: "1d", ttl: HISTORY_TTL },
279
+ "1Y": { range: "1y", interval: "1d", ttl: HISTORY_TTL },
280
+ "5Y": { range: "5y", interval: "1d", ttl: HISTORY_TTL },
281
+ "ALL": { range: "max", interval: "1wk", ttl: HISTORY_TTL },
282
+ };
283
+
284
+ export class YahooFinanceClient implements DataProvider {
285
+ readonly id = "yahoo";
286
+ readonly name = "Yahoo Finance";
287
+
288
+ private crumb: string | null = null;
289
+ private cookie: string | null = null;
290
+ private crumbPromise: Promise<void> | null = null;
291
+ private readonly secClient = new SecEdgarClient();
292
+
293
+ constructor() {}
294
+
295
+ private defaultHeaders() {
296
+ return {
297
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
298
+ Accept: "application/json,text/plain,*/*",
299
+ "Accept-Language": "en-US,en;q=0.9",
300
+ Referer: "https://finance.yahoo.com/",
301
+ };
302
+ }
303
+
304
+ /** Fetch a crumb + cookie pair needed by some Yahoo endpoints (e.g. options). */
305
+ private async ensureCrumb(): Promise<void> {
306
+ if (this.crumb && this.cookie) return;
307
+ if (this.crumbPromise) return this.crumbPromise;
308
+ this.crumbPromise = (async () => {
309
+ try {
310
+ // Step 1: get a cookie from Yahoo
311
+ const cookieResp = await fetch("https://fc.yahoo.com/", {
312
+ headers: this.defaultHeaders(),
313
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
314
+ redirect: "manual",
315
+ });
316
+ const setCookie = cookieResp.headers.get("set-cookie");
317
+ if (!setCookie) throw new Error("Failed to get Yahoo cookie");
318
+ // Extract just the cookie key=value pairs
319
+ this.cookie = setCookie.split(",").map((c) => c.split(";")[0]!.trim()).join("; ");
320
+
321
+ // Step 2: get the crumb value
322
+ const crumbResp = await fetch("https://query2.finance.yahoo.com/v1/test/getcrumb", {
323
+ headers: { ...this.defaultHeaders(), Cookie: this.cookie },
324
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
325
+ });
326
+ if (!crumbResp.ok) throw new Error(`Failed to get crumb: ${crumbResp.status}`);
327
+ this.crumb = await crumbResp.text();
328
+ if (!this.crumb) throw new Error("Empty crumb response");
329
+ } catch (err) {
330
+ this.crumb = null;
331
+ this.cookie = null;
332
+ throw err;
333
+ } finally {
334
+ this.crumbPromise = null;
335
+ }
336
+ })();
337
+ return this.crumbPromise;
338
+ }
339
+
340
+ /** Fetch JSON from a Yahoo endpoint that requires crumb authentication. */
341
+ private async fetchJsonWithCrumb<T>(label: string, url: string): Promise<T> {
342
+ return this.withRetry(label, async () => {
343
+ await this.ensureCrumb();
344
+ const separator = url.includes("?") ? "&" : "?";
345
+ const fullUrl = `${url}${separator}crumb=${encodeURIComponent(this.crumb!)}`;
346
+ const resp = await fetch(fullUrl, {
347
+ headers: { ...this.defaultHeaders(), Cookie: this.cookie! },
348
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
349
+ });
350
+ if (resp.status === 401) {
351
+ // Crumb expired — clear and let retry logic re-fetch
352
+ this.crumb = null;
353
+ this.cookie = null;
354
+ throw new Error(`[401] Invalid Crumb`);
355
+ }
356
+ if (!resp.ok) throw new Error(`[${resp.status}] ${(await resp.text()).slice(0, 200)}`);
357
+ return resp.json() as Promise<T>;
358
+ });
359
+ }
360
+
361
+ private async withRetry<T>(label: string, fn: () => Promise<T>): Promise<T> {
362
+ let lastError: any;
363
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
364
+ try {
365
+ return await fn();
366
+ } catch (error: any) {
367
+ lastError = error;
368
+ if (!RETRYABLE_ERROR.test(error?.message || String(error)) || attempt === MAX_RETRIES) throw error;
369
+ const delay = Math.min(30_000, RETRY_BASE_MS * Math.pow(2, attempt)) + Math.round(Math.random() * 300);
370
+ await sleep(delay);
371
+ }
372
+ }
373
+ throw lastError;
374
+ }
375
+
376
+ private async fetchJson<T>(label: string, url: string): Promise<T> {
377
+ return this.withRetry(label, async () => {
378
+ const resp = await fetch(url, {
379
+ headers: this.defaultHeaders(),
380
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
381
+ });
382
+ if (!resp.ok) throw new Error(`[${resp.status}] ${(await resp.text()).slice(0, 200)}`);
383
+ return resp.json() as Promise<T>;
384
+ });
385
+ }
386
+
387
+ /** All known Yahoo Finance suffixes — used to detect tickers that already include one */
388
+ private static KNOWN_SUFFIXES = new Set(
389
+ Object.values(EXCHANGE_SUFFIX_MAP).filter(Boolean)
390
+ .concat(GENERIC_SUFFIX_FALLBACKS.filter(Boolean)),
391
+ );
392
+
393
+ /** Check if the ticker already ends with a known Yahoo suffix (e.g. "6324.T") */
394
+ private tickerHasSuffix(ticker: string): boolean {
395
+ const dot = ticker.indexOf(".");
396
+ if (dot < 0) return false;
397
+ return YahooFinanceClient.KNOWN_SUFFIXES.has(ticker.slice(dot));
398
+ }
399
+
400
+ private isHKExchange(exchange: string): boolean {
401
+ return exchange === "HKEX" || exchange === "SEHK" || exchange === "HKG";
402
+ }
403
+
404
+ private normalizeTicker(ticker: string, exchange: string): string {
405
+ // Hong Kong stocks need 4-digit codes with leading zeros
406
+ if (this.isHKExchange(exchange) && /^\d+$/.test(ticker)) {
407
+ return ticker.padStart(4, "0");
408
+ }
409
+ // Yahoo Finance uses hyphens where IBKR uses spaces (e.g. "HEXA B" → "HEXA-B")
410
+ return ticker.replace(/ /g, "-");
411
+ }
412
+
413
+ private getSymbol(ticker: string, exchange: string): string {
414
+ // If ticker already contains a Yahoo suffix, use it as-is
415
+ if (this.tickerHasSuffix(ticker)) return ticker;
416
+ const suffix = EXCHANGE_SUFFIX_MAP[exchange] ?? "";
417
+ return `${this.normalizeTicker(ticker, exchange)}${suffix}`;
418
+ }
419
+
420
+ private getSymbolsToTry(ticker: string, exchange: string): string[] {
421
+ // If ticker already contains a Yahoo suffix, use it as-is
422
+ if (this.tickerHasSuffix(ticker)) return [ticker];
423
+
424
+ const normalized = this.normalizeTicker(ticker, exchange);
425
+
426
+ // For tickers with dots that aren't Yahoo suffixes (e.g. "BTC.USD"),
427
+ // also try replacing the dot with a hyphen (Yahoo uses "BTC-USD" for crypto pairs)
428
+ const dotVariant = normalized.includes(".") ? normalized.replace(/\./g, "-") : null;
429
+
430
+ const ex = (exchange || "").toUpperCase();
431
+ if (!ex) {
432
+ const symbols = new Set<string>();
433
+ // For numeric tickers with no exchange, also try HK-padded variant
434
+ const candidates = [normalized];
435
+ if (dotVariant) candidates.unshift(dotVariant); // Try hyphen variant first
436
+ if (/^\d+$/.test(normalized) && normalized.length < 4) {
437
+ candidates.push(normalized.padStart(4, "0"));
438
+ }
439
+ for (const candidate of candidates) {
440
+ for (const suffix of GENERIC_SUFFIX_FALLBACKS) symbols.add(`${candidate}${suffix}`);
441
+ }
442
+ return Array.from(symbols);
443
+ }
444
+ const fallbacks = EXCHANGE_FALLBACKS[exchange];
445
+ if (fallbacks) {
446
+ const results = fallbacks.map((s) => `${normalized}${s}`);
447
+ if (dotVariant) results.unshift(...fallbacks.map((s) => `${dotVariant}${s}`));
448
+ return results;
449
+ }
450
+ const primary = this.getSymbol(ticker, exchange);
451
+ if (dotVariant) {
452
+ const suffix = EXCHANGE_SUFFIX_MAP[exchange] ?? "";
453
+ return [`${dotVariant}${suffix}`, primary];
454
+ }
455
+ return [primary];
456
+ }
457
+
458
+ private async fetchChart(symbol: string, range: string, interval = "1d", includePrePost = false) {
459
+ const params = new URLSearchParams({ interval, range, includePrePost: String(includePrePost), events: "div,split" });
460
+ const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?${params}`;
461
+ const data = await this.fetchJson<ChartResponse>(`chart ${symbol}`, url);
462
+ const result = data.chart?.result?.[0];
463
+ if (!result?.timestamp?.length) throw new Error(data.chart?.error?.description || `No chart data for ${symbol}`);
464
+ const quote = result.indicators?.quote?.[0];
465
+ if (!quote) throw new Error(`Missing indicators for ${symbol}`);
466
+
467
+ const history: PricePoint[] = [];
468
+ for (let i = 0; i < result.timestamp.length; i++) {
469
+ const close = quote.close?.[i];
470
+ if (close == null || Number.isNaN(close)) continue;
471
+ history.push({
472
+ date: new Date((result.timestamp[i]!) * 1000),
473
+ open: quote.open?.[i] ?? undefined,
474
+ high: quote.high?.[i] ?? undefined,
475
+ low: quote.low?.[i] ?? undefined,
476
+ close,
477
+ volume: quote.volume?.[i] ?? undefined,
478
+ });
479
+ }
480
+ return { meta: result.meta || {}, history };
481
+ }
482
+
483
+ /** Fetch extended hours data using 1d intraday chart with pre/post market included */
484
+ private async fetchExtendedHoursData(symbol: string, regularPrice: number, meta: NonNullable<ChartResult["meta"]>): Promise<ExtendedHoursData> {
485
+ const marketState = deriveMarketState(meta);
486
+ if (marketState !== "PRE" && marketState !== "POST") return {};
487
+
488
+ try {
489
+ const params = new URLSearchParams({ interval: "5m", range: "1d", includePrePost: "true" });
490
+ const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?${params}`;
491
+ const data = await this.fetchJson<ChartResponse>(`ext-hours ${symbol}`, url);
492
+ const result = data.chart?.result?.[0];
493
+ if (!result?.timestamp?.length) return {};
494
+ const closes = result.indicators?.quote?.[0]?.close || [];
495
+ return extractExtendedHoursPrices(meta, result.timestamp, closes, marketState);
496
+ } catch {
497
+ return {};
498
+ }
499
+ }
500
+
501
+ private async fetchTimeseries(symbol: string, types: string[], period1 = "2010-01-01") {
502
+ const p1 = Math.floor(new Date(period1).getTime() / 1000);
503
+ const p2 = Math.floor(Date.now() / 1000);
504
+ const params = new URLSearchParams({ type: types.join(","), period1: String(p1), period2: String(p2) });
505
+ const url = `https://query1.finance.yahoo.com/ws/fundamentals-timeseries/v1/finance/timeseries/${encodeURIComponent(symbol)}?${params}`;
506
+ const data = await this.fetchJson<TimeseriesResponse>(`timeseries ${symbol}`, url);
507
+ return data.timeseries?.result || [];
508
+ }
509
+
510
+ private async fetchAssetProfile(symbol: string): Promise<CompanyProfile | undefined> {
511
+ const params = new URLSearchParams({ modules: "assetProfile" });
512
+ const url = `https://query1.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?${params}`;
513
+ const data = await this.fetchJsonWithCrumb<QuoteSummaryResponse>(`asset profile ${symbol}`, url);
514
+ const profile = data.quoteSummary?.result?.[0]?.assetProfile;
515
+ if (!profile) return undefined;
516
+
517
+ const normalized: CompanyProfile = {
518
+ description: profile.longBusinessSummary?.trim() || undefined,
519
+ sector: profile.sector?.trim() || undefined,
520
+ industry: profile.industry?.trim() || undefined,
521
+ };
522
+
523
+ return normalized.description || normalized.sector || normalized.industry ? normalized : undefined;
524
+ }
525
+
526
+ private async fetchQuoteSupplement(
527
+ symbol: string,
528
+ currencyDivisor = 1,
529
+ ): Promise<Pick<Quote, "bid" | "ask" | "bidSize" | "askSize" | "previousClose" | "open" | "high" | "low">> {
530
+ try {
531
+ const params = new URLSearchParams({ modules: "summaryDetail" });
532
+ const url = `https://query1.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?${params}`;
533
+ const data = await this.fetchJsonWithCrumb<QuoteSummaryResponse>(`quote supplement ${symbol}`, url);
534
+ const summaryDetail = data.quoteSummary?.result?.[0]?.summaryDetail;
535
+ if (!summaryDetail) return {};
536
+
537
+ const bid = normalizePositiveMarketValue(financeRawNumber(summaryDetail.bid), currencyDivisor);
538
+ const ask = normalizePositiveMarketValue(financeRawNumber(summaryDetail.ask), currencyDivisor);
539
+ const bidSize = financeRawNumber(summaryDetail.bidSize);
540
+ const askSize = financeRawNumber(summaryDetail.askSize);
541
+ const previousClose = normalizeMarketValue(financeRawNumber(summaryDetail.previousClose), currencyDivisor);
542
+ const open = normalizeMarketValue(financeRawNumber(summaryDetail.open), currencyDivisor);
543
+ const high = normalizeMarketValue(financeRawNumber(summaryDetail.dayHigh), currencyDivisor);
544
+ const low = normalizeMarketValue(financeRawNumber(summaryDetail.dayLow), currencyDivisor);
545
+
546
+ return {
547
+ bid,
548
+ ask,
549
+ bidSize,
550
+ askSize,
551
+ previousClose,
552
+ open,
553
+ high,
554
+ low,
555
+ };
556
+ } catch {
557
+ return {};
558
+ }
559
+ }
560
+
561
+ private parseTimeseries(results: Array<Record<string, any>>) {
562
+ const parsed: Record<string, Array<{ asOfDate: string; periodType?: string; value: number }>> = {};
563
+ for (const result of results) {
564
+ const type = result?.meta?.type?.[0];
565
+ if (!type) continue;
566
+ const key = Object.keys(result).find((k) => k !== "meta" && k !== "timestamp");
567
+ if (!key) continue;
568
+ parsed[type] = (Array.isArray(result[key]) ? result[key] : [])
569
+ .map((p: any) => ({ asOfDate: p?.asOfDate, periodType: p?.periodType, value: p?.reportedValue?.raw }))
570
+ .filter((p: any) => typeof p.asOfDate === "string" && typeof p.value === "number" && Number.isFinite(p.value));
571
+ }
572
+ return parsed;
573
+ }
574
+
575
+ private computeReturn(history: PricePoint[], days: number): number | undefined {
576
+ if (history.length < 2) return undefined;
577
+ const latest = history[history.length - 1]!;
578
+ const cutoff = new Date(latest.date.getTime() - days * 86400_000);
579
+ let baseline = history[0]!;
580
+ for (const p of history) {
581
+ if (p.date <= cutoff) baseline = p;
582
+ else break;
583
+ }
584
+ if (!baseline.close) return undefined;
585
+ return (latest.close - baseline.close) / baseline.close;
586
+ }
587
+
588
+ /** Fetch full financials for a ticker */
589
+ async getTickerFinancials(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise<TickerFinancials> {
590
+ const symbolsToTry = this.getSymbolsToTry(ticker, exchange);
591
+ let lastError: any;
592
+
593
+ for (const symbol of symbolsToTry) {
594
+ try {
595
+ const result = await this.fetchFullFinancials(symbol);
596
+ return result;
597
+ } catch (err) {
598
+ lastError = err;
599
+ }
600
+ }
601
+ throw lastError || new Error(`No data for ${ticker}`);
602
+ }
603
+
604
+ private async fetchFullFinancials(symbol: string): Promise<TickerFinancials> {
605
+ const [chart, tsRaw, profile] = await Promise.all([
606
+ this.fetchChart(symbol, "5y"),
607
+ this.fetchTimeseries(symbol, [
608
+ ...TIMESERIES_TYPES.annual,
609
+ ...TIMESERIES_TYPES.quarterly,
610
+ ...TIMESERIES_TYPES.trailing,
611
+ ]),
612
+ this.fetchAssetProfile(symbol).catch(() => undefined),
613
+ ]);
614
+
615
+ const { meta, history } = chart;
616
+ if (!history.length) throw new Error(`No history for ${symbol}`);
617
+
618
+ const metrics = this.parseTimeseries(tsRaw);
619
+ const latest = (type: string) => {
620
+ const pts = metrics[type];
621
+ return pts?.length ? pts[pts.length - 1]!.value : undefined;
622
+ };
623
+
624
+ // Normalize sub-unit currencies (e.g. GBp → GBP, dividing prices by 100)
625
+ const rawCurrency = meta.currency || "USD";
626
+ const { currency: normalizedCurrency, divisor: currencyDivisor } = normalizeSubUnitCurrency(rawCurrency);
627
+
628
+ if (currencyDivisor !== 1) {
629
+ // Normalize price history points
630
+ for (const point of history) {
631
+ point.close /= currencyDivisor;
632
+ if (point.open != null) point.open /= currencyDivisor;
633
+ if (point.high != null) point.high /= currencyDivisor;
634
+ if (point.low != null) point.low /= currencyDivisor;
635
+ }
636
+ // Normalize meta prices
637
+ if (meta.regularMarketPrice != null) meta.regularMarketPrice /= currencyDivisor;
638
+ if (meta.chartPreviousClose != null) meta.chartPreviousClose /= currencyDivisor;
639
+ if (meta.fiftyTwoWeekHigh != null) meta.fiftyTwoWeekHigh /= currencyDivisor;
640
+ if (meta.fiftyTwoWeekLow != null) meta.fiftyTwoWeekLow /= currencyDivisor;
641
+ }
642
+
643
+ const quoteSupplement = await this.fetchQuoteSupplement(symbol, currencyDivisor);
644
+
645
+ const currentPrice = meta.regularMarketPrice ?? history[history.length - 1]!.close;
646
+ const prev = history.length > 1 ? history[history.length - 2]!.close : meta.chartPreviousClose;
647
+ const change = prev != null ? currentPrice - prev : 0;
648
+ const changePct = prev ? (change / prev) * 100 : 0;
649
+
650
+ const marketState = deriveMarketState(meta);
651
+ const extHours = await this.fetchExtendedHoursData(symbol, currentPrice, meta);
652
+
653
+ // Normalize extended hours prices too
654
+ if (currencyDivisor !== 1) {
655
+ if (extHours.preMarketPrice != null) extHours.preMarketPrice /= currencyDivisor;
656
+ if (extHours.preMarketChange != null) extHours.preMarketChange /= currencyDivisor;
657
+ if (extHours.postMarketPrice != null) extHours.postMarketPrice /= currencyDivisor;
658
+ if (extHours.postMarketChange != null) extHours.postMarketChange /= currencyDivisor;
659
+ }
660
+
661
+ const quote: Quote = {
662
+ symbol,
663
+ price: currentPrice,
664
+ currency: normalizedCurrency,
665
+ change,
666
+ changePercent: changePct,
667
+ high52w: meta.fiftyTwoWeekHigh,
668
+ low52w: meta.fiftyTwoWeekLow,
669
+ marketCap: latest("trailingMarketCap"),
670
+ name: meta.shortName || meta.longName,
671
+ lastUpdated: Date.now(),
672
+ exchangeName: meta.exchangeName,
673
+ fullExchangeName: meta.fullExchangeName,
674
+ marketState,
675
+ dataSource: "yahoo",
676
+ ...quoteSupplement,
677
+ ...extHours,
678
+ };
679
+
680
+ const revenue = latest("annualTotalRevenue");
681
+ const netIncome = latest("annualNetIncome");
682
+
683
+ const fundamentals: Fundamentals = {
684
+ trailingPE: latest("trailingPeRatio"),
685
+ forwardPE: latest("trailingForwardPeRatio"),
686
+ pegRatio: latest("trailingPegRatio"),
687
+ enterpriseValue: latest("trailingEnterpriseValue"),
688
+ operatingCashFlow: latest("trailingOperatingCashFlow"),
689
+ freeCashFlow: latest("trailingFreeCashFlow"),
690
+ dividendYield: latest("trailingDividendYield"),
691
+ revenue,
692
+ netIncome,
693
+ eps: latest("annualDilutedEPS"),
694
+ operatingMargin: revenue && latest("annualEBITDA") != null ? latest("annualEBITDA")! / revenue : undefined,
695
+ profitMargin: revenue && netIncome != null ? netIncome / revenue : undefined,
696
+ return1Y: this.computeReturn(history, 365),
697
+ return3Y: this.computeReturn(history, 3 * 365),
698
+ sharesOutstanding: latest("annualDilutedAverageShares"),
699
+ };
700
+
701
+ // Build statement arrays
702
+ const buildStatements = (prefix: "annual" | "quarterly"): FinancialStatement[] => {
703
+ const byDate = new Map<string, FinancialStatement>();
704
+ const assign = (type: string, field: keyof FinancialStatement) => {
705
+ for (const pt of metrics[type] || []) {
706
+ const row = byDate.get(pt.asOfDate) || { date: pt.asOfDate };
707
+ (row as any)[field] = pt.value;
708
+ byDate.set(pt.asOfDate, row);
709
+ }
710
+ };
711
+ // Income Statement
712
+ assign(`${prefix}TotalRevenue`, "totalRevenue");
713
+ assign(`${prefix}CostOfRevenue`, "costOfRevenue");
714
+ assign(`${prefix}GrossProfit`, "grossProfit");
715
+ assign(`${prefix}SellingGeneralAndAdministration`, "sellingGeneralAndAdministration");
716
+ assign(`${prefix}ResearchAndDevelopment`, "researchAndDevelopment");
717
+ assign(`${prefix}OperatingExpense`, "operatingExpense");
718
+ assign(`${prefix}OperatingIncome`, "operatingIncome");
719
+ assign(`${prefix}InterestExpense`, "interestExpense");
720
+ assign(`${prefix}TaxProvision`, "taxProvision");
721
+ assign(`${prefix}NetIncome`, "netIncome");
722
+ assign(`${prefix}EBITDA`, "ebitda");
723
+ assign(`${prefix}BasicEPS`, "basicEps");
724
+ assign(`${prefix}DilutedEPS`, "eps");
725
+ assign(`${prefix}DilutedAverageShares`, "dilutedShares");
726
+ // Cash Flow
727
+ assign(`${prefix}OperatingCashFlow`, "operatingCashFlow");
728
+ assign(`${prefix}CapitalExpenditure`, "capitalExpenditure");
729
+ assign(`${prefix}FreeCashFlow`, "freeCashFlow");
730
+ assign(`${prefix}InvestingCashFlow`, "investingCashFlow");
731
+ assign(`${prefix}FinancingCashFlow`, "financingCashFlow");
732
+ assign(`${prefix}IssuanceOfDebt`, "issuanceOfDebt");
733
+ assign(`${prefix}RepurchaseOfCapitalStock`, "repurchaseOfCapitalStock");
734
+ assign(`${prefix}CashDividendsPaid`, "cashDividendsPaid");
735
+ // Balance Sheet
736
+ assign(`${prefix}TotalAssets`, "totalAssets");
737
+ assign(`${prefix}CurrentAssets`, "currentAssets");
738
+ assign(`${prefix}CashAndCashEquivalents`, "cashAndCashEquivalents");
739
+ assign(`${prefix}TotalLiabilitiesNetMinorityInterest`, "totalLiabilities");
740
+ assign(`${prefix}CurrentLiabilities`, "currentLiabilities");
741
+ assign(`${prefix}LongTermDebt`, "longTermDebt");
742
+ assign(`${prefix}TotalDebt`, "totalDebt");
743
+ assign(`${prefix}StockholdersEquity`, "totalEquity");
744
+ assign(`${prefix}RetainedEarnings`, "retainedEarnings");
745
+ return Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date));
746
+ };
747
+
748
+ return {
749
+ quote,
750
+ fundamentals,
751
+ profile,
752
+ annualStatements: buildStatements("annual"),
753
+ quarterlyStatements: buildStatements("quarterly"),
754
+ priceHistory: history,
755
+ };
756
+ }
757
+
758
+ /** Fetch just a quote (lighter weight) */
759
+ async getQuote(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise<Quote> {
760
+ const symbolsToTry = this.getSymbolsToTry(ticker, exchange);
761
+ let lastError: any;
762
+
763
+ for (const symbol of symbolsToTry) {
764
+ try {
765
+ const { meta, history } = await this.fetchChart(symbol, "1mo");
766
+
767
+ // Normalize sub-unit currencies
768
+ const rawCurrency = meta.currency || "USD";
769
+ const { currency: normalizedCurrency, divisor: currencyDivisor } = normalizeSubUnitCurrency(rawCurrency);
770
+
771
+ if (currencyDivisor !== 1) {
772
+ for (const point of history) {
773
+ point.close /= currencyDivisor;
774
+ if (point.open != null) point.open /= currencyDivisor;
775
+ if (point.high != null) point.high /= currencyDivisor;
776
+ if (point.low != null) point.low /= currencyDivisor;
777
+ }
778
+ if (meta.regularMarketPrice != null) meta.regularMarketPrice /= currencyDivisor;
779
+ if (meta.chartPreviousClose != null) meta.chartPreviousClose /= currencyDivisor;
780
+ if (meta.fiftyTwoWeekHigh != null) meta.fiftyTwoWeekHigh /= currencyDivisor;
781
+ if (meta.fiftyTwoWeekLow != null) meta.fiftyTwoWeekLow /= currencyDivisor;
782
+ }
783
+
784
+ const quoteSupplement = await this.fetchQuoteSupplement(symbol, currencyDivisor);
785
+ const latest = history[history.length - 1]!;
786
+ const prev = history.length > 1 ? history[history.length - 2]!.close : meta.chartPreviousClose;
787
+ const price = meta.regularMarketPrice ?? latest.close;
788
+ const change = prev != null ? price - prev : 0;
789
+
790
+ const marketState = deriveMarketState(meta);
791
+ const extHours = await this.fetchExtendedHoursData(symbol, price, meta);
792
+
793
+ if (currencyDivisor !== 1) {
794
+ if (extHours.preMarketPrice != null) extHours.preMarketPrice /= currencyDivisor;
795
+ if (extHours.preMarketChange != null) extHours.preMarketChange /= currencyDivisor;
796
+ if (extHours.postMarketPrice != null) extHours.postMarketPrice /= currencyDivisor;
797
+ if (extHours.postMarketChange != null) extHours.postMarketChange /= currencyDivisor;
798
+ }
799
+
800
+ const quote: Quote = {
801
+ symbol,
802
+ price,
803
+ currency: normalizedCurrency,
804
+ change,
805
+ changePercent: prev ? (change / prev) * 100 : 0,
806
+ high52w: meta.fiftyTwoWeekHigh,
807
+ low52w: meta.fiftyTwoWeekLow,
808
+ name: meta.shortName || meta.longName,
809
+ lastUpdated: Date.now(),
810
+ exchangeName: meta.exchangeName,
811
+ fullExchangeName: meta.fullExchangeName,
812
+ marketState,
813
+ dataSource: "yahoo",
814
+ ...quoteSupplement,
815
+ ...extHours,
816
+ };
817
+
818
+ return quote;
819
+ } catch (err) {
820
+ lastError = err;
821
+ }
822
+ }
823
+ throw lastError || new Error(`No quote for ${ticker}`);
824
+ }
825
+
826
+ /** Fetch exchange rate to USD */
827
+ async getExchangeRate(fromCurrency: string): Promise<number> {
828
+ // Normalize sub-unit currencies to their main unit
829
+ const { currency: normalized } = normalizeSubUnitCurrency(fromCurrency);
830
+ fromCurrency = normalized;
831
+ if (fromCurrency === "USD") return 1;
832
+
833
+ try {
834
+ const { meta, history } = await this.fetchChart(`${fromCurrency}USD=X`, "1mo");
835
+ const rate = meta.regularMarketPrice || history[history.length - 1]?.close || 1;
836
+ return rate;
837
+ } catch {
838
+ return 1;
839
+ }
840
+ }
841
+
842
+ /** Search for a ticker by name/symbol - uses direct fetch (no retry) for speed */
843
+ async search(query: string): Promise<InstrumentSearchResult[]> {
844
+ const url = `https://query1.finance.yahoo.com/v1/finance/search?q=${encodeURIComponent(query)}&quotesCount=10&newsCount=0`;
845
+ try {
846
+ const resp = await fetch(url, {
847
+ headers: this.defaultHeaders(),
848
+ signal: AbortSignal.timeout(5000),
849
+ });
850
+ if (!resp.ok) return [];
851
+ const data = await resp.json() as any;
852
+ return (data.quotes || []).map((q: any) => ({
853
+ providerId: this.id,
854
+ symbol: q.symbol || "",
855
+ name: q.shortname || q.longname || "",
856
+ exchange: q.exchDisp || q.exchange || "",
857
+ type: q.quoteType || "",
858
+ }));
859
+ } catch {
860
+ return [];
861
+ }
862
+ }
863
+
864
+ /** Fetch news for a ticker */
865
+ async getNews(ticker: string, count = 10, exchange = "", _context?: MarketDataRequestContext): Promise<NewsItem[]> {
866
+ // Use the Yahoo symbol for better search results on international tickers
867
+ const symbol = this.getSymbol(ticker, exchange);
868
+ const url = `https://query1.finance.yahoo.com/v1/finance/search?q=${encodeURIComponent(symbol)}&quotesCount=0&newsCount=${count}`;
869
+ try {
870
+ const resp = await fetch(url, {
871
+ headers: this.defaultHeaders(),
872
+ signal: AbortSignal.timeout(5000),
873
+ });
874
+ if (!resp.ok) return [];
875
+ const data = await resp.json() as any;
876
+ const items: NewsItem[] = (data.news || []).map((n: any) => ({
877
+ title: n.title || "",
878
+ url: n.link || "",
879
+ source: n.publisher || "",
880
+ publishedAt: new Date((n.providerPublishTime || 0) * 1000),
881
+ summary: n.summary || undefined,
882
+ }));
883
+
884
+ return items;
885
+ } catch {
886
+ return [];
887
+ }
888
+ }
889
+
890
+ async getSecFilings(ticker: string, count = 10, _exchange = "", _context?: MarketDataRequestContext): Promise<SecFilingItem[]> {
891
+ return this.secClient.getRecentFilings(ticker, count);
892
+ }
893
+
894
+ async getSecFilingContent(filing: SecFilingItem): Promise<string | null> {
895
+ return this.secClient.getFilingContent(filing);
896
+ }
897
+
898
+ /** Fetch article summary by scraping og:description from the article page */
899
+ async getArticleSummary(url: string): Promise<string | null> {
900
+ try {
901
+ const resp = await fetch(url, {
902
+ headers: {
903
+ ...this.defaultHeaders(),
904
+ Accept: "text/html",
905
+ },
906
+ signal: AbortSignal.timeout(8000),
907
+ redirect: "follow",
908
+ });
909
+ if (!resp.ok) return null;
910
+ const html = await resp.text();
911
+ // Extract og:description content
912
+ const match = html.match(/og:description"\s+content="([^"]*?)"/);
913
+ if (!match?.[1]) return null;
914
+ // Decode HTML entities
915
+ return match[1]
916
+ .replace(/&amp;/g, "&")
917
+ .replace(/&lt;/g, "<")
918
+ .replace(/&gt;/g, ">")
919
+ .replace(/&quot;/g, '"')
920
+ .replace(/&#x27;/g, "'")
921
+ .replace(/&#39;/g, "'");
922
+ } catch {
923
+ return null;
924
+ }
925
+ }
926
+
927
+ /** Fetch price history with appropriate granularity for the given time range */
928
+ async getPriceHistory(ticker: string, exchange = "", range: TimeRange, _context?: MarketDataRequestContext): Promise<PricePoint[]> {
929
+ const params = RANGE_PARAMS[range];
930
+ const symbolsToTry = this.getSymbolsToTry(ticker, exchange);
931
+ let lastError: any;
932
+
933
+ for (const symbol of symbolsToTry) {
934
+ try {
935
+ const { meta, history } = await this.fetchChart(symbol, params.range, params.interval);
936
+
937
+ // Normalize sub-unit currencies in price history
938
+ const { divisor } = normalizeSubUnitCurrency(meta.currency || "USD");
939
+ if (divisor !== 1) {
940
+ for (const point of history) {
941
+ point.close /= divisor;
942
+ if (point.open != null) point.open /= divisor;
943
+ if (point.high != null) point.high /= divisor;
944
+ if (point.low != null) point.low /= divisor;
945
+ }
946
+ }
947
+
948
+ return history;
949
+ } catch (err) {
950
+ lastError = err;
951
+ }
952
+ }
953
+ throw lastError || new Error(`No history for ${ticker}`);
954
+ }
955
+
956
+ // ── Options Chain ──────────────────────────────────────────────────
957
+
958
+ private mapContract(raw: Record<string, any>): OptionContract {
959
+ return {
960
+ contractSymbol: raw.contractSymbol ?? "",
961
+ strike: raw.strike ?? 0,
962
+ currency: raw.currency ?? "USD",
963
+ lastPrice: raw.lastPrice ?? 0,
964
+ change: raw.change ?? 0,
965
+ percentChange: raw.percentChange ?? 0,
966
+ volume: raw.volume ?? 0,
967
+ openInterest: raw.openInterest ?? 0,
968
+ bid: raw.bid ?? 0,
969
+ ask: raw.ask ?? 0,
970
+ impliedVolatility: raw.impliedVolatility ?? 0,
971
+ inTheMoney: raw.inTheMoney ?? false,
972
+ expiration: raw.expiration ?? 0,
973
+ lastTradeDate: raw.lastTradeDate ?? 0,
974
+ };
975
+ }
976
+
977
+ async getOptionsChain(ticker: string, exchange = "", expirationDate?: number, _context?: MarketDataRequestContext): Promise<OptionsChain> {
978
+ const symbolsToTry = this.getSymbolsToTry(ticker, exchange);
979
+ let lastError: any;
980
+
981
+ for (const symbol of symbolsToTry) {
982
+ try {
983
+ let url = `https://query1.finance.yahoo.com/v7/finance/options/${encodeURIComponent(symbol)}`;
984
+ if (expirationDate != null) url += `?date=${expirationDate}`;
985
+
986
+ const data = await this.fetchJsonWithCrumb<{
987
+ optionChain?: {
988
+ result?: Array<{
989
+ underlyingSymbol?: string;
990
+ expirationDates?: number[];
991
+ options?: Array<{
992
+ calls?: Array<Record<string, any>>;
993
+ puts?: Array<Record<string, any>>;
994
+ }>;
995
+ }>;
996
+ };
997
+ }>("options " + symbol, url);
998
+
999
+ const result = data.optionChain?.result?.[0];
1000
+ if (!result) throw new Error("No options data");
1001
+
1002
+ const opts = result.options?.[0];
1003
+ const chain: OptionsChain = {
1004
+ underlyingSymbol: result.underlyingSymbol ?? symbol,
1005
+ expirationDates: result.expirationDates ?? [],
1006
+ calls: (opts?.calls ?? []).map((c) => this.mapContract(c)),
1007
+ puts: (opts?.puts ?? []).map((p) => this.mapContract(p)),
1008
+ };
1009
+
1010
+ return chain;
1011
+ } catch (err) {
1012
+ lastError = err;
1013
+ }
1014
+ }
1015
+ throw lastError || new Error(`No options chain for ${ticker}`);
1016
+ }
1017
+ }