opencandle 0.3.0 → 0.5.0

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 (408) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +106 -14
  3. package/assets/logo.svg +187 -0
  4. package/dist/cli.d.ts +1 -1
  5. package/dist/cli.js +40 -3
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +25 -0
  8. package/dist/config.js +72 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/infra/browser.d.ts +11 -3
  11. package/dist/infra/browser.js +2 -1
  12. package/dist/infra/browser.js.map +1 -1
  13. package/dist/infra/native-dependencies.d.ts +1 -0
  14. package/dist/infra/native-dependencies.js +10 -0
  15. package/dist/infra/native-dependencies.js.map +1 -0
  16. package/dist/infra/node-version.d.ts +2 -0
  17. package/dist/infra/node-version.js +23 -0
  18. package/dist/infra/node-version.js.map +1 -0
  19. package/dist/infra/rate-limiter.d.ts +4 -0
  20. package/dist/infra/rate-limiter.js +5 -1
  21. package/dist/infra/rate-limiter.js.map +1 -1
  22. package/dist/memory/index.d.ts +2 -0
  23. package/dist/memory/index.js +1 -0
  24. package/dist/memory/index.js.map +1 -1
  25. package/dist/memory/manager.d.ts +9 -0
  26. package/dist/memory/manager.js +28 -11
  27. package/dist/memory/manager.js.map +1 -1
  28. package/dist/memory/sqlite.js +42 -4
  29. package/dist/memory/sqlite.js.map +1 -1
  30. package/dist/memory/storage.d.ts +7 -0
  31. package/dist/memory/storage.js +3 -3
  32. package/dist/memory/storage.js.map +1 -1
  33. package/dist/memory/tool-defaults.d.ts +8 -0
  34. package/dist/memory/tool-defaults.js +59 -0
  35. package/dist/memory/tool-defaults.js.map +1 -0
  36. package/dist/memory/types.js +4 -0
  37. package/dist/memory/types.js.map +1 -1
  38. package/dist/onboarding/connect.d.ts +13 -1
  39. package/dist/onboarding/connect.js +21 -10
  40. package/dist/onboarding/connect.js.map +1 -1
  41. package/dist/onboarding/prompt-user.d.ts +1 -1
  42. package/dist/onboarding/providers.d.ts +7 -0
  43. package/dist/onboarding/providers.js +6 -3
  44. package/dist/onboarding/providers.js.map +1 -1
  45. package/dist/onboarding/tool-helpers.d.ts +1 -1
  46. package/dist/pi/opencandle-extension.d.ts +7 -1
  47. package/dist/pi/opencandle-extension.js +391 -21
  48. package/dist/pi/opencandle-extension.js.map +1 -1
  49. package/dist/pi/session-storage.d.ts +2 -0
  50. package/dist/pi/session-storage.js +5 -0
  51. package/dist/pi/session-storage.js.map +1 -0
  52. package/dist/pi/session.d.ts +4 -1
  53. package/dist/pi/session.js +25 -3
  54. package/dist/pi/session.js.map +1 -1
  55. package/dist/pi/setup.d.ts +1 -1
  56. package/dist/pi/setup.js +11 -1
  57. package/dist/pi/setup.js.map +1 -1
  58. package/dist/pi/tool-adapter.d.ts +2 -2
  59. package/dist/pi/tool-adapter.js +14 -1
  60. package/dist/pi/tool-adapter.js.map +1 -1
  61. package/dist/prompts/context-builder.d.ts +40 -3
  62. package/dist/prompts/context-builder.js +140 -19
  63. package/dist/prompts/context-builder.js.map +1 -1
  64. package/dist/prompts/disclaimer.d.ts +6 -0
  65. package/dist/prompts/disclaimer.js +9 -0
  66. package/dist/prompts/disclaimer.js.map +1 -0
  67. package/dist/prompts/policy-cards.d.ts +13 -0
  68. package/dist/prompts/policy-cards.js +197 -0
  69. package/dist/prompts/policy-cards.js.map +1 -0
  70. package/dist/prompts/sections.js +3 -3
  71. package/dist/prompts/sections.js.map +1 -1
  72. package/dist/prompts/workflow-prompts.d.ts +8 -0
  73. package/dist/prompts/workflow-prompts.js +208 -22
  74. package/dist/prompts/workflow-prompts.js.map +1 -1
  75. package/dist/providers/alpha-vantage.js +23 -1
  76. package/dist/providers/alpha-vantage.js.map +1 -1
  77. package/dist/providers/sec-edgar.d.ts +8 -1
  78. package/dist/providers/sec-edgar.js +172 -5
  79. package/dist/providers/sec-edgar.js.map +1 -1
  80. package/dist/providers/yahoo-finance.d.ts +2 -0
  81. package/dist/providers/yahoo-finance.js +203 -35
  82. package/dist/providers/yahoo-finance.js.map +1 -1
  83. package/dist/routing/classify-intent.d.ts +3 -0
  84. package/dist/routing/classify-intent.js +82 -3
  85. package/dist/routing/classify-intent.js.map +1 -1
  86. package/dist/routing/defaults.js +4 -4
  87. package/dist/routing/defaults.js.map +1 -1
  88. package/dist/routing/entity-extractor.d.ts +1 -0
  89. package/dist/routing/entity-extractor.js +158 -12
  90. package/dist/routing/entity-extractor.js.map +1 -1
  91. package/dist/routing/index.d.ts +10 -0
  92. package/dist/routing/index.js +7 -0
  93. package/dist/routing/index.js.map +1 -1
  94. package/dist/routing/legacy-rule-router.d.ts +9 -0
  95. package/dist/routing/legacy-rule-router.js +12 -0
  96. package/dist/routing/legacy-rule-router.js.map +1 -0
  97. package/dist/routing/planning.d.ts +54 -0
  98. package/dist/routing/planning.js +531 -0
  99. package/dist/routing/planning.js.map +1 -0
  100. package/dist/routing/route-manifest.d.ts +35 -0
  101. package/dist/routing/route-manifest.js +221 -0
  102. package/dist/routing/route-manifest.js.map +1 -0
  103. package/dist/routing/router-llm-client.d.ts +11 -0
  104. package/dist/routing/router-llm-client.js +42 -0
  105. package/dist/routing/router-llm-client.js.map +1 -0
  106. package/dist/routing/router-prompt.d.ts +2 -0
  107. package/dist/routing/router-prompt.js +141 -0
  108. package/dist/routing/router-prompt.js.map +1 -0
  109. package/dist/routing/router-types.d.ts +71 -0
  110. package/dist/routing/router-types.js +2 -0
  111. package/dist/routing/router-types.js.map +1 -0
  112. package/dist/routing/router.d.ts +11 -0
  113. package/dist/routing/router.js +638 -0
  114. package/dist/routing/router.js.map +1 -0
  115. package/dist/routing/slot-resolver.js +46 -6
  116. package/dist/routing/slot-resolver.js.map +1 -1
  117. package/dist/routing/turn-context.d.ts +44 -0
  118. package/dist/routing/turn-context.js +45 -0
  119. package/dist/routing/turn-context.js.map +1 -0
  120. package/dist/routing/types.d.ts +13 -1
  121. package/dist/runtime/answer-contracts.d.ts +82 -0
  122. package/dist/runtime/answer-contracts.js +414 -0
  123. package/dist/runtime/answer-contracts.js.map +1 -0
  124. package/dist/runtime/artifact-contracts.d.ts +14 -0
  125. package/dist/runtime/artifact-contracts.js +57 -0
  126. package/dist/runtime/artifact-contracts.js.map +1 -0
  127. package/dist/runtime/planning-evidence.d.ts +99 -0
  128. package/dist/runtime/planning-evidence.js +445 -0
  129. package/dist/runtime/planning-evidence.js.map +1 -0
  130. package/dist/runtime/session-coordinator.d.ts +81 -3
  131. package/dist/runtime/session-coordinator.js +201 -17
  132. package/dist/runtime/session-coordinator.js.map +1 -1
  133. package/dist/runtime/tool-defaults-wrapper.d.ts +3 -0
  134. package/dist/runtime/tool-defaults-wrapper.js +25 -0
  135. package/dist/runtime/tool-defaults-wrapper.js.map +1 -0
  136. package/dist/sentiment/store.js +5 -0
  137. package/dist/sentiment/store.js.map +1 -1
  138. package/dist/system-prompt.js +23 -12
  139. package/dist/system-prompt.js.map +1 -1
  140. package/dist/tool-kit.d.ts +4 -4
  141. package/dist/tools/fundamentals/company-overview.d.ts +1 -1
  142. package/dist/tools/fundamentals/company-overview.js +1 -1
  143. package/dist/tools/fundamentals/company-overview.js.map +1 -1
  144. package/dist/tools/fundamentals/comps.d.ts +1 -1
  145. package/dist/tools/fundamentals/comps.js +1 -1
  146. package/dist/tools/fundamentals/comps.js.map +1 -1
  147. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  148. package/dist/tools/fundamentals/dcf.js +1 -1
  149. package/dist/tools/fundamentals/dcf.js.map +1 -1
  150. package/dist/tools/fundamentals/earnings.d.ts +1 -1
  151. package/dist/tools/fundamentals/earnings.js +1 -1
  152. package/dist/tools/fundamentals/earnings.js.map +1 -1
  153. package/dist/tools/fundamentals/financials.d.ts +1 -1
  154. package/dist/tools/fundamentals/financials.js +1 -1
  155. package/dist/tools/fundamentals/financials.js.map +1 -1
  156. package/dist/tools/fundamentals/sec-filings.d.ts +2 -1
  157. package/dist/tools/fundamentals/sec-filings.js +19 -2
  158. package/dist/tools/fundamentals/sec-filings.js.map +1 -1
  159. package/dist/tools/index.d.ts +29 -1
  160. package/dist/tools/index.js +30 -0
  161. package/dist/tools/index.js.map +1 -1
  162. package/dist/tools/interaction/ask-user.d.ts +1 -1
  163. package/dist/tools/interaction/twitter-login.d.ts +1 -1
  164. package/dist/tools/macro/fear-greed.d.ts +1 -1
  165. package/dist/tools/macro/fear-greed.js +1 -1
  166. package/dist/tools/macro/fear-greed.js.map +1 -1
  167. package/dist/tools/macro/fred-data.d.ts +1 -1
  168. package/dist/tools/macro/fred-data.js +29 -5
  169. package/dist/tools/macro/fred-data.js.map +1 -1
  170. package/dist/tools/market/crypto-history.d.ts +1 -1
  171. package/dist/tools/market/crypto-history.js +18 -2
  172. package/dist/tools/market/crypto-history.js.map +1 -1
  173. package/dist/tools/market/crypto-price.d.ts +1 -1
  174. package/dist/tools/market/crypto-price.js +1 -1
  175. package/dist/tools/market/crypto-price.js.map +1 -1
  176. package/dist/tools/market/search-ticker.d.ts +1 -1
  177. package/dist/tools/market/search-ticker.js +1 -1
  178. package/dist/tools/market/search-ticker.js.map +1 -1
  179. package/dist/tools/market/stock-history.d.ts +1 -1
  180. package/dist/tools/market/stock-history.js +1 -1
  181. package/dist/tools/market/stock-history.js.map +1 -1
  182. package/dist/tools/market/stock-quote.d.ts +1 -1
  183. package/dist/tools/market/stock-quote.js +1 -1
  184. package/dist/tools/market/stock-quote.js.map +1 -1
  185. package/dist/tools/options/greeks.js +0 -1
  186. package/dist/tools/options/greeks.js.map +1 -1
  187. package/dist/tools/options/option-chain.d.ts +1 -1
  188. package/dist/tools/options/option-chain.js +13 -5
  189. package/dist/tools/options/option-chain.js.map +1 -1
  190. package/dist/tools/portfolio/correlation.d.ts +1 -1
  191. package/dist/tools/portfolio/correlation.js +1 -1
  192. package/dist/tools/portfolio/correlation.js.map +1 -1
  193. package/dist/tools/portfolio/holdings-overlap.d.ts +8 -0
  194. package/dist/tools/portfolio/holdings-overlap.js +105 -0
  195. package/dist/tools/portfolio/holdings-overlap.js.map +1 -0
  196. package/dist/tools/portfolio/predictions.d.ts +1 -1
  197. package/dist/tools/portfolio/predictions.js +1 -1
  198. package/dist/tools/portfolio/predictions.js.map +1 -1
  199. package/dist/tools/portfolio/risk-analysis.d.ts +1 -1
  200. package/dist/tools/portfolio/risk-analysis.js +1 -1
  201. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  202. package/dist/tools/portfolio/tracker.d.ts +1 -1
  203. package/dist/tools/portfolio/tracker.js +1 -1
  204. package/dist/tools/portfolio/tracker.js.map +1 -1
  205. package/dist/tools/portfolio/watchlist.d.ts +1 -1
  206. package/dist/tools/portfolio/watchlist.js +12 -4
  207. package/dist/tools/portfolio/watchlist.js.map +1 -1
  208. package/dist/tools/sentiment/reddit-sentiment.d.ts +1 -1
  209. package/dist/tools/sentiment/reddit-sentiment.js +1 -1
  210. package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
  211. package/dist/tools/sentiment/sentiment-summary.d.ts +1 -1
  212. package/dist/tools/sentiment/sentiment-summary.js +57 -2
  213. package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
  214. package/dist/tools/sentiment/sentiment-trend.d.ts +1 -1
  215. package/dist/tools/sentiment/twitter-sentiment.d.ts +1 -1
  216. package/dist/tools/sentiment/twitter-sentiment.js +1 -1
  217. package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
  218. package/dist/tools/sentiment/web-search.d.ts +1 -1
  219. package/dist/tools/sentiment/web-search.js +32 -3
  220. package/dist/tools/sentiment/web-search.js.map +1 -1
  221. package/dist/tools/sentiment/web-sentiment.d.ts +1 -1
  222. package/dist/tools/sentiment/web-sentiment.js +1 -1
  223. package/dist/tools/sentiment/web-sentiment.js.map +1 -1
  224. package/dist/tools/technical/backtest.d.ts +3 -3
  225. package/dist/tools/technical/backtest.js +41 -27
  226. package/dist/tools/technical/backtest.js.map +1 -1
  227. package/dist/tools/technical/indicators.d.ts +1 -1
  228. package/dist/tools/technical/indicators.js +8 -4
  229. package/dist/tools/technical/indicators.js.map +1 -1
  230. package/dist/types/options.d.ts +10 -0
  231. package/dist/types/portfolio.d.ts +27 -0
  232. package/dist/workflows/compare-assets.js +38 -2
  233. package/dist/workflows/compare-assets.js.map +1 -1
  234. package/dist/workflows/options-screener.js +94 -8
  235. package/dist/workflows/options-screener.js.map +1 -1
  236. package/dist/workflows/portfolio-builder.js +9 -5
  237. package/dist/workflows/portfolio-builder.js.map +1 -1
  238. package/gui/server/ask-user-bridge.ts +82 -0
  239. package/gui/server/background-quotes.ts +31 -0
  240. package/gui/server/chat-event-adapter.ts +142 -0
  241. package/gui/server/gui-session-manager.ts +5 -0
  242. package/gui/server/invoke-tool.ts +89 -0
  243. package/gui/server/live-chat-event-adapter.ts +181 -0
  244. package/gui/server/model-setup.ts +100 -0
  245. package/gui/server/package.json +5 -0
  246. package/gui/server/projector.ts +254 -0
  247. package/gui/server/prompt-observation.ts +61 -0
  248. package/gui/server/server.ts +703 -0
  249. package/gui/server/session-actions.ts +31 -0
  250. package/gui/server/session-entry-wait.ts +81 -0
  251. package/gui/server/tool-metadata.ts +88 -0
  252. package/gui/server/websocket.ts +128 -0
  253. package/gui/server/writer-lock.ts +118 -0
  254. package/gui/shared/chat-events.ts +118 -0
  255. package/gui/shared/event-reducer.ts +186 -0
  256. package/gui/web/dist/assets/CatalogOverlay-Bmp6Knu7.js +1 -0
  257. package/gui/web/dist/assets/index-Bxt9QpLX.css +1 -0
  258. package/gui/web/dist/assets/index-CZ9DHZYy.js +67 -0
  259. package/gui/web/dist/assets/logo-CWpt6Y2a.svg +187 -0
  260. package/gui/web/dist/index.html +17 -0
  261. package/package.json +50 -18
  262. package/src/analysts/contracts.ts +189 -0
  263. package/src/analysts/orchestrator.ts +300 -0
  264. package/src/cli.ts +206 -0
  265. package/src/config.ts +245 -0
  266. package/src/index.ts +5 -0
  267. package/src/infra/browser.ts +111 -0
  268. package/src/infra/cache.ts +103 -0
  269. package/src/infra/http-client.ts +68 -0
  270. package/src/infra/index.ts +18 -0
  271. package/src/infra/native-dependencies.ts +12 -0
  272. package/src/infra/node-version.ts +24 -0
  273. package/src/infra/open-url.ts +28 -0
  274. package/src/infra/opencandle-paths.ts +64 -0
  275. package/src/infra/rate-limiter.ts +73 -0
  276. package/src/memory/index.ts +10 -0
  277. package/src/memory/manager.ts +192 -0
  278. package/src/memory/preference-extractor.ts +106 -0
  279. package/src/memory/retrieval.ts +70 -0
  280. package/src/memory/sqlite.ts +172 -0
  281. package/src/memory/storage.ts +205 -0
  282. package/src/memory/tool-defaults.ts +87 -0
  283. package/src/memory/types.ts +71 -0
  284. package/src/onboarding/connect.ts +184 -0
  285. package/src/onboarding/credential-interceptor.ts +134 -0
  286. package/src/onboarding/degradation-accumulator.ts +79 -0
  287. package/src/onboarding/prompt-user.ts +85 -0
  288. package/src/onboarding/providers.ts +315 -0
  289. package/src/onboarding/state.ts +218 -0
  290. package/src/onboarding/tool-helpers.ts +111 -0
  291. package/src/onboarding/tool-tags.ts +201 -0
  292. package/src/onboarding/validation.ts +158 -0
  293. package/src/pi/opencandle-extension.ts +955 -0
  294. package/src/pi/session-storage.ts +5 -0
  295. package/src/pi/session.ts +81 -0
  296. package/src/pi/setup.ts +381 -0
  297. package/src/pi/tool-adapter.ts +36 -0
  298. package/src/prompts/context-builder.ts +315 -0
  299. package/src/prompts/disclaimer.ts +9 -0
  300. package/src/prompts/policy-cards.ts +220 -0
  301. package/src/prompts/sections.ts +46 -0
  302. package/src/prompts/workflow-prompts.ts +433 -0
  303. package/src/providers/alpha-vantage.ts +315 -0
  304. package/src/providers/coingecko.ts +96 -0
  305. package/src/providers/exa-search.ts +373 -0
  306. package/src/providers/fear-greed.ts +45 -0
  307. package/src/providers/finnhub.ts +124 -0
  308. package/src/providers/fred.ts +83 -0
  309. package/src/providers/index.ts +9 -0
  310. package/src/providers/provider-credential-error.ts +23 -0
  311. package/src/providers/reddit.ts +151 -0
  312. package/src/providers/sec-edgar.ts +312 -0
  313. package/src/providers/twitter.ts +173 -0
  314. package/src/providers/web-search.ts +293 -0
  315. package/src/providers/with-fallback.ts +41 -0
  316. package/src/providers/wrap-provider.ts +64 -0
  317. package/src/providers/yahoo-finance.ts +534 -0
  318. package/src/routing/classify-intent.ts +285 -0
  319. package/src/routing/defaults.ts +29 -0
  320. package/src/routing/entity-extractor.ts +291 -0
  321. package/src/routing/index.ts +70 -0
  322. package/src/routing/legacy-rule-router.ts +13 -0
  323. package/src/routing/planning.ts +732 -0
  324. package/src/routing/route-manifest.ts +287 -0
  325. package/src/routing/router-llm-client.ts +51 -0
  326. package/src/routing/router-prompt.ts +163 -0
  327. package/src/routing/router-types.ts +87 -0
  328. package/src/routing/router.ts +712 -0
  329. package/src/routing/slot-resolver.ts +190 -0
  330. package/src/routing/turn-context.ts +111 -0
  331. package/src/routing/types.ts +75 -0
  332. package/src/runtime/answer-contracts.ts +633 -0
  333. package/src/runtime/artifact-contracts.ts +76 -0
  334. package/src/runtime/evidence.ts +77 -0
  335. package/src/runtime/planning-evidence.ts +591 -0
  336. package/src/runtime/prompt-step.ts +75 -0
  337. package/src/runtime/provider-tracker.ts +40 -0
  338. package/src/runtime/run-context.ts +22 -0
  339. package/src/runtime/session-coordinator.ts +472 -0
  340. package/src/runtime/tool-defaults-wrapper.ts +35 -0
  341. package/src/runtime/validation.ts +214 -0
  342. package/src/runtime/workflow-events.ts +75 -0
  343. package/src/runtime/workflow-runner.ts +188 -0
  344. package/src/runtime/workflow-types.ts +102 -0
  345. package/src/sentiment/adapters/finnhub.ts +44 -0
  346. package/src/sentiment/adapters/reddit.ts +65 -0
  347. package/src/sentiment/adapters/twitter.ts +36 -0
  348. package/src/sentiment/adapters/web.ts +44 -0
  349. package/src/sentiment/index.ts +58 -0
  350. package/src/sentiment/keywords.ts +9 -0
  351. package/src/sentiment/pipeline.ts +68 -0
  352. package/src/sentiment/scorer.ts +78 -0
  353. package/src/sentiment/store.ts +260 -0
  354. package/src/sentiment/trends.ts +90 -0
  355. package/src/sentiment/types.ts +108 -0
  356. package/src/system-prompt.ts +118 -0
  357. package/src/tool-kit.ts +68 -0
  358. package/src/tools/AGENTS.md +36 -0
  359. package/src/tools/fundamentals/company-overview.ts +54 -0
  360. package/src/tools/fundamentals/comps.ts +156 -0
  361. package/src/tools/fundamentals/dcf.ts +267 -0
  362. package/src/tools/fundamentals/earnings.ts +47 -0
  363. package/src/tools/fundamentals/financials.ts +54 -0
  364. package/src/tools/fundamentals/sec-filings.ts +84 -0
  365. package/src/tools/index.ts +91 -0
  366. package/src/tools/interaction/ask-user.ts +81 -0
  367. package/src/tools/interaction/twitter-login.ts +93 -0
  368. package/src/tools/macro/fear-greed.ts +41 -0
  369. package/src/tools/macro/fred-data.ts +80 -0
  370. package/src/tools/market/crypto-history.ts +67 -0
  371. package/src/tools/market/crypto-price.ts +53 -0
  372. package/src/tools/market/search-ticker.ts +53 -0
  373. package/src/tools/market/stock-history.ts +79 -0
  374. package/src/tools/market/stock-quote.ts +64 -0
  375. package/src/tools/options/greeks.ts +81 -0
  376. package/src/tools/options/option-chain.ts +96 -0
  377. package/src/tools/portfolio/correlation.ts +162 -0
  378. package/src/tools/portfolio/holdings-overlap.ts +123 -0
  379. package/src/tools/portfolio/predictions.ts +253 -0
  380. package/src/tools/portfolio/risk-analysis.ts +134 -0
  381. package/src/tools/portfolio/tracker.ts +147 -0
  382. package/src/tools/portfolio/watchlist.ts +159 -0
  383. package/src/tools/sentiment/reddit-sentiment.ts +164 -0
  384. package/src/tools/sentiment/sentiment-summary.ts +316 -0
  385. package/src/tools/sentiment/sentiment-trend.ts +58 -0
  386. package/src/tools/sentiment/twitter-sentiment.ts +96 -0
  387. package/src/tools/sentiment/web-search.ts +183 -0
  388. package/src/tools/sentiment/web-sentiment.ts +76 -0
  389. package/src/tools/technical/backtest.ts +267 -0
  390. package/src/tools/technical/indicators.ts +256 -0
  391. package/src/types/fundamentals.ts +46 -0
  392. package/src/types/index.ts +20 -0
  393. package/src/types/macro.ts +27 -0
  394. package/src/types/market.ts +43 -0
  395. package/src/types/options.ts +52 -0
  396. package/src/types/portfolio.ts +73 -0
  397. package/src/types/sentiment.ts +70 -0
  398. package/src/workflows/compare-assets.ts +75 -0
  399. package/src/workflows/index.ts +4 -0
  400. package/src/workflows/options-screener.ts +127 -0
  401. package/src/workflows/portfolio-builder.ts +56 -0
  402. package/src/workflows/types.ts +4 -0
  403. package/dist/runtime/index.d.ts +0 -16
  404. package/dist/runtime/index.js +0 -10
  405. package/dist/runtime/index.js.map +0 -1
  406. package/dist/runtime/provider-ids.d.ts +0 -14
  407. package/dist/runtime/provider-ids.js +0 -14
  408. package/dist/runtime/provider-ids.js.map +0 -1
@@ -0,0 +1,151 @@
1
+ import { httpGet } from "../infra/http-client.js";
2
+ import { cache, TTL, STALE_LIMIT } from "../infra/cache.js";
3
+ import { rateLimiter } from "../infra/rate-limiter.js";
4
+ import type { RedditSentimentResult } from "../types/sentiment.js";
5
+ import { BULLISH_TERMS, BEARISH_TERMS } from "../sentiment/keywords.js";
6
+
7
+ interface RedditListingResponse {
8
+ data: {
9
+ children: Array<{
10
+ data: {
11
+ id: string;
12
+ title: string;
13
+ selftext: string;
14
+ author: string;
15
+ score: number;
16
+ num_comments: number;
17
+ permalink: string;
18
+ created_utc: number;
19
+ };
20
+ }>;
21
+ };
22
+ }
23
+
24
+ const REDDIT_HEADERS = { "User-Agent": "OpenCandle/1.0 (financial analysis agent)" };
25
+
26
+ export async function getSubredditPosts(
27
+ subreddit: string,
28
+ limit: number = 25,
29
+ ): Promise<RedditSentimentResult> {
30
+ const cacheKey = `reddit:${subreddit}:${limit}`;
31
+ const cached = cache.get<RedditSentimentResult>(cacheKey);
32
+ if (cached) return cached;
33
+
34
+ try {
35
+ await rateLimiter.acquire("reddit");
36
+ const url = `https://www.reddit.com/r/${encodeURIComponent(subreddit)}/hot.json?limit=${limit}`;
37
+ const data = await httpGet<RedditListingResponse>(url, {
38
+ headers: REDDIT_HEADERS,
39
+ });
40
+
41
+ const posts = data.data.children.map((child) => ({
42
+ id: child.data.id,
43
+ title: child.data.title,
44
+ selftext: child.data.selftext ?? "",
45
+ author: child.data.author ?? "unknown",
46
+ score: child.data.score,
47
+ comments: child.data.num_comments,
48
+ url: `https://reddit.com${child.data.permalink}`,
49
+ created: new Date(child.data.created_utc * 1000).toISOString(),
50
+ }));
51
+
52
+ // Extract ticker-like mentions ($AAPL, $TSLA, etc.)
53
+ const tickerRegex = /\$([A-Z]{1,5})\b/g;
54
+ const mentionCounts = new Map<string, number>();
55
+ for (const post of posts) {
56
+ for (const match of post.title.matchAll(tickerRegex)) {
57
+ const ticker = match[1];
58
+ mentionCounts.set(ticker, (mentionCounts.get(ticker) ?? 0) + 1);
59
+ }
60
+ }
61
+ const topMentions = [...mentionCounts.entries()]
62
+ .sort((a, b) => b[1] - a[1])
63
+ .slice(0, 10)
64
+ .map(([ticker]) => ticker);
65
+
66
+ const sentiment = scoreSentiment(posts);
67
+
68
+ const result: RedditSentimentResult = {
69
+ subreddit,
70
+ postCount: posts.length,
71
+ posts,
72
+ topMentions,
73
+ sentimentScore: sentiment.score,
74
+ bullishCount: sentiment.bullish,
75
+ bearishCount: sentiment.bearish,
76
+ fetchedAt: new Date().toISOString(),
77
+ };
78
+
79
+ cache.set(cacheKey, result, TTL.SENTIMENT);
80
+ return result;
81
+ } catch (error) {
82
+ const stale = cache.getStale<RedditSentimentResult>(cacheKey, STALE_LIMIT.SENTIMENT);
83
+ if (stale) return stale.value;
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ // ── Comment fetching ────────────────────────────────────
89
+
90
+ export interface RedditComment {
91
+ id: string;
92
+ body: string;
93
+ author: string;
94
+ score: number;
95
+ permalink: string;
96
+ }
97
+
98
+ const COMMENT_TTL = 30 * 60 * 1000; // 30 minutes
99
+
100
+ export async function getPostComments(
101
+ subreddit: string,
102
+ postId: string,
103
+ limit: number = 5,
104
+ ): Promise<RedditComment[]> {
105
+ const cacheKey = `reddit:comments:${subreddit}:${postId}:${limit}`;
106
+ const cached = cache.get<RedditComment[]>(cacheKey);
107
+ if (cached) return cached;
108
+
109
+ await rateLimiter.acquire("reddit_comments");
110
+ const url = `https://www.reddit.com/r/${encodeURIComponent(subreddit)}/comments/${postId}.json`;
111
+ const data = await httpGet<Array<{ data: { children: Array<{ kind: string; data: { id: string; body?: string; author?: string; score?: number; permalink?: string } }> } }>>(url, {
112
+ headers: REDDIT_HEADERS,
113
+ });
114
+
115
+ // Comments are in the second listing element
116
+ const commentListing = data[1]?.data?.children ?? [];
117
+ const comments: RedditComment[] = commentListing
118
+ .filter((c) => c.kind === "t1" && c.data.body)
119
+ .sort((a, b) => (b.data.score ?? 0) - (a.data.score ?? 0))
120
+ .slice(0, limit)
121
+ .map((c) => ({
122
+ id: c.data.id,
123
+ body: c.data.body!,
124
+ author: c.data.author ?? "unknown",
125
+ score: c.data.score ?? 0,
126
+ permalink: `https://reddit.com${c.data.permalink ?? ""}`,
127
+ }));
128
+
129
+ cache.set(cacheKey, comments, COMMENT_TTL);
130
+ return comments;
131
+ }
132
+
133
+ // ── Sentiment scoring ───────────────────────────────────
134
+
135
+ export function scoreSentiment(
136
+ posts: Array<{ title: string }>,
137
+ ): { score: number; bullish: number; bearish: number } {
138
+ let bullish = 0;
139
+ let bearish = 0;
140
+ for (const post of posts) {
141
+ const lower = post.title.toLowerCase();
142
+ bullish += BULLISH_TERMS.filter((t) => lower.includes(t)).length;
143
+ bearish += BEARISH_TERMS.filter((t) => lower.includes(t)).length;
144
+ }
145
+ const total = bullish + bearish;
146
+ return {
147
+ score: total === 0 ? 0 : (bullish - bearish) / total,
148
+ bullish,
149
+ bearish,
150
+ };
151
+ }
@@ -0,0 +1,312 @@
1
+ import { httpGet } from "../infra/http-client.js";
2
+ import { cache, TTL } from "../infra/cache.js";
3
+
4
+ const EFTS_BASE = "https://efts.sec.gov/LATEST/search-index";
5
+ const COMPANY_TICKERS_URL = "https://www.sec.gov/files/company_tickers.json";
6
+ const SUBMISSIONS_BASE = "https://data.sec.gov/submissions";
7
+
8
+ export interface SECFiling {
9
+ formType: string;
10
+ filedDate: string;
11
+ periodOfReport: string;
12
+ entityName: string;
13
+ accessionNumber: string;
14
+ url: string;
15
+ primaryDocumentUrl?: string;
16
+ items?: string[];
17
+ evidenceSnippets?: string[];
18
+ }
19
+
20
+ export interface SearchFilingsOptions {
21
+ includeSnippets?: boolean;
22
+ snippetLimitPerFiling?: number;
23
+ }
24
+
25
+ interface EFTSResponse {
26
+ hits: {
27
+ hits: Array<{
28
+ _id: string;
29
+ _source: {
30
+ file_date: string;
31
+ form: string;
32
+ adsh: string;
33
+ display_names: string[];
34
+ period_ending: string;
35
+ ciks: string[];
36
+ items?: string[];
37
+ };
38
+ }>;
39
+ };
40
+ }
41
+
42
+ interface CompanyTickerEntry {
43
+ cik_str: number;
44
+ ticker: string;
45
+ title: string;
46
+ }
47
+
48
+ interface SubmissionsResponse {
49
+ name: string;
50
+ tickers?: string[];
51
+ filings: {
52
+ recent: {
53
+ accessionNumber: string[];
54
+ filingDate: string[];
55
+ reportDate: string[];
56
+ form: string[];
57
+ primaryDocument: string[];
58
+ items?: string[];
59
+ };
60
+ };
61
+ }
62
+
63
+ export async function searchFilings(
64
+ ticker: string,
65
+ formTypes: string[] = ["10-K", "10-Q", "8-K"],
66
+ limit: number = 10,
67
+ options: SearchFilingsOptions = {},
68
+ ): Promise<SECFiling[]> {
69
+ const cacheKey = `sec:${ticker}:${formTypes.join(",")}:${limit}:${options.includeSnippets ? "snippets" : "metadata"}`;
70
+ const cached = cache.get<SECFiling[]>(cacheKey);
71
+ if (cached) return cached;
72
+
73
+ if (options.includeSnippets) {
74
+ const submissions = await searchFilingsFromCompanySubmissions(ticker, formTypes, limit).catch(() => []);
75
+ if (submissions.length > 0) {
76
+ await enrichWithEvidenceSnippets(submissions, options.snippetLimitPerFiling ?? 3);
77
+ cache.set(cacheKey, submissions, TTL.FUNDAMENTALS);
78
+ return submissions;
79
+ }
80
+ }
81
+
82
+ const params = new URLSearchParams({
83
+ q: ticker,
84
+ forms: formTypes.join(","),
85
+ dateRange: "custom",
86
+ startdt: getDateYearsAgo(3),
87
+ enddt: new Date().toISOString().split("T")[0],
88
+ from: "0",
89
+ size: String(limit),
90
+ });
91
+
92
+ const url = `${EFTS_BASE}?${params}`;
93
+ const data = await httpGet<EFTSResponse>(url, {
94
+ headers: { "User-Agent": "OpenCandle/1.0 (financial analysis agent)" },
95
+ });
96
+
97
+ // Deduplicate by accession number (EDGAR returns multiple hits per filing)
98
+ const seen = new Set<string>();
99
+ const filings: SECFiling[] = [];
100
+
101
+ for (const hit of data.hits?.hits ?? []) {
102
+ const src = hit._source;
103
+ const accession = src.adsh;
104
+ if (!accession || seen.has(accession)) continue;
105
+ if (!matchesTicker(src.display_names, ticker)) continue;
106
+ seen.add(accession);
107
+
108
+ const cik = src.ciks?.[0] ?? "";
109
+ const displayName = src.display_names?.[0] ?? "";
110
+ // Extract entity name from display format: "APPLE INC (AAPL) (CIK 0000320193)"
111
+ const entityName = displayName.split("(")[0]?.trim() ?? displayName;
112
+ const archiveDir = buildEdgarArchiveDir(cik, accession);
113
+ const primaryDocumentUrl = buildPrimaryDocumentUrl(archiveDir, hit._id);
114
+
115
+ filings.push({
116
+ formType: src.form ?? "",
117
+ filedDate: src.file_date ?? "",
118
+ periodOfReport: src.period_ending ?? "",
119
+ entityName,
120
+ accessionNumber: accession,
121
+ url: `${archiveDir}/${accession}-index.htm`,
122
+ primaryDocumentUrl,
123
+ items: src.items ?? [],
124
+ });
125
+
126
+ if (filings.length >= limit) break;
127
+ }
128
+
129
+ if (options.includeSnippets) {
130
+ await enrichWithEvidenceSnippets(filings, options.snippetLimitPerFiling ?? 3);
131
+ }
132
+
133
+ cache.set(cacheKey, filings, TTL.FUNDAMENTALS);
134
+ return filings;
135
+ }
136
+
137
+ async function searchFilingsFromCompanySubmissions(
138
+ ticker: string,
139
+ formTypes: string[],
140
+ limit: number,
141
+ ): Promise<SECFiling[]> {
142
+ const company = await resolveCompanyTicker(ticker);
143
+ if (!company) return [];
144
+
145
+ const cik = String(company.cik_str).padStart(10, "0");
146
+ const data = await httpGet<SubmissionsResponse>(`${SUBMISSIONS_BASE}/CIK${cik}.json`, {
147
+ headers: { "User-Agent": "OpenCandle/1.0 (financial analysis agent)" },
148
+ });
149
+
150
+ const filings: SECFiling[] = [];
151
+ const recent = data.filings.recent;
152
+ for (let i = 0; i < recent.form.length && filings.length < limit; i++) {
153
+ const formType = recent.form[i] ?? "";
154
+ if (!formTypes.includes(formType)) continue;
155
+ const accession = recent.accessionNumber[i];
156
+ if (!accession) continue;
157
+ const archiveDir = buildEdgarArchiveDir(cik, accession);
158
+ const primaryDocument = recent.primaryDocument[i];
159
+ filings.push({
160
+ formType,
161
+ filedDate: recent.filingDate[i] ?? "",
162
+ periodOfReport: recent.reportDate[i] ?? "",
163
+ entityName: data.name || company.title,
164
+ accessionNumber: accession,
165
+ url: `${archiveDir}/${accession}-index.htm`,
166
+ primaryDocumentUrl: primaryDocument ? `${archiveDir}/${primaryDocument}` : undefined,
167
+ items: splitFilingItems(recent.items?.[i]),
168
+ });
169
+ }
170
+ return filings;
171
+ }
172
+
173
+ async function resolveCompanyTicker(ticker: string): Promise<CompanyTickerEntry | undefined> {
174
+ const cacheKey = "sec:company-tickers";
175
+ let companies = cache.get<CompanyTickerEntry[]>(cacheKey);
176
+ if (!companies) {
177
+ const data = await httpGet<Record<string, CompanyTickerEntry>>(COMPANY_TICKERS_URL, {
178
+ headers: { "User-Agent": "OpenCandle/1.0 (financial analysis agent)" },
179
+ });
180
+ companies = Object.values(data);
181
+ cache.set(cacheKey, companies, TTL.FUNDAMENTALS);
182
+ }
183
+ const normalized = ticker.toUpperCase();
184
+ return companies.find((company) => company.ticker.toUpperCase() === normalized);
185
+ }
186
+
187
+ function splitFilingItems(raw: string | undefined): string[] {
188
+ return raw
189
+ ? raw.split(",").map((item) => item.trim()).filter(Boolean)
190
+ : [];
191
+ }
192
+
193
+ function matchesTicker(displayNames: string[] | undefined, ticker: string): boolean {
194
+ const normalized = ticker.toUpperCase();
195
+ return (displayNames ?? []).some((name) => {
196
+ const tickerGroups = name.match(/\(([^)]*)\)/g) ?? [];
197
+ return tickerGroups.some((group) =>
198
+ group
199
+ .slice(1, -1)
200
+ .split(",")
201
+ .map((part) => part.trim().toUpperCase())
202
+ .includes(normalized),
203
+ );
204
+ });
205
+ }
206
+
207
+ function buildEdgarArchiveDir(cik: string, accession: string): string {
208
+ const cikNum = cik.replace(/^0+/, "");
209
+ const accessionNoDash = accession.replace(/-/g, "");
210
+ return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accessionNoDash}`;
211
+ }
212
+
213
+ function buildPrimaryDocumentUrl(archiveDir: string, hitId: string): string | undefined {
214
+ const fileName = hitId.split(":")[1];
215
+ if (!fileName || !/\.(?:htm|html|txt)$/i.test(fileName)) return undefined;
216
+ return `${archiveDir}/${fileName}`;
217
+ }
218
+
219
+ function getDateYearsAgo(years: number): string {
220
+ const d = new Date();
221
+ d.setFullYear(d.getFullYear() - years);
222
+ return d.toISOString().split("T")[0];
223
+ }
224
+
225
+ async function enrichWithEvidenceSnippets(filings: SECFiling[], limitPerFiling: number): Promise<void> {
226
+ await Promise.all(
227
+ filings.map(async (filing) => {
228
+ if (!filing.primaryDocumentUrl) return;
229
+ try {
230
+ const raw = await fetchText(filing.primaryDocumentUrl);
231
+ filing.evidenceSnippets = extractEvidenceSnippets(raw, limitPerFiling);
232
+ } catch {
233
+ filing.evidenceSnippets = [];
234
+ }
235
+ }),
236
+ );
237
+ }
238
+
239
+ async function fetchText(url: string): Promise<string> {
240
+ const response = await fetch(url, {
241
+ headers: { "User-Agent": "OpenCandle/1.0 (financial analysis agent)" },
242
+ });
243
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
244
+ return response.text();
245
+ }
246
+
247
+ const EVIDENCE_KEYWORDS = [
248
+ "risk factor",
249
+ "legal proceedings",
250
+ "litigation",
251
+ "regulatory",
252
+ "regulation",
253
+ "revenue",
254
+ "concentration",
255
+ "management's discussion",
256
+ "management discussion",
257
+ "liquidity",
258
+ "outlook",
259
+ "guidance",
260
+ "material weakness",
261
+ "going concern",
262
+ ];
263
+
264
+ function extractEvidenceSnippets(raw: string, limit: number): string[] {
265
+ const text = stripFilingMarkup(raw);
266
+ const snippets: string[] = [];
267
+ const lower = text.toLowerCase();
268
+
269
+ for (const keyword of EVIDENCE_KEYWORDS) {
270
+ let searchFrom = 0;
271
+ while (searchFrom < lower.length) {
272
+ const index = lower.indexOf(keyword, searchFrom);
273
+ if (index === -1) break;
274
+ searchFrom = index + keyword.length;
275
+
276
+ const start = Math.max(0, index - 220);
277
+ const end = Math.min(text.length, index + keyword.length + 420);
278
+ const snippet = text.slice(start, end).replace(/\s+/g, " ").trim();
279
+ if (
280
+ snippet &&
281
+ !isLikelyTableOfContentsSnippet(snippet) &&
282
+ !snippets.some((existing) => existing.includes(snippet.slice(0, 80)))
283
+ ) {
284
+ snippets.push(snippet);
285
+ break;
286
+ }
287
+ }
288
+ if (snippets.length >= limit) break;
289
+ }
290
+
291
+ return snippets;
292
+ }
293
+
294
+ function stripFilingMarkup(raw: string): string {
295
+ return raw
296
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
297
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
298
+ .replace(/<[^>]+>/g, " ")
299
+ .replace(/&nbsp;/gi, " ")
300
+ .replace(/&amp;/gi, "&")
301
+ .replace(/&#160;/g, " ")
302
+ .replace(/&#8217;/g, "'")
303
+ .replace(/&#8220;|&#8221;/g, "\"")
304
+ .replace(/\s+/g, " ")
305
+ .trim();
306
+ }
307
+
308
+ function isLikelyTableOfContentsSnippet(snippet: string): boolean {
309
+ const lower = snippet.toLowerCase();
310
+ const itemHeadingCount = lower.match(/\bitem\s+\d+[a-z]?\b/g)?.length ?? 0;
311
+ return lower.includes("table of contents") && itemHeadingCount >= 2;
312
+ }
@@ -0,0 +1,173 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import Database from "better-sqlite3";
4
+ import { Scraper, SearchMode } from "@the-convocation/twitter-scraper";
5
+ import { cache, TTL, STALE_LIMIT } from "../infra/cache.js";
6
+ import { rateLimiter } from "../infra/rate-limiter.js";
7
+ import { getBrowserProfileDir } from "../infra/opencandle-paths.js";
8
+ import type { TwitterSentimentResult, TwitterTweet } from "../types/sentiment.js";
9
+
10
+ // ── Cookie extraction ────────────────────────────────────
11
+
12
+ interface FirefoxCookie {
13
+ name: string;
14
+ value: string;
15
+ domain: string;
16
+ path: string;
17
+ }
18
+
19
+ export function readTwitterCookies(profileDir: string): FirefoxCookie[] {
20
+ const dbPath = join(profileDir, "cookies.sqlite");
21
+ if (!existsSync(dbPath)) return [];
22
+
23
+ const db = new Database(dbPath, { readonly: true });
24
+ try {
25
+ const rows = db
26
+ .prepare(
27
+ `SELECT name, value, host AS domain, path FROM moz_cookies WHERE host LIKE ? OR host LIKE ?`,
28
+ )
29
+ .all("%x.com%", "%twitter.com%") as FirefoxCookie[];
30
+ return rows;
31
+ } finally {
32
+ db.close();
33
+ }
34
+ }
35
+
36
+ // ── Sentiment scoring ────────────────────────────────────
37
+
38
+ import { BULLISH_TERMS, BEARISH_TERMS } from "../sentiment/keywords.js";
39
+
40
+ export function scoreTwitterSentiment(
41
+ tweets: Array<{ text: string; likes: number; retweets: number }>,
42
+ ): { score: number; bullish: number; bearish: number } {
43
+ let bullishWeight = 0;
44
+ let bearishWeight = 0;
45
+ let bullishCount = 0;
46
+ let bearishCount = 0;
47
+
48
+ for (const tweet of tweets) {
49
+ const lower = tweet.text.toLowerCase();
50
+ const engagement = 1 + (tweet.likes ?? 0) + (tweet.retweets ?? 0);
51
+ const tweetBullish = BULLISH_TERMS.filter((t) => lower.includes(t)).length;
52
+ const tweetBearish = BEARISH_TERMS.filter((t) => lower.includes(t)).length;
53
+
54
+ bullishCount += tweetBullish;
55
+ bearishCount += tweetBearish;
56
+ bullishWeight += tweetBullish * engagement;
57
+ bearishWeight += tweetBearish * engagement;
58
+ }
59
+
60
+ const totalWeight = bullishWeight + bearishWeight;
61
+ return {
62
+ score: totalWeight === 0 ? 0 : (bullishWeight - bearishWeight) / totalWeight,
63
+ bullish: bullishCount,
64
+ bearish: bearishCount,
65
+ };
66
+ }
67
+
68
+ // ── Query normalization ──────────────────────────────────
69
+
70
+ export function normalizeQuery(query: string): string {
71
+ if (/^[A-Z]{1,5}$/.test(query)) return "$" + query;
72
+ return query;
73
+ }
74
+
75
+ // ── Main provider function ───────────────────────────────
76
+
77
+ export async function getTwitterSentiment(
78
+ query: string,
79
+ limit: number = 50,
80
+ hours: number = 24,
81
+ ): Promise<TwitterSentimentResult> {
82
+ const normalizedQuery = normalizeQuery(query);
83
+ const cacheKey = `twitter:${normalizedQuery}:${limit}:${hours}`;
84
+ const cached = cache.get<TwitterSentimentResult>(cacheKey);
85
+ if (cached) return cached;
86
+
87
+ await rateLimiter.acquire("twitter");
88
+
89
+ try {
90
+ const profileDir = getBrowserProfileDir();
91
+ const cookies = readTwitterCookies(profileDir);
92
+
93
+ const authToken = cookies.find((c) => c.name === "auth_token");
94
+ const ct0 = cookies.find((c) => c.name === "ct0");
95
+
96
+ if (!authToken || !ct0) {
97
+ throw new Error("No Twitter session found.");
98
+ }
99
+
100
+ const scraper = new Scraper();
101
+ const cookieStrings = cookies.map(
102
+ (c) => `${c.name}=${c.value}; Domain=${c.domain}; Path=${c.path}`,
103
+ );
104
+ await scraper.setCookies(cookieStrings);
105
+
106
+ const loggedIn = await scraper.isLoggedIn();
107
+ if (!loggedIn) {
108
+ throw new Error("Twitter session expired.");
109
+ }
110
+
111
+ const cutoff = new Date(Date.now() - hours * 3_600_000);
112
+ const tweets: TwitterTweet[] = [];
113
+ const results = scraper.searchTweets(normalizedQuery, limit, SearchMode.Latest);
114
+
115
+ for await (const tweet of results) {
116
+ const created = tweet.timeParsed ?? new Date(0);
117
+ if (created < cutoff) continue;
118
+
119
+ tweets.push({
120
+ id: tweet.id ?? "",
121
+ text: tweet.text?.slice(0, 280) ?? "",
122
+ author: tweet.username ?? "unknown",
123
+ likes: tweet.likes ?? 0,
124
+ retweets: tweet.retweets ?? 0,
125
+ replies: tweet.replies ?? 0,
126
+ views: tweet.views ?? null,
127
+ url: tweet.permanentUrl ?? "",
128
+ created: created.toISOString(),
129
+ });
130
+
131
+ if (tweets.length >= limit) break;
132
+ }
133
+
134
+ // Extract co-mentioned cashtags
135
+ const tickerRegex = /\$([A-Z]{1,5})\b/g;
136
+ const mentionCounts = new Map<string, number>();
137
+ // Exclude the searched ticker itself from co-mentions
138
+ const searchedTicker = normalizedQuery.startsWith("$")
139
+ ? normalizedQuery.slice(1)
140
+ : null;
141
+ for (const t of tweets) {
142
+ for (const match of t.text.matchAll(tickerRegex)) {
143
+ const ticker = match[1];
144
+ if (ticker === searchedTicker) continue;
145
+ mentionCounts.set(ticker, (mentionCounts.get(ticker) ?? 0) + 1);
146
+ }
147
+ }
148
+ const topMentions = [...mentionCounts.entries()]
149
+ .sort((a, b) => b[1] - a[1])
150
+ .slice(0, 10)
151
+ .map(([ticker]) => ticker);
152
+
153
+ const sentiment = scoreTwitterSentiment(tweets);
154
+
155
+ const result: TwitterSentimentResult = {
156
+ query: normalizedQuery,
157
+ tweetCount: tweets.length,
158
+ tweets,
159
+ sentimentScore: sentiment.score,
160
+ bullishCount: sentiment.bullish,
161
+ bearishCount: sentiment.bearish,
162
+ topMentions,
163
+ fetchedAt: new Date().toISOString(),
164
+ };
165
+
166
+ cache.set(cacheKey, result, TTL.SENTIMENT);
167
+ return result;
168
+ } catch (error) {
169
+ const stale = cache.getStale<TwitterSentimentResult>(cacheKey, STALE_LIMIT.SENTIMENT);
170
+ if (stale) return stale.value;
171
+ throw error;
172
+ }
173
+ }