opencandle 0.4.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 (251) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +106 -14
  3. package/dist/cli.js +2 -1
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config.d.ts +19 -3
  6. package/dist/config.js +61 -2
  7. package/dist/config.js.map +1 -1
  8. package/dist/infra/browser.d.ts +1 -3
  9. package/dist/infra/browser.js +1 -1
  10. package/dist/infra/browser.js.map +1 -1
  11. package/dist/infra/rate-limiter.d.ts +4 -0
  12. package/dist/infra/rate-limiter.js +5 -1
  13. package/dist/infra/rate-limiter.js.map +1 -1
  14. package/dist/memory/manager.d.ts +9 -0
  15. package/dist/memory/manager.js +28 -11
  16. package/dist/memory/manager.js.map +1 -1
  17. package/dist/memory/storage.d.ts +3 -2
  18. package/dist/memory/storage.js.map +1 -1
  19. package/dist/memory/types.js +4 -0
  20. package/dist/memory/types.js.map +1 -1
  21. package/dist/pi/opencandle-extension.js +230 -36
  22. package/dist/pi/opencandle-extension.js.map +1 -1
  23. package/dist/pi/setup.js +10 -0
  24. package/dist/pi/setup.js.map +1 -1
  25. package/dist/prompts/context-builder.d.ts +18 -3
  26. package/dist/prompts/context-builder.js +102 -16
  27. package/dist/prompts/context-builder.js.map +1 -1
  28. package/dist/prompts/disclaimer.js +1 -1
  29. package/dist/prompts/disclaimer.js.map +1 -1
  30. package/dist/prompts/policy-cards.d.ts +13 -0
  31. package/dist/prompts/policy-cards.js +197 -0
  32. package/dist/prompts/policy-cards.js.map +1 -0
  33. package/dist/prompts/sections.js +3 -3
  34. package/dist/prompts/sections.js.map +1 -1
  35. package/dist/prompts/workflow-prompts.js +170 -18
  36. package/dist/prompts/workflow-prompts.js.map +1 -1
  37. package/dist/providers/alpha-vantage.js +23 -1
  38. package/dist/providers/alpha-vantage.js.map +1 -1
  39. package/dist/providers/sec-edgar.d.ts +8 -1
  40. package/dist/providers/sec-edgar.js +172 -5
  41. package/dist/providers/sec-edgar.js.map +1 -1
  42. package/dist/providers/yahoo-finance.d.ts +2 -0
  43. package/dist/providers/yahoo-finance.js +134 -3
  44. package/dist/providers/yahoo-finance.js.map +1 -1
  45. package/dist/routing/classify-intent.d.ts +3 -0
  46. package/dist/routing/classify-intent.js +82 -3
  47. package/dist/routing/classify-intent.js.map +1 -1
  48. package/dist/routing/defaults.js +3 -3
  49. package/dist/routing/defaults.js.map +1 -1
  50. package/dist/routing/entity-extractor.d.ts +1 -0
  51. package/dist/routing/entity-extractor.js +158 -12
  52. package/dist/routing/entity-extractor.js.map +1 -1
  53. package/dist/routing/index.d.ts +7 -1
  54. package/dist/routing/index.js +4 -0
  55. package/dist/routing/index.js.map +1 -1
  56. package/dist/routing/legacy-rule-router.d.ts +9 -0
  57. package/dist/routing/legacy-rule-router.js +12 -0
  58. package/dist/routing/legacy-rule-router.js.map +1 -0
  59. package/dist/routing/planning.d.ts +54 -0
  60. package/dist/routing/planning.js +531 -0
  61. package/dist/routing/planning.js.map +1 -0
  62. package/dist/routing/route-manifest.d.ts +35 -0
  63. package/dist/routing/route-manifest.js +221 -0
  64. package/dist/routing/route-manifest.js.map +1 -0
  65. package/dist/routing/router-prompt.js +45 -42
  66. package/dist/routing/router-prompt.js.map +1 -1
  67. package/dist/routing/router-types.d.ts +9 -0
  68. package/dist/routing/router.d.ts +1 -0
  69. package/dist/routing/router.js +456 -12
  70. package/dist/routing/router.js.map +1 -1
  71. package/dist/routing/slot-resolver.js +46 -6
  72. package/dist/routing/slot-resolver.js.map +1 -1
  73. package/dist/routing/turn-context.d.ts +44 -0
  74. package/dist/routing/turn-context.js +45 -0
  75. package/dist/routing/turn-context.js.map +1 -0
  76. package/dist/routing/types.d.ts +13 -1
  77. package/dist/runtime/answer-contracts.d.ts +82 -0
  78. package/dist/runtime/answer-contracts.js +414 -0
  79. package/dist/runtime/answer-contracts.js.map +1 -0
  80. package/dist/runtime/artifact-contracts.d.ts +14 -0
  81. package/dist/runtime/artifact-contracts.js +57 -0
  82. package/dist/runtime/artifact-contracts.js.map +1 -0
  83. package/dist/runtime/planning-evidence.d.ts +99 -0
  84. package/dist/runtime/planning-evidence.js +445 -0
  85. package/dist/runtime/planning-evidence.js.map +1 -0
  86. package/dist/runtime/session-coordinator.d.ts +20 -2
  87. package/dist/runtime/session-coordinator.js +47 -14
  88. package/dist/runtime/session-coordinator.js.map +1 -1
  89. package/dist/system-prompt.js +4 -1
  90. package/dist/system-prompt.js.map +1 -1
  91. package/dist/tools/fundamentals/company-overview.js +1 -1
  92. package/dist/tools/fundamentals/company-overview.js.map +1 -1
  93. package/dist/tools/fundamentals/comps.js +1 -1
  94. package/dist/tools/fundamentals/comps.js.map +1 -1
  95. package/dist/tools/fundamentals/dcf.js +1 -1
  96. package/dist/tools/fundamentals/dcf.js.map +1 -1
  97. package/dist/tools/fundamentals/earnings.js +1 -1
  98. package/dist/tools/fundamentals/earnings.js.map +1 -1
  99. package/dist/tools/fundamentals/financials.js +1 -1
  100. package/dist/tools/fundamentals/financials.js.map +1 -1
  101. package/dist/tools/fundamentals/sec-filings.d.ts +1 -0
  102. package/dist/tools/fundamentals/sec-filings.js +19 -2
  103. package/dist/tools/fundamentals/sec-filings.js.map +1 -1
  104. package/dist/tools/index.d.ts +1 -0
  105. package/dist/tools/index.js +3 -0
  106. package/dist/tools/index.js.map +1 -1
  107. package/dist/tools/macro/fear-greed.js +1 -1
  108. package/dist/tools/macro/fear-greed.js.map +1 -1
  109. package/dist/tools/macro/fred-data.js +29 -5
  110. package/dist/tools/macro/fred-data.js.map +1 -1
  111. package/dist/tools/market/crypto-history.js +18 -2
  112. package/dist/tools/market/crypto-history.js.map +1 -1
  113. package/dist/tools/market/crypto-price.js +1 -1
  114. package/dist/tools/market/crypto-price.js.map +1 -1
  115. package/dist/tools/market/search-ticker.js +1 -1
  116. package/dist/tools/market/search-ticker.js.map +1 -1
  117. package/dist/tools/market/stock-history.js +1 -1
  118. package/dist/tools/market/stock-history.js.map +1 -1
  119. package/dist/tools/market/stock-quote.js +1 -1
  120. package/dist/tools/market/stock-quote.js.map +1 -1
  121. package/dist/tools/options/greeks.js +0 -1
  122. package/dist/tools/options/greeks.js.map +1 -1
  123. package/dist/tools/options/option-chain.js +9 -4
  124. package/dist/tools/options/option-chain.js.map +1 -1
  125. package/dist/tools/portfolio/correlation.js +1 -1
  126. package/dist/tools/portfolio/correlation.js.map +1 -1
  127. package/dist/tools/portfolio/holdings-overlap.d.ts +8 -0
  128. package/dist/tools/portfolio/holdings-overlap.js +105 -0
  129. package/dist/tools/portfolio/holdings-overlap.js.map +1 -0
  130. package/dist/tools/portfolio/predictions.js +1 -1
  131. package/dist/tools/portfolio/predictions.js.map +1 -1
  132. package/dist/tools/portfolio/risk-analysis.js +1 -1
  133. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  134. package/dist/tools/portfolio/tracker.js +1 -1
  135. package/dist/tools/portfolio/tracker.js.map +1 -1
  136. package/dist/tools/portfolio/watchlist.js +12 -4
  137. package/dist/tools/portfolio/watchlist.js.map +1 -1
  138. package/dist/tools/sentiment/reddit-sentiment.js +1 -1
  139. package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
  140. package/dist/tools/sentiment/sentiment-summary.js +57 -2
  141. package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
  142. package/dist/tools/sentiment/twitter-sentiment.js +1 -1
  143. package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
  144. package/dist/tools/sentiment/web-search.js +32 -3
  145. package/dist/tools/sentiment/web-search.js.map +1 -1
  146. package/dist/tools/sentiment/web-sentiment.js +1 -1
  147. package/dist/tools/sentiment/web-sentiment.js.map +1 -1
  148. package/dist/tools/technical/backtest.d.ts +2 -2
  149. package/dist/tools/technical/backtest.js +41 -27
  150. package/dist/tools/technical/backtest.js.map +1 -1
  151. package/dist/tools/technical/indicators.js +1 -3
  152. package/dist/tools/technical/indicators.js.map +1 -1
  153. package/dist/types/options.d.ts +10 -0
  154. package/dist/types/portfolio.d.ts +27 -0
  155. package/dist/workflows/compare-assets.js +38 -2
  156. package/dist/workflows/compare-assets.js.map +1 -1
  157. package/dist/workflows/options-screener.js +88 -7
  158. package/dist/workflows/options-screener.js.map +1 -1
  159. package/dist/workflows/portfolio-builder.js +7 -3
  160. package/dist/workflows/portfolio-builder.js.map +1 -1
  161. package/gui/server/ask-user-bridge.ts +82 -0
  162. package/gui/server/gui-session-manager.ts +5 -0
  163. package/gui/server/projector.ts +47 -5
  164. package/gui/server/prompt-observation.ts +61 -0
  165. package/gui/server/server.ts +119 -8
  166. package/gui/server/session-entry-wait.ts +81 -0
  167. package/gui/web/dist/assets/{CatalogOverlay-D1ImSJTe.js → CatalogOverlay-Bmp6Knu7.js} +1 -1
  168. package/gui/web/dist/assets/index-Bxt9QpLX.css +1 -0
  169. package/gui/web/dist/assets/index-CZ9DHZYy.js +67 -0
  170. package/gui/web/dist/index.html +2 -2
  171. package/package.json +18 -12
  172. package/src/cli.ts +2 -1
  173. package/src/config.ts +89 -5
  174. package/src/infra/browser.ts +1 -1
  175. package/src/infra/rate-limiter.ts +10 -1
  176. package/src/memory/manager.ts +43 -10
  177. package/src/memory/storage.ts +3 -2
  178. package/src/memory/types.ts +4 -0
  179. package/src/pi/opencandle-extension.ts +273 -42
  180. package/src/pi/setup.ts +10 -0
  181. package/src/prompts/context-builder.ts +128 -17
  182. package/src/prompts/disclaimer.ts +1 -1
  183. package/src/prompts/policy-cards.ts +220 -0
  184. package/src/prompts/sections.ts +3 -3
  185. package/src/prompts/workflow-prompts.ts +172 -18
  186. package/src/providers/alpha-vantage.ts +24 -1
  187. package/src/providers/sec-edgar.ts +220 -4
  188. package/src/providers/web-search.ts +1 -1
  189. package/src/providers/yahoo-finance.ts +171 -4
  190. package/src/routing/classify-intent.ts +94 -3
  191. package/src/routing/defaults.ts +3 -3
  192. package/src/routing/entity-extractor.ts +164 -13
  193. package/src/routing/index.ts +44 -0
  194. package/src/routing/legacy-rule-router.ts +13 -0
  195. package/src/routing/planning.ts +732 -0
  196. package/src/routing/route-manifest.ts +287 -0
  197. package/src/routing/router-prompt.ts +50 -46
  198. package/src/routing/router-types.ts +21 -0
  199. package/src/routing/router.ts +511 -12
  200. package/src/routing/slot-resolver.ts +44 -6
  201. package/src/routing/turn-context.ts +111 -0
  202. package/src/routing/types.ts +13 -1
  203. package/src/runtime/answer-contracts.ts +633 -0
  204. package/src/runtime/artifact-contracts.ts +76 -0
  205. package/src/runtime/planning-evidence.ts +591 -0
  206. package/src/runtime/session-coordinator.ts +78 -12
  207. package/src/system-prompt.ts +4 -1
  208. package/src/tools/fundamentals/company-overview.ts +1 -1
  209. package/src/tools/fundamentals/comps.ts +1 -1
  210. package/src/tools/fundamentals/dcf.ts +1 -1
  211. package/src/tools/fundamentals/earnings.ts +1 -1
  212. package/src/tools/fundamentals/financials.ts +1 -1
  213. package/src/tools/fundamentals/sec-filings.ts +25 -2
  214. package/src/tools/index.ts +3 -0
  215. package/src/tools/macro/fear-greed.ts +1 -1
  216. package/src/tools/macro/fred-data.ts +31 -5
  217. package/src/tools/market/crypto-history.ts +18 -2
  218. package/src/tools/market/crypto-price.ts +1 -1
  219. package/src/tools/market/search-ticker.ts +1 -1
  220. package/src/tools/market/stock-history.ts +1 -1
  221. package/src/tools/market/stock-quote.ts +1 -1
  222. package/src/tools/options/greeks.ts +0 -1
  223. package/src/tools/options/option-chain.ts +9 -4
  224. package/src/tools/portfolio/correlation.ts +1 -1
  225. package/src/tools/portfolio/holdings-overlap.ts +123 -0
  226. package/src/tools/portfolio/predictions.ts +1 -1
  227. package/src/tools/portfolio/risk-analysis.ts +1 -1
  228. package/src/tools/portfolio/tracker.ts +1 -1
  229. package/src/tools/portfolio/watchlist.ts +10 -4
  230. package/src/tools/sentiment/reddit-sentiment.ts +1 -1
  231. package/src/tools/sentiment/sentiment-summary.ts +62 -2
  232. package/src/tools/sentiment/twitter-sentiment.ts +1 -1
  233. package/src/tools/sentiment/web-search.ts +36 -3
  234. package/src/tools/sentiment/web-sentiment.ts +1 -1
  235. package/src/tools/technical/backtest.ts +50 -29
  236. package/src/tools/technical/indicators.ts +1 -3
  237. package/src/types/options.ts +17 -0
  238. package/src/types/portfolio.ts +32 -0
  239. package/src/workflows/compare-assets.ts +38 -2
  240. package/src/workflows/options-screener.ts +85 -7
  241. package/src/workflows/portfolio-builder.ts +7 -3
  242. package/dist/runtime/index.d.ts +0 -16
  243. package/dist/runtime/index.js +0 -10
  244. package/dist/runtime/index.js.map +0 -1
  245. package/dist/runtime/provider-ids.d.ts +0 -14
  246. package/dist/runtime/provider-ids.js +0 -14
  247. package/dist/runtime/provider-ids.js.map +0 -1
  248. package/gui/web/dist/assets/index-DBrWq43L.css +0 -1
  249. package/gui/web/dist/assets/index-RflHaj0y.js +0 -67
  250. package/src/runtime/index.ts +0 -55
  251. package/src/runtime/provider-ids.ts +0 -15
@@ -48,7 +48,11 @@ const DISPLAY_NAMES: Record<string, string> = {
48
48
  objective: "objective",
49
49
  moneynessPreference: "moneyness",
50
50
  liquidityMinimum: "liquidity",
51
+ optionStrategy: "option strategy",
52
+ costBasis: "cost basis",
53
+ shareQuantity: "share quantity",
51
54
  symbols: "symbols",
55
+ metrics: "metrics",
52
56
  };
53
57
 
54
58
  /**
@@ -62,6 +66,8 @@ export function buildDisclosureBlock(
62
66
  ): string {
63
67
  const userSpecified: string[] = [];
64
68
  const fromPreferences: string[] = [];
69
+ const fromPriorContext: string[] = [];
70
+ const fromMemory: string[] = [];
65
71
  const defaults: string[] = [];
66
72
 
67
73
  for (const [key, source] of Object.entries(slotSources)) {
@@ -75,6 +81,12 @@ export function buildDisclosureBlock(
75
81
  case "preference":
76
82
  fromPreferences.push(display);
77
83
  break;
84
+ case "prior_context":
85
+ fromPriorContext.push(display);
86
+ break;
87
+ case "memory":
88
+ fromMemory.push(display);
89
+ break;
78
90
  case "default":
79
91
  defaults.push(display);
80
92
  break;
@@ -85,6 +97,8 @@ export function buildDisclosureBlock(
85
97
  lines.push("Assumptions (reproduce this block exactly — do not relabel sources):");
86
98
  if (userSpecified.length > 0) lines.push(` User-specified: ${userSpecified.join(", ")}`);
87
99
  if (fromPreferences.length > 0) lines.push(` From saved preferences: ${fromPreferences.join(", ")}`);
100
+ if (fromPriorContext.length > 0) lines.push(` From prior context: ${fromPriorContext.join(", ")}`);
101
+ if (fromMemory.length > 0) lines.push(` From memory: ${fromMemory.join(", ")}`);
88
102
  if (defaults.length > 0) lines.push(` Defaults: ${defaults.join(", ")}`);
89
103
  if (workflowConstraints && workflowConstraints.length > 0) {
90
104
  lines.push(` Workflow constraints: ${workflowConstraints.join(", ")}`);
@@ -124,7 +138,8 @@ function formatSlotValue(value: unknown): string {
124
138
 
125
139
  export function buildPortfolioPrompt(resolution: SlotResolution<PortfolioSlots>): string {
126
140
  const { resolved: s, sources } = resolution;
127
- const isEtfOnly = s.assetScope.toLowerCase().startsWith("etf");
141
+ const normalizedScope = s.assetScope.toLowerCase();
142
+ const isFundBuildingBlocks = normalizedScope.includes("etf") || normalizedScope.includes("fund") || normalizedScope.includes("building_blocks");
128
143
 
129
144
  const disclosureBlock = buildDisclosureBlock(
130
145
  {
@@ -138,12 +153,14 @@ export function buildPortfolioPrompt(resolution: SlotResolution<PortfolioSlots>)
138
153
  sources as Record<string, SlotSource | undefined>,
139
154
  );
140
155
 
141
- const toolSteps = isEtfOnly
142
- ? `1. Identify ${s.positionCount} diverse ETF candidates appropriate for a ${s.riskProfile} ${s.timeHorizon} portfolio.
156
+ const toolSteps = isFundBuildingBlocks
157
+ ? `1. Identify ${s.positionCount} diversified fund/ETF building-block candidates appropriate for a ${s.riskProfile} ${s.timeHorizon} portfolio.
158
+ Include distinct asset-class roles such as core domestic equity, international equity, fixed income, short-duration or cash-like stability, and inflation-sensitive ballast when appropriate.
143
159
  2. Use get_stock_quote for each candidate to get current prices.
144
160
  3. Use analyze_risk on each candidate for volatility, Sharpe, and max drawdown.
145
161
  4. Use analyze_correlation across all candidates to check diversification.`
146
162
  : `1. Identify ${s.positionCount} diverse candidates appropriate for a ${s.riskProfile} ${s.timeHorizon} portfolio.
163
+ Avoid over-concentration in individual equities unless the user explicitly asked for stock picks; use diversified funds where they better fit the requested horizon and risk profile.
147
164
  2. Use get_stock_quote for each candidate to get current prices.
148
165
  3. Use get_company_overview for fundamentals on each candidate.
149
166
  4. Use analyze_risk on each candidate for volatility, Sharpe, and max drawdown.
@@ -162,13 +179,22 @@ Build a draft portfolio under these parameters:
162
179
  Steps:
163
180
  ${toolSteps}
164
181
 
182
+ Portfolio construction guardrails:
183
+ - For broad balanced portfolio requests, prefer diversified building blocks over individual-company concentration unless the user explicitly asks for stocks.
184
+ - For horizons under 5 years, include enough fixed-income, short-duration, cash-like, or inflation-sensitive ballast to make the drawdown risk match the horizon.
185
+ - If a candidate's risk metrics undermine its role (for example materially negative risk-adjusted returns, high drawdown, or excessive correlation), lower the allocation, name a role-equivalent replacement, or explain why you are keeping it.
186
+ - Keep rationale tied to each holding's role in this portfolio; do not paste company descriptions or generic issuer background.
187
+
165
188
  ${disclosureBlock}
166
189
 
167
190
  Response format:
168
191
  - Start with the assumptions block above exactly as written. Do not relabel source attribution anywhere else in your response.
192
+ - Then start the analysis with "Bottom line:" and directly say what portfolio you would build for the user.
169
193
  - Commit to the draft: give concrete percentages for each position, not ranges, and not "consider allocating X-Y%".
170
- - Present an allocation table: symbol, allocation %, dollar amount, and a one-line analyst rationale for each position (what the data showed).
194
+ - Present an allocation table: symbol, allocation %, dollar amount, current price used, estimated shares, role, and a one-line analyst rationale for each position (what the data showed and why it belongs in this portfolio).
195
+ - After the table, add a brief "Why this fits the horizon" summary explaining the growth/stability tradeoff and horizon-specific risks for the stated time horizon.
171
196
  - Include a risk summary (portfolio volatility, diversification quality) and an invalidation condition for the overall draft ("revisit if correlation exceeds 0.7 across the core ETFs" or equivalent).
197
+ - Include practical implementation notes: rebalance cadence, low-cost/liquid implementation, and tax/account caveats where relevant.
172
198
  - Suggest what to change for more growth or more safety.`;
173
199
  }
174
200
 
@@ -216,11 +242,62 @@ For LEAPS / long-dated options:
216
242
  objective: s.objective,
217
243
  moneynessPreference: s.moneynessPreference,
218
244
  liquidityMinimum: s.liquidityMinimum,
245
+ ...(s.maxPremium !== undefined ? { maxPremium: formatBudget(s.maxPremium) } : {}),
246
+ ...(s.optionStrategy ? { optionStrategy: s.optionStrategy } : {}),
247
+ ...(s.costBasis !== undefined ? { costBasis: formatBudget(s.costBasis) } : {}),
248
+ ...(s.shareQuantity !== undefined ? { shareQuantity: `${s.shareQuantity} shares` } : {}),
249
+ ...(s.catalystSymbols?.length ? { catalystSymbols: s.catalystSymbols.join(", ") } : {}),
219
250
  },
220
251
  sources as Record<string, SlotSource | undefined>,
221
252
  workflowConstraints,
222
253
  );
223
254
 
255
+ const coveredCallContext = [
256
+ s.optionStrategy ? `\n- Option strategy: ${s.optionStrategy}${tag(sources.optionStrategy)}` : "",
257
+ s.costBasis !== undefined ? `\n- Cost basis: ${formatBudget(s.costBasis)} (Position cost basis: ${formatBudget(s.costBasis)})${tag(sources.costBasis)}` : "",
258
+ s.shareQuantity !== undefined ? `\n- Share quantity: ${s.shareQuantity} shares${tag(sources.shareQuantity)}` : "",
259
+ s.catalystSymbols?.length ? `\n- Catalyst/context tickers: ${s.catalystSymbols.join(", ")}${tag(sources.catalystSymbols)}` : "",
260
+ ].join("");
261
+
262
+ const isProtectivePutContext = s.optionStrategy === "protective_put";
263
+ const isCoveredCallContext = !isProtectivePutContext && (
264
+ s.optionStrategy === "covered_call" ||
265
+ s.costBasis !== undefined ||
266
+ (s.catalystSymbols?.length ?? 0) > 0
267
+ );
268
+ const coveredCallInstructions = isCoveredCallContext
269
+ ? `
270
+ Covered-call sale guidance:
271
+ - Treat this as selling covered calls against an existing ${s.symbol} share position, not buying calls.
272
+ - Treat ${s.symbol} as the option-chain underlying.
273
+ - Because the user phrased ${s.symbol} as an existing holding, briefly state that you are treating ${s.symbol} as the held ticker. If they meant memory exposure or a different ticker, tell them to clarify and do not silently switch to another underlying.
274
+ - Do not substitute catalyst/context tickers as the option-chain underlying.
275
+ - Use catalyst/context tickers only to frame event risk, sympathy moves, and whether a nearer expiration is appropriate.
276
+ - Rank by premium collected, strike above cost basis, assignment risk, event risk, and live liquidity.
277
+ - Do not describe max loss as the option premium paid. Covered-call sale risks are assignment/capped upside, share-price downside in the owned stock, IV/event risk, and poor exit liquidity.
278
+ - If the option-chain tool reports closed_market_or_stale_quotes, do not treat zero bid/ask as confirmed live illiquidity; say the chain was checked outside regular options trading and recheck after regular options trading opens.
279
+ - If retrieved contracts have zero bid/ask, zero open interest, or otherwise unusable live quotes, the final answer MUST still include "Best action:" and "Conditional candidate:".
280
+ - In that fallback, "Best action:" should be no trade unless the user's broker shows a real bid, and "Conditional candidate:" should be a strike above cost basis labeled conditional on live bid/ask.
281
+ `
282
+ : "";
283
+ const protectivePutInstructions = isProtectivePutContext
284
+ ? `
285
+ Protective-put hedge guidance:
286
+ - Treat this as buying puts to hedge an existing long ${s.symbol} share position, not buying calls.
287
+ - Treat ${s.symbol} as the option-chain underlying.
288
+ - Rank put contracts by protection per dollar of premium: expiration fit, hedge floor, moneyness, liquidity, and premium as a percent of the stock position.
289
+ - For "doesn't cost too much" or similar cost-sensitive language, prefer liquid puts modestly below the current stock price before far-OTM lottery hedges; explain the tradeoff between cheaper premium and weaker protection.
290
+ - If share quantity is provided, use 1 put contract per 100 shares when discussing coverage and contract count.
291
+ - If share quantity is provided, state total premium for the required number of contracts in the ranked table or top-pick explanation.
292
+ - Include the hedge floor: approximate protected stock value at strike, net of premium where possible.
293
+ - Mention lower-cost alternatives such as a collar or put spread when outright put premium is high.
294
+ - Long protective puts have premium/decay risk and exercise/exit choices; do not frame assignment risk like a short option sale.
295
+ `
296
+ : "";
297
+ const topPickExplanation = isCoveredCallContext
298
+ ? `Explain why the top pick is ranked #1. For covered calls with a cost basis, include the effective assignment sale price (strike + premium collected) and compare it with the ${s.costBasis !== undefined ? formatBudget(s.costBasis) : "user's"} cost basis.`
299
+ : "Explain why the top pick is ranked #1.";
300
+
224
301
  return `Current date: ${dateStr}
225
302
  Do NOT invent or assume a different current date.${expirationSection}
226
303
 
@@ -229,51 +306,128 @@ Screen and rank options contracts for ${s.symbol}:
229
306
  - DTE target: ${s.dteTarget}${tag(sources.dteTarget)}
230
307
  - Objective: ${s.objective}${tag(sources.objective)}
231
308
  - Moneyness: ${s.moneynessPreference}${tag(sources.moneynessPreference)}
232
- - Liquidity: ${s.liquidityMinimum}${tag(sources.liquidityMinimum)}${s.budget ? `\n- Budget: ${formatBudget(s.budget)}` : ""}${s.maxPremium ? `\n- Max premium: ${formatBudget(s.maxPremium)}` : ""}
309
+ - Liquidity: ${s.liquidityMinimum}${tag(sources.liquidityMinimum)}${coveredCallContext}${s.budget ? `\n- Budget: ${formatBudget(s.budget)}` : ""}${s.maxPremium ? `\n- Max premium: ${formatBudget(s.maxPremium)}` : ""}
233
310
 
234
311
  Steps:
235
312
  1. Use get_stock_quote for ${s.symbol} to get current price and recent movement.
236
313
  2. Use get_option_chain for ${s.symbol} to get the full chain with Greeks. If you filter by contract type, pass \`type: "call"\` or \`type: "put"\` in lowercase.
237
- 3. Filter contracts matching: ${s.direction === "bullish" ? "calls" : "puts"}, DTE near ${s.dteTarget}, ${s.moneynessPreference} strikes.
238
- 4. Rank by ${s.objective}: balance premium cost, delta exposure, and probability of profit.
314
+ 3. Filter contracts matching: ${s.direction === "bullish" && !isProtectivePutContext ? "calls" : "puts"}, DTE near ${s.dteTarget}, ${s.moneynessPreference} strikes.
315
+ 4. ${isProtectivePutContext ? "Rank by hedge quality: protection per dollar of premium, expiration fit, moneyness, liquidity, and hedge floor." : `Rank by ${s.objective}: balance premium cost, delta exposure, and probability of profit.`}${s.maxPremium !== undefined ? ` Do not rank contracts above the user's max premium of ${formatBudget(s.maxPremium)} unless no contracts under that cap are liquid; if so, say the cap could not be met.` : ""}
239
316
  5. Filter for ${s.liquidityMinimum}: high open interest and tight bid-ask spread.
317
+ ${s.optionStrategy === "covered_call" ? `6. Covered call framing: treat option premium as premium received, not paid. Use the user's cost basis when provided, and include return-if-assigned and assignment/downside risk instead of long-call max-loss framing.
318
+ ` : ""}${isCoveredCallContext && s.costBasis !== undefined ? `Cost-basis math: if assigned, share gain/loss is strike minus ${formatBudget(s.costBasis)} before premium. Total return if assigned is (strike - cost basis + premium received) / cost basis.
319
+ ` : ""}
240
320
  ${longDatedInstructions}
321
+ ${coveredCallInstructions}
322
+ ${protectivePutInstructions}
241
323
  ${rankingConstraints}
242
324
  ${disclosureBlock}
243
325
 
244
326
  Response format:
245
327
  - Start with the assumptions block above exactly as written. Do not relabel source attribution anywhere else in your response.
246
- - Present top 3-5 ranked contracts in a table: strike, expiry, premium, delta, IV, OI, bid-ask spread.
247
- - Explain why the top pick is ranked #1.
248
- - Include risk caveats (max loss = premium, IV crush risk, time decay).`;
328
+ - ${isCoveredCallContext ? `Start with an Interpretation line: "Interpretation: Treating ${s.symbol} as the held ticker because you phrased it as an existing position. If you meant ${s.symbol} as memory exposure or another ticker, clarify before trading."` : isProtectivePutContext ? `Start with an Interpretation line: "Interpretation: Treating this as buying protective puts on an existing long ${s.symbol} share position."` : "State the interpretation only if the user's requested underlying is ambiguous."}
329
+ - Present top 3-5 ranked contracts in a table: strike, expiry, premium, delta, gamma, theta, vega, rho, IV, OI, bid-ask spread${isProtectivePutContext ? ", hedge floor, premium % of position" : ""}.
330
+ - ${topPickExplanation}
331
+ - Verify bid/ask and open interest in the user's broker before trading, even when OC shows live values.
332
+ - Include ${isCoveredCallContext ? "covered-call sale risks (assignment/capped upside, share-price downside in the owned stock, IV/event risk, exit liquidity). Do not describe max loss as the option premium paid" : isProtectivePutContext ? "protective-put risks (premium decay/cost, imperfect hedge before the strike, liquidity, and opportunity cost). Do not discuss short-option assignment risk" : "risk caveats (max loss = premium, IV crush risk, time decay)"}.`;
249
333
  }
250
334
 
251
335
  export function buildCompareAssetsPrompt(resolution: SlotResolution<CompareAssetsSlots>): string {
252
336
  const symbols = resolution.resolved.symbols;
253
337
  const symbolList = symbols.join(", ");
338
+ const timeHorizon = resolution.resolved.timeHorizon;
339
+ const includeSentiment = resolution.resolved.metrics?.includes("sentiment") ?? false;
340
+ const isMacroHedge = resolution.resolved.metrics?.includes("macro_hedge") ?? false;
341
+ const isInterestRateSensitive = resolution.resolved.metrics?.includes("interest_rates") ?? false;
342
+ const isOverlapComparison = resolution.resolved.metrics?.includes("overlap") ?? false;
343
+ const sentimentStep = includeSentiment
344
+ ? `\n6. Use get_sentiment_summary for each of: ${symbolList} to compare retail/news sentiment and note source availability.`
345
+ : "";
346
+ const interestRateStep = isInterestRateSensitive
347
+ ? `\n${includeSentiment ? "7" : "6"}. Use get_economic_data for the current Fed funds backdrop. Treat this as historical/current context unless you also have explicit futures or forecast evidence.`
348
+ : "";
349
+ const sentimentMetric = includeSentiment ? ", sentiment score/summary" : "";
350
+ const interestRateGuidance = isInterestRateSensitive
351
+ ? `
352
+ interest-rate comparison guidance:
353
+ - Separate the user's conditional premise from observed data: if the prompt says rates "start falling," state that the recommendation depends on why rates fall and whether market pricing confirms it.
354
+ - Give a compact scenario split: benign disinflation/soft landing, recession or earnings shock, and sticky inflation or renewed rate pressure. State which asset type should benefit in each case and why.
355
+ - Connect rates to asset mechanics: duration-like sensitivity of future earnings, cost of capital, earnings resilience, valuation multiples, and risk appetite.
356
+ - For ETF or fund comparisons, include concentration and sector-exposure risk when one asset is meaningfully narrower or more growth/technology-heavy than the other.
357
+ - If forward valuation, earnings estimates, or rate-futures evidence is unavailable, say that directly and avoid treating historical Fed funds data as a forecast.`
358
+ : "";
359
+ const overlapGuidance = isOverlapComparison
360
+ ? `
361
+ ETF overlap guidance:
362
+ - Treat this as an ETF overlap and diversification question, not a generic return ranking.
363
+ - Lead with whether adding the second ETF creates a mega-cap technology tilt, growth-factor tilt, or real diversification.
364
+ - Explain that holdings overlap and sector concentration are not the same as correlation; correlation is supporting evidence, not the answer.
365
+ - Use provider top holdings and overlap weights when available. If provider coverage is partial or unavailable, say so directly and fall back to plain-language fund structure.
366
+ - Discuss top holdings, shared mega-cap names, sector concentration, and whether the position is a deliberate tilt or accidental duplication.
367
+ - avoid treating price, RSI, or generic risk metrics as the main answer.`
368
+ : "";
369
+ const macroHedgeSteps = isMacroHedge
370
+ ? `
371
+ macro hedge decision guidance:
372
+ - Treat this as a hedge-role comparison, not a generic "which asset has better recent technicals" ranking.
373
+ - Prioritize what each asset hedges: inflation, falling real yields, USD weakness, geopolitical/systemic shocks, liquidity stress, and risk-asset drawdowns.
374
+ - Compare volatility, drawdown, and correlation regime stability. If a metric is unavailable for one asset, explain how that limits confidence instead of awarding the other asset by default.
375
+ - Use current macro evidence where available: real yields or Fed-rate direction, inflation trend, USD/liquidity backdrop, and risk-on/risk-off conditions.
376
+ - Include a compact scenario map for stagflation, rising real yields, liquidity crunch/risk-off, USD debasement, and geopolitical shock. State which asset is likely the better hedge in each scenario and why.
377
+ - End with conditional guidance: prefer the steadier hedge for capital preservation; prefer the higher-volatility asset only for debasement/asymmetric-upside exposure.`
378
+ : "";
379
+ const tableInstruction = isMacroHedge
380
+ ? "- Present a comparison table with hedge-relevant columns: hedge role, macro drivers, volatility/drawdown evidence, correlation regime, liquidity/risk-on sensitivity, current data, and missing evidence."
381
+ : isOverlapComparison
382
+ ? "- Present an ETF overlap table with columns: fund role, shared top holdings/overlap weight from provider when available, sector concentration, what exposure is duplicated, what exposure is new, and diversification implication."
383
+ : `- Present a comparison table with key metrics: price, P/E, revenue growth, profit margin, RSI, Sharpe, max drawdown${sentimentMetric}.
384
+ - Highlight which asset is stronger on each metric.`;
385
+ const technicalRiskSteps = isOverlapComparison
386
+ ? `3. Use analyze_holdings_overlap with symbols [${symbolList}] to fetch provider top holdings and compute pairwise overlap by weight.
387
+ 4. Use analyze_correlation across [${symbolList}] only as supporting diversification evidence; do not substitute correlation for holdings overlap.
388
+ 5. Skip momentum/risk tool calls unless the user asks about timing or trade setup; the core question is top holdings and sector overlap.`
389
+ : `3. Use get_technical_indicators for each to compare momentum and trend.
390
+ 4. Use analyze_risk for each to compare risk metrics.
391
+ 5. Use analyze_correlation across [${symbolList}] to check diversification.`;
392
+ const horizonLine = timeHorizon ? `\nTime horizon: ${timeHorizon}` : "";
393
+ const horizonSteps = timeHorizon
394
+ ? `
395
+ 6. Adapt the comparison to the ${timeHorizon} horizon: prioritize near-term catalysts, earnings/guidance, estimate revisions, sentiment, and forward-looking valuation evidence over long-term historical averages.
396
+ 7. Use historical risk and technical metrics as context, but explain what they do and do not imply over ${timeHorizon}.`
397
+ : "";
398
+ const horizonResponse = timeHorizon
399
+ ? `
400
+ - Start the verdict by directly answering whether the assets should compare for a ${timeHorizon} horizon and why.
401
+ - Prioritize the evidence that matters most over ${timeHorizon}: near-term catalysts, earnings/guidance, forward-looking valuation/estimates, sentiment, macro sensitivity, and company-specific risks.
402
+ - Call out evidence that is missing or unavailable, especially forward-looking estimates or company-specific catalysts.`
403
+ : "";
254
404
 
255
405
  const disclosureBlock = buildDisclosureBlock(
256
- { symbols: symbolList },
406
+ {
407
+ symbols: symbolList,
408
+ ...(timeHorizon ? { timeHorizon } : {}),
409
+ ...(resolution.resolved.metrics ? { metrics: resolution.resolved.metrics.join(", ") } : {}),
410
+ },
257
411
  resolution.sources as Record<string, SlotSource | undefined>,
258
412
  );
259
413
 
260
414
  return `Current date: ${todayStr()}
261
415
 
262
- Compare these assets side by side: ${symbolList}
416
+ Compare these assets side by side: ${symbolList}${horizonLine}
263
417
 
264
418
  Steps:
265
419
  1. Use get_stock_quote for each of: ${symbolList}.
266
420
  2. Use compare_companies with symbols [${symbols.map((s) => `"${s}"`).join(", ")}] for peer metrics. If some fundamentals are unavailable, continue the comparison with the available symbols and mark missing metrics as unavailable.
267
- 3. Use get_technical_indicators for each to compare momentum and trend.
268
- 4. Use analyze_risk for each to compare risk metrics.
269
- 5. Use analyze_correlation across [${symbolList}] to check diversification.
421
+ ${technicalRiskSteps}${sentimentStep}${interestRateStep}${horizonSteps}
422
+ ${macroHedgeSteps}
423
+ ${interestRateGuidance}
424
+ ${overlapGuidance}
270
425
 
271
426
  ${disclosureBlock}
272
427
 
273
428
  Response format:
274
429
  - Start with the assumptions block above exactly as written. Do not relabel source attribution anywhere else in your response.
275
- - Present a comparison table with key metrics: price, P/E, revenue growth, profit margin, RSI, Sharpe, max drawdown.
276
- - Highlight which asset is stronger on each metric.
430
+ ${tableInstruction}
277
431
  - Provide a summary verdict: which is most attractive and why.
278
- - Note any caveats (different sectors, market cap disparity, unavailable fundamentals, etc.).`;
432
+ - Note any caveats (different sectors, concentration, market cap disparity, unavailable fundamentals, unavailable forward-looking estimates, etc.).${horizonResponse}`;
279
433
  }
@@ -27,6 +27,23 @@ function buildUrl(fn: string, params: Record<string, string>, apiKey: string): s
27
27
  return `${BASE_URL}?${qs}`;
28
28
  }
29
29
 
30
+ function throwIfApiMessage(data: unknown): void {
31
+ if (!data || typeof data !== "object") return;
32
+
33
+ const payload = data as Record<string, unknown>;
34
+ const message = payload.Note ?? payload.Information ?? payload["Error Message"];
35
+ if (typeof message !== "string" || message.length === 0) return;
36
+
37
+ const normalized = message.toLowerCase();
38
+ if (normalized.includes("api call frequency") || normalized.includes("rate limit")) {
39
+ throw new Error(`Alpha Vantage rate limited: ${message}`);
40
+ }
41
+ if (normalized.includes("invalid api")) {
42
+ throw new ProviderCredentialError("alpha_vantage", "stale");
43
+ }
44
+ throw new Error(`Alpha Vantage error: ${message}`);
45
+ }
46
+
30
47
  export async function getOverview(
31
48
  symbol: string,
32
49
  apiKey: string,
@@ -44,6 +61,7 @@ export async function getOverview(
44
61
 
45
62
  const url = buildUrl("OVERVIEW", { symbol }, apiKey);
46
63
  const data = await httpGet<Record<string, string>>(url);
64
+ throwIfApiMessage(data);
47
65
 
48
66
  if (!data.Symbol) {
49
67
  cache.set(missingCacheKey, "missing", MISSING_OVERVIEW_TTL);
@@ -93,6 +111,7 @@ export async function getEarnings(
93
111
 
94
112
  const url = buildUrl("EARNINGS", { symbol }, apiKey);
95
113
  const data = await httpGet<{ quarterlyEarnings: any[] }>(url);
114
+ throwIfApiMessage(data);
96
115
 
97
116
  const quarterly = (data.quarterlyEarnings ?? []).slice(0, 8).map((e: any) => ({
98
117
  date: e.fiscalDateEnding,
@@ -178,7 +197,9 @@ export async function getFinancials(
178
197
  async function fetchStatement<T>(fn: string, symbol: string, apiKey: string): Promise<T> {
179
198
  await rateLimiter.acquire("alphavantage");
180
199
  const url = buildUrl(fn, { symbol }, apiKey);
181
- return httpGet<T>(url);
200
+ const data = await httpGet<T>(url);
201
+ throwIfApiMessage(data);
202
+ return data;
182
203
  }
183
204
 
184
205
  export async function getGlobalQuote(
@@ -194,6 +215,7 @@ export async function getGlobalQuote(
194
215
 
195
216
  const url = buildUrl("GLOBAL_QUOTE", { symbol }, apiKey);
196
217
  const data = await httpGet<{ "Global Quote": Record<string, string> }>(url);
218
+ throwIfApiMessage(data);
197
219
  const gq = data["Global Quote"];
198
220
 
199
221
  if (!gq || !gq["05. price"]) {
@@ -245,6 +267,7 @@ export async function getDailyHistory(
245
267
  const outputsize = daysNeeded > 100 ? "full" : "compact";
246
268
  const url = buildUrl("TIME_SERIES_DAILY", { symbol, outputsize }, apiKey);
247
269
  const data = await httpGet<{ "Time Series (Daily)": Record<string, Record<string, string>> }>(url);
270
+ throwIfApiMessage(data);
248
271
 
249
272
  const timeSeries = data["Time Series (Daily)"];
250
273
  if (!timeSeries) {
@@ -2,6 +2,8 @@ import { httpGet } from "../infra/http-client.js";
2
2
  import { cache, TTL } from "../infra/cache.js";
3
3
 
4
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";
5
7
 
6
8
  export interface SECFiling {
7
9
  formType: string;
@@ -10,6 +12,14 @@ export interface SECFiling {
10
12
  entityName: string;
11
13
  accessionNumber: string;
12
14
  url: string;
15
+ primaryDocumentUrl?: string;
16
+ items?: string[];
17
+ evidenceSnippets?: string[];
18
+ }
19
+
20
+ export interface SearchFilingsOptions {
21
+ includeSnippets?: boolean;
22
+ snippetLimitPerFiling?: number;
13
23
  }
14
24
 
15
25
  interface EFTSResponse {
@@ -23,20 +33,52 @@ interface EFTSResponse {
23
33
  display_names: string[];
24
34
  period_ending: string;
25
35
  ciks: string[];
36
+ items?: string[];
26
37
  };
27
38
  }>;
28
39
  };
29
40
  }
30
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
+
31
63
  export async function searchFilings(
32
64
  ticker: string,
33
65
  formTypes: string[] = ["10-K", "10-Q", "8-K"],
34
66
  limit: number = 10,
67
+ options: SearchFilingsOptions = {},
35
68
  ): Promise<SECFiling[]> {
36
- const cacheKey = `sec:${ticker}:${formTypes.join(",")}:${limit}`;
69
+ const cacheKey = `sec:${ticker}:${formTypes.join(",")}:${limit}:${options.includeSnippets ? "snippets" : "metadata"}`;
37
70
  const cached = cache.get<SECFiling[]>(cacheKey);
38
71
  if (cached) return cached;
39
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
+
40
82
  const params = new URLSearchParams({
41
83
  q: ticker,
42
84
  forms: formTypes.join(","),
@@ -60,12 +102,15 @@ export async function searchFilings(
60
102
  const src = hit._source;
61
103
  const accession = src.adsh;
62
104
  if (!accession || seen.has(accession)) continue;
105
+ if (!matchesTicker(src.display_names, ticker)) continue;
63
106
  seen.add(accession);
64
107
 
65
108
  const cik = src.ciks?.[0] ?? "";
66
109
  const displayName = src.display_names?.[0] ?? "";
67
110
  // Extract entity name from display format: "APPLE INC (AAPL) (CIK 0000320193)"
68
111
  const entityName = displayName.split("(")[0]?.trim() ?? displayName;
112
+ const archiveDir = buildEdgarArchiveDir(cik, accession);
113
+ const primaryDocumentUrl = buildPrimaryDocumentUrl(archiveDir, hit._id);
69
114
 
70
115
  filings.push({
71
116
  formType: src.form ?? "",
@@ -73,20 +118,102 @@ export async function searchFilings(
73
118
  periodOfReport: src.period_ending ?? "",
74
119
  entityName,
75
120
  accessionNumber: accession,
76
- url: buildEdgarUrl(cik, accession),
121
+ url: `${archiveDir}/${accession}-index.htm`,
122
+ primaryDocumentUrl,
123
+ items: src.items ?? [],
77
124
  });
78
125
 
79
126
  if (filings.length >= limit) break;
80
127
  }
81
128
 
129
+ if (options.includeSnippets) {
130
+ await enrichWithEvidenceSnippets(filings, options.snippetLimitPerFiling ?? 3);
131
+ }
132
+
82
133
  cache.set(cacheKey, filings, TTL.FUNDAMENTALS);
83
134
  return filings;
84
135
  }
85
136
 
86
- function buildEdgarUrl(cik: string, accession: string): string {
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 {
87
208
  const cikNum = cik.replace(/^0+/, "");
88
209
  const accessionNoDash = accession.replace(/-/g, "");
89
- return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accessionNoDash}/${accession}-index.htm`;
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}`;
90
217
  }
91
218
 
92
219
  function getDateYearsAgo(years: number): string {
@@ -94,3 +221,92 @@ function getDateYearsAgo(years: number): string {
94
221
  d.setFullYear(d.getFullYear() - years);
95
222
  return d.toISOString().split("T")[0];
96
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
+ }
@@ -1,5 +1,5 @@
1
1
  import { search, searchNews, SafeSearchType, SearchTimeType } from "duck-duck-scrape";
2
- import type { SearchResult, SearchResults } from "duck-duck-scrape";
2
+ import type { SearchResult } from "duck-duck-scrape";
3
3
  import type { NewsResult } from "duck-duck-scrape";
4
4
  import { httpGet, HttpError } from "../infra/http-client.js";
5
5
  import { cache, TTL, STALE_LIMIT } from "../infra/cache.js";