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
@@ -3,10 +3,12 @@ import { cache, TTL, STALE_LIMIT } from "../infra/cache.js";
3
3
  import { rateLimiter } from "../infra/rate-limiter.js";
4
4
  import { StealthBrowser } from "../infra/browser.js";
5
5
  import type { StockQuote, OHLCV } from "../types/market.js";
6
- import type { OptionsChain, OptionContract } from "../types/options.js";
6
+ import type { OptionsChain, OptionContract, OptionsMarketSession, OptionsQuoteStatus } from "../types/options.js";
7
+ import type { FundHoldings } from "../types/portfolio.js";
7
8
  import { computeGreeks } from "../tools/options/greeks.js";
8
9
 
9
10
  const BASE_URL = "https://query1.finance.yahoo.com/v8/finance/chart";
11
+ const QUOTE_SUMMARY_URL = "https://query1.finance.yahoo.com/v10/finance/quoteSummary";
10
12
 
11
13
  interface YahooChartResponse {
12
14
  chart: {
@@ -28,6 +30,29 @@ interface YahooChartResponse {
28
30
  };
29
31
  }
30
32
 
33
+ interface YahooQuoteSummaryResponse {
34
+ quoteSummary: {
35
+ result?: Array<{
36
+ price?: {
37
+ symbol?: string;
38
+ shortName?: string;
39
+ longName?: string;
40
+ };
41
+ topHoldings?: {
42
+ holdings?: Array<{
43
+ symbol?: string;
44
+ holdingName?: string;
45
+ holdingPercent?: number;
46
+ }>;
47
+ equityHoldings?: {
48
+ sectorWeightings?: Array<Record<string, number>>;
49
+ };
50
+ };
51
+ }>;
52
+ error?: { code?: string; description?: string } | null;
53
+ };
54
+ }
55
+
31
56
  export async function getQuote(symbol: string): Promise<StockQuote> {
32
57
  const cacheKey = `yahoo:quote:${symbol}`;
33
58
  const cached = cache.get<StockQuote>(cacheKey);
@@ -128,6 +153,80 @@ export async function getHistory(
128
153
  }
129
154
  }
130
155
 
156
+ export async function getFundHoldings(symbol: string): Promise<FundHoldings> {
157
+ const normalizedSymbol = symbol.toUpperCase();
158
+ const cacheKey = `yahoo:fund-holdings:${normalizedSymbol}`;
159
+ const cached = cache.get<FundHoldings>(cacheKey);
160
+ if (cached) return cached;
161
+
162
+ try {
163
+ await rateLimiter.acquire("yahoo");
164
+
165
+ const modules = encodeURIComponent("price,topHoldings");
166
+ const url = `${QUOTE_SUMMARY_URL}/${encodeURIComponent(normalizedSymbol)}?modules=${modules}`;
167
+ const data = await httpGet<YahooQuoteSummaryResponse>(url, {
168
+ headers: { "User-Agent": "OpenCandle/1.0" },
169
+ });
170
+ const result = data.quoteSummary.result?.[0];
171
+ if (data.quoteSummary.error) {
172
+ throw new Error(`Yahoo Finance: ${data.quoteSummary.error.description ?? data.quoteSummary.error.code ?? "quoteSummary error"}`);
173
+ }
174
+ if (!result?.topHoldings?.holdings?.length) {
175
+ throw new Error(`Yahoo Finance: no fund holdings returned for ${normalizedSymbol}`);
176
+ }
177
+
178
+ const holdings: FundHoldings = {
179
+ symbol: result.price?.symbol?.toUpperCase() ?? normalizedSymbol,
180
+ name: result.price?.shortName ?? result.price?.longName,
181
+ provider: "yahoo",
182
+ holdings: result.topHoldings.holdings.flatMap((holding) => {
183
+ const holdingSymbol = holding.symbol?.trim().toUpperCase();
184
+ const weight = normalizeHoldingWeight(holding.holdingPercent);
185
+ if (!holdingSymbol || weight === undefined) return [];
186
+ return [{
187
+ symbol: holdingSymbol,
188
+ name: holding.holdingName?.trim() || holdingSymbol,
189
+ weight,
190
+ }];
191
+ }),
192
+ sectorWeights: normalizeSectorWeights(result.topHoldings.equityHoldings?.sectorWeightings),
193
+ };
194
+ if (holdings.holdings.length === 0) {
195
+ throw new Error(`Yahoo Finance: no weighted fund holdings returned for ${normalizedSymbol}`);
196
+ }
197
+
198
+ cache.set(cacheKey, holdings, TTL.FUNDAMENTALS);
199
+ return holdings;
200
+ } catch (error) {
201
+ const stale = cache.getStale<FundHoldings>(cacheKey, STALE_LIMIT.FUNDAMENTALS);
202
+ if (stale) return stale.value;
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ function normalizeHoldingWeight(value: number | undefined): number | undefined {
208
+ if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined;
209
+ return value > 1 ? roundWeight(value / 100) : roundWeight(value);
210
+ }
211
+
212
+ function normalizeSectorWeights(
213
+ sectors: Array<Record<string, number>> | undefined,
214
+ ): Record<string, number> | undefined {
215
+ if (!sectors?.length) return undefined;
216
+ const weights: Record<string, number> = {};
217
+ for (const sector of sectors) {
218
+ for (const [name, rawWeight] of Object.entries(sector)) {
219
+ const weight = normalizeHoldingWeight(rawWeight);
220
+ if (weight !== undefined) weights[name] = weight;
221
+ }
222
+ }
223
+ return Object.keys(weights).length > 0 ? weights : undefined;
224
+ }
225
+
226
+ function roundWeight(value: number): number {
227
+ return Math.round(value * 10_000) / 10_000;
228
+ }
229
+
131
230
  // --- Options Chain (v7 API with crumb+cookie auth) ---
132
231
 
133
232
  const BROWSER_UA =
@@ -227,7 +326,7 @@ export async function getOptionsChain(
227
326
  try {
228
327
  const browserData = await fetchOptionsViaBrowser(symbol, expiration);
229
328
  if (browserData) {
230
- const chain = parseOptionsResponse(symbol, browserData);
329
+ const chain = parseOptionsResponse(browserData);
231
330
  cache.set(cacheKey, chain, TTL.OPTIONS_CHAIN);
232
331
  return chain;
233
332
  }
@@ -252,7 +351,7 @@ export async function getOptionsChain(
252
351
  }
253
352
 
254
353
  const data: YahooOptionsResponse = await res.json();
255
- const chain = parseOptionsResponse(symbol, data);
354
+ const chain = parseOptionsResponse(data);
256
355
  cache.set(cacheKey, chain, TTL.OPTIONS_CHAIN);
257
356
  return chain;
258
357
  }
@@ -275,7 +374,73 @@ export function computeTimeToExpiry(expirationTs: number, nowMs: number = Date.n
275
374
  return Math.max(MIN_TIME_YEARS, remainingS / SECONDS_PER_YEAR);
276
375
  }
277
376
 
278
- function parseOptionsResponse(symbol: string, data: YahooOptionsResponse): OptionsChain {
377
+ function getUsOptionsMarketSession(now: Date = new Date()): OptionsMarketSession {
378
+ const parts = new Intl.DateTimeFormat("en-US", {
379
+ timeZone: "America/New_York",
380
+ weekday: "short",
381
+ hour: "2-digit",
382
+ minute: "2-digit",
383
+ hour12: false,
384
+ }).formatToParts(now);
385
+ const part = (type: string): string => parts.find((p) => p.type === type)?.value ?? "";
386
+ const weekday = part("weekday");
387
+ if (weekday === "Sat" || weekday === "Sun") return "closed";
388
+
389
+ const hour = Number(part("hour"));
390
+ const minute = Number(part("minute"));
391
+ const minutes = hour * 60 + minute;
392
+ if (minutes < 9 * 60 + 30) return "pre_market";
393
+ if (minutes < 16 * 60) return "regular";
394
+ return "after_hours";
395
+ }
396
+
397
+ function buildOptionsQuoteStatus(
398
+ contracts: OptionContract[],
399
+ now: Date = new Date(),
400
+ ): OptionsQuoteStatus {
401
+ const marketSession = getUsOptionsMarketSession(now);
402
+ const totalContracts = contracts.length;
403
+ const zeroBidAskContracts = contracts.filter((c) => c.bid === 0 && c.ask === 0).length;
404
+ const allZeroBidAsk = totalContracts > 0 && zeroBidAskContracts === totalContracts;
405
+ const hasLiveBidAsk = contracts.some((c) => c.bid > 0 || c.ask > 0);
406
+
407
+ if (allZeroBidAsk && marketSession !== "regular") {
408
+ return {
409
+ marketSession,
410
+ bidAskState: "closed_market_or_stale_quotes",
411
+ zeroBidAskContracts,
412
+ totalContracts,
413
+ warning:
414
+ "All option contracts have $0.00/$0.00 bid/ask before regular options trading or outside market hours; treat bid/ask as closed-market or stale until the market opens.",
415
+ };
416
+ }
417
+
418
+ if (allZeroBidAsk) {
419
+ return {
420
+ marketSession,
421
+ bidAskState: "live_zero_bid_ask",
422
+ zeroBidAskContracts,
423
+ totalContracts,
424
+ warning:
425
+ "All option contracts have $0.00/$0.00 bid/ask during regular options trading hours; verify with a broker, but this may indicate live illiquidity.",
426
+ };
427
+ }
428
+
429
+ return {
430
+ marketSession,
431
+ bidAskState: hasLiveBidAsk ? "live_quotes" : "mixed_or_unknown",
432
+ zeroBidAskContracts,
433
+ totalContracts,
434
+ ...(marketSession !== "regular"
435
+ ? {
436
+ warning:
437
+ "Options bid/ask quotes may be stale outside regular options trading hours; verify live executable prices after the market opens.",
438
+ }
439
+ : {}),
440
+ };
441
+ }
442
+
443
+ function parseOptionsResponse(data: YahooOptionsResponse): OptionsChain {
279
444
  if (data.optionChain.error) {
280
445
  throw new Error(`Yahoo Finance options: ${JSON.stringify(data.optionChain.error)}`);
281
446
  }
@@ -314,6 +479,7 @@ function parseOptionsResponse(symbol: string, data: YahooOptionsResponse): Optio
314
479
  const puts = (opts.puts ?? []).map((c: any) => mapContract(c, "put"));
315
480
  const totalCallVolume = calls.reduce((s, c) => s + c.volume, 0);
316
481
  const totalPutVolume = puts.reduce((s, c) => s + c.volume, 0);
482
+ const quoteStatus = buildOptionsQuoteStatus([...calls, ...puts]);
317
483
 
318
484
  return {
319
485
  symbol: result.underlyingSymbol,
@@ -325,6 +491,7 @@ function parseOptionsResponse(symbol: string, data: YahooOptionsResponse): Optio
325
491
  totalCallVolume,
326
492
  totalPutVolume,
327
493
  putCallRatio: totalCallVolume > 0 ? totalPutVolume / totalCallVolume : 0,
494
+ quoteStatus,
328
495
  fetchedAt: new Date().toISOString(),
329
496
  };
330
497
  }
@@ -29,7 +29,21 @@ const RULES: Rule[] = [
29
29
  entities.symbols.length === 1 &&
30
30
  (/\bis\s+\S+\s+(?:attractive|undervalued|overvalued|cheap|expensive)/i.test(lower) ||
31
31
  /\bshould\s+i\s+buy\s+\$?[a-z]{1,5}\b/i.test(lower) ||
32
- /\bwhat\s+do\s+you\s+think\s+(?:of|about)\s+\$?[a-z]{1,5}\b/i.test(lower))
32
+ /\bwhat\s+do\s+you\s+think\s+(?:of|about)\s+\$?[a-z]{1,5}\b/i.test(lower) ||
33
+ /\bbull\s+(?:and|or)\s+bear\s+case\b/i.test(lower))
34
+ );
35
+ },
36
+ },
37
+ // Portfolio risk for existing holdings must route before multi-symbol compare.
38
+ {
39
+ workflow: "watchlist_or_tracking",
40
+ confidence: 0.9,
41
+ test: (input, entities) => {
42
+ const lower = input.toLowerCase();
43
+ return (
44
+ entities.symbols.length >= 1 &&
45
+ (/\bi\s+own\b/.test(lower) || /\bmy\s+holdings\b/.test(lower)) &&
46
+ (/\bportfolio\s+risk\b/.test(lower) || /\bbiggest\s+risk\b/.test(lower) || /\bconcentration\b/.test(lower))
33
47
  );
34
48
  },
35
49
  },
@@ -56,6 +70,67 @@ const RULES: Rule[] = [
56
70
  return hasNewsKeyword;
57
71
  },
58
72
  },
73
+ // Tool-backed finance tasks that are not a structured multi-step workflow.
74
+ {
75
+ workflow: "general_finance_qa",
76
+ confidence: 0.9,
77
+ test: (input, entities) => {
78
+ const lower = input.toLowerCase();
79
+ const hasOptionKeywords =
80
+ /\bcalls?\b/.test(lower) ||
81
+ /\bputs?\b/.test(lower) ||
82
+ /\boption(?:s)?\s*chain\b/.test(lower) ||
83
+ /\boptions?\b/.test(lower);
84
+ const hasCompareKeywords =
85
+ /\bcompare\b/.test(lower) ||
86
+ /\bvs\.?\b/.test(lower) ||
87
+ /\bversus\b/.test(lower) ||
88
+ /\bwhich\s+is\s+better\b/.test(lower);
89
+
90
+ if (hasOptionKeywords && entities.symbols.length >= 1) return false;
91
+ if (hasCompareKeywords && entities.symbols.length >= 2) return false;
92
+
93
+ return (
94
+ /\bbacktest\b/.test(lower) ||
95
+ /\bsentiment\b/.test(lower) ||
96
+ /\brate\s+cuts?\b/.test(lower)
97
+ );
98
+ },
99
+ },
100
+ // Broad market / sector / macro research that should receive the general
101
+ // analyst fallback rather than disappearing into an unclassified turn.
102
+ {
103
+ workflow: "general_finance_qa",
104
+ confidence: 0.85,
105
+ test: (input) => {
106
+ const lower = input.toLowerCase();
107
+ const hasResearchVerb =
108
+ /\banaly[sz]e\b/.test(lower) ||
109
+ /\bevaluat(?:e|ion)\b/.test(lower) ||
110
+ /\breview\b/.test(lower) ||
111
+ /\bdiscuss\b/.test(lower) ||
112
+ /\bpredict\b/.test(lower) ||
113
+ /\bassess\b/.test(lower) ||
114
+ /^what\b/.test(lower);
115
+ const hasBroadFinanceTopic =
116
+ /\bmarket\s+structure\b/.test(lower) ||
117
+ /\b(?:sector|industry)\b/.test(lower) ||
118
+ /\bmacro\s+risks?\b/.test(lower) ||
119
+ /\bmonetary\s+policy\b/.test(lower) ||
120
+ /\bemerging\s+markets?\b/.test(lower) ||
121
+ /\bcapital\s+flows?\b/.test(lower) ||
122
+ /\bcurrency\s+fluctuations?\b/.test(lower) ||
123
+ /\binflation\b/.test(lower);
124
+ return hasResearchVerb && hasBroadFinanceTopic;
125
+ },
126
+ },
127
+ // Existing allocation / portfolio review. This is not portfolio construction
128
+ // and should not require a budget.
129
+ {
130
+ workflow: "general_finance_qa",
131
+ confidence: 0.85,
132
+ test: (input) => isPortfolioEvaluationRequest(input),
133
+ },
59
134
  // Options: symbol + option keyword
60
135
  {
61
136
  workflow: "options_screener",
@@ -88,9 +163,10 @@ const RULES: Rule[] = [
88
163
  {
89
164
  workflow: "compare_assets",
90
165
  confidence: 0.85,
91
- test: (input) => {
166
+ test: (input, entities) => {
92
167
  const lower = input.toLowerCase();
93
- return /\bcompare\s+[a-z]{1,5}(?:\s*,?\s*(?:and\s+)?[a-z]{1,5})+/.test(lower);
168
+ return entities.symbols.length >= 2 &&
169
+ /\bcompare\s+[a-z]{1,5}\b(?:\s*,?\s*(?:and\s+)?[a-z]{1,5}\b)+/.test(lower);
94
170
  },
95
171
  },
96
172
  // Compare: 2+ uppercase symbols without explicit keyword
@@ -161,6 +237,9 @@ const RULES: Rule[] = [
161
237
  },
162
238
  ];
163
239
 
240
+ /**
241
+ * @deprecated Use the LLM router (`route`) for new classification paths; keep this only for rules-mode fallback and deterministic safety nets.
242
+ */
164
243
  export function classifyIntent(input: string): ClassificationResult {
165
244
  const trimmed = input.trim();
166
245
  if (!trimmed) {
@@ -192,3 +271,15 @@ export function classifyIntent(input: string): ClassificationResult {
192
271
  entities,
193
272
  };
194
273
  }
274
+
275
+ function isPortfolioEvaluationRequest(input: string): boolean {
276
+ const lower = input.toLowerCase();
277
+ const hasEvaluationIntent =
278
+ /\b(?:evaluat(?:e|ion)|review|assess|analy[sz]e|prospects?|risks?|opportunities?|mitigat(?:e|ion)|adjustment)\b/.test(lower);
279
+ const hasPortfolioObject =
280
+ /\b(?:portfolio|allocation|asset\s+allocation|60\/40|equity|fixed\s+income|bonds?)\b/.test(lower);
281
+ const hasConstructionIntent =
282
+ /\b(?:build|create|construct|put\s+together|invest|allocate)\b/.test(lower) &&
283
+ (/\$\s*\d|\b\d+(?:\.\d+)?\s*k\b|\bbudget\b|\bcapital\b/.test(lower));
284
+ return hasEvaluationIntent && hasPortfolioObject && !hasConstructionIntent;
285
+ }
@@ -3,9 +3,9 @@ import type { PortfolioSlots, OptionsScreenerSlots } from "./types.js";
3
3
  export const PORTFOLIO_DEFAULTS: Omit<PortfolioSlots, "budget"> = {
4
4
  riskProfile: "balanced",
5
5
  timeHorizon: "1y_plus",
6
- assetScope: "mixed_etf_and_large_cap_equities",
7
- positionCount: 4,
8
- maxSinglePositionPct: 35,
6
+ assetScope: "diversified_etf_building_blocks",
7
+ positionCount: 6,
8
+ maxSinglePositionPct: 20,
9
9
  };
10
10
 
11
11
  export const OPTIONS_SCREENER_DEFAULTS: Omit<OptionsScreenerSlots, "symbol" | "direction"> = {
@@ -7,11 +7,13 @@ const COMMON_WORDS = new Set([
7
7
  "HIM", "HIS", "HOW", "ITS", "LET", "MAY", "NEW", "NOW", "OLD", "OUR", "OWN",
8
8
  "SAY", "SHE", "TOO", "USE", "WAY", "WHO", "BOY", "DID", "GET", "HAS", "HIM",
9
9
  "OUT", "PUT", "RUN", "SET", "TOP", "WHY", "BIG", "END", "FAR", "FEW",
10
- "GOT", "LOW", "MAN", "OFF", "PAY", "TRY", "TWO", "BUY", "ETF", "ETFS",
10
+ "GOT", "LOW", "MAN", "OFF", "PAY", "TRY", "TWO", "BUY", "DOES", "ETF", "ETFS",
11
11
  // Technical analysis acronyms
12
12
  "SMA", "EMA", "RSI", "MACD", "OBV", "ATR", "ADX", "VWAP",
13
13
  // Fundamental analysis acronyms
14
14
  "DCF", "FCF", "ROE", "ROA", "ROI", "EPS", "NAV", "WACC", "EBIT",
15
+ // Regulatory / source acronyms that are not tickers in natural language
16
+ "SEC",
15
17
  "BEST", "WHAT", "WITH", "THAT", "THIS", "FROM", "HAVE", "BEEN", "SOME",
16
18
  "THEM", "THAN", "LIKE", "JUST", "OVER", "ALSO", "BACK", "MUCH", "MOST",
17
19
  "ONLY", "VERY", "WHEN", "COME", "MAKE", "FIND", "HERE", "KNOW", "TAKE",
@@ -19,22 +21,50 @@ const COMMON_WORDS = new Set([
19
21
  "NEXT", "SHOW", "LAST",
20
22
  ]);
21
23
 
24
+ const AMBIGUOUS_CONCEPT_TICKERS = new Set(["AI", "CPI", "FRED", "GUI"]);
25
+ const LOWERCASE_FINANCE_TERMS = new Set([
26
+ "bond", "bonds", "cash", "rate", "rates", "cuts", "gold", "oil", "stock", "stocks",
27
+ "fund", "funds", "etf", "etfs", "puts", "calls", "option", "options",
28
+ ]);
29
+
22
30
  export function extractEntities(input: string): ExtractedEntities {
31
+ const symbols = extractSymbols(input);
32
+ const heldSymbol = extractHeldSymbol(input, symbols);
33
+ const catalystSymbols = heldSymbol
34
+ ? symbols.filter((symbol) => symbol !== heldSymbol)
35
+ : [];
23
36
  return {
24
- symbols: extractSymbols(input),
37
+ symbols,
25
38
  budget: extractBudget(input),
26
39
  maxPremium: extractMaxPremium(input),
40
+ costBasis: extractCostBasis(input),
41
+ shareQuantity: extractShareQuantity(input),
27
42
  direction: extractDirection(input),
28
43
  riskProfile: extractRiskProfile(input),
29
44
  dteHint: extractDteHint(input),
45
+ optionStrategy: extractOptionStrategy(input),
46
+ heldSymbol,
47
+ catalystSymbols: catalystSymbols.length > 0 ? catalystSymbols : undefined,
30
48
  timeHorizon: extractTimeHorizon(input),
49
+ assetScope: extractAssetScope(input),
50
+ compareMetrics: extractCompareMetrics(input),
31
51
  };
32
52
  }
33
53
 
34
54
  export function extractBudget(input: string): number | undefined {
55
+ if (
56
+ /\b(?:at|above|below|under|over|near)\s+\$\s*[\d,]+(?:\.\d+)?\s*([kK])?\b/i.test(input) &&
57
+ !hasBudgetContext(input)
58
+ ) {
59
+ return undefined;
60
+ }
61
+
35
62
  // Match $10,000 or $10000 or $10k
36
63
  const dollarSign = input.match(/\$\s*([\d,]+(?:\.\d+)?)\s*([kK])?\b/);
37
64
  if (dollarSign) {
65
+ if (isNonBudgetDollarAmount(input, dollarSign.index ?? 0, dollarSign[0].length)) {
66
+ return undefined;
67
+ }
38
68
  const base = parseFloat(dollarSign[1].replace(/,/g, ""));
39
69
  return dollarSign[2] ? base * 1000 : base;
40
70
  }
@@ -54,34 +84,88 @@ export function extractBudget(input: string): number | undefined {
54
84
  return undefined;
55
85
  }
56
86
 
87
+ function hasBudgetContext(input: string): boolean {
88
+ return /\b(?:budget|invest|allocate|portfolio|cash|capital|with|have|spend|put\s+to\s+work)\b/i.test(input);
89
+ }
90
+
91
+ function isNonBudgetDollarAmount(input: string, start: number, length: number): boolean {
92
+ const before = input.slice(Math.max(0, start - 32), start);
93
+ const after = input.slice(start + length, start + length + 24);
94
+ return /\b(?:cost\s*basis|basis|entry(?:\s*price)?)\s*(?:is|at|of|:)?\s*$/i.test(before) ||
95
+ /^\s*(?:premium|max\s+premium|cost\s*basis|basis|entry(?:\s*price)?)\b/i.test(after);
96
+ }
97
+
57
98
  function extractSymbols(input: string): string[] {
58
99
  const symbols: string[] = [];
100
+ const addSymbol = (raw: string | undefined, options: { lowercaseContext?: boolean } = {}) => {
101
+ const symbol = raw?.toUpperCase();
102
+ if (options.lowercaseContext && LOWERCASE_FINANCE_TERMS.has(String(raw || "").toLowerCase())) {
103
+ return;
104
+ }
105
+ if (
106
+ symbol &&
107
+ symbol.length >= 1 &&
108
+ symbol.length <= 5 &&
109
+ /^[A-Z]+$/.test(symbol) &&
110
+ !COMMON_WORDS.has(symbol) &&
111
+ !isAmbiguousConceptUsage(input, symbol) &&
112
+ !symbols.includes(symbol)
113
+ ) {
114
+ symbols.push(symbol);
115
+ }
116
+ };
59
117
 
60
118
  // Match $TICKER patterns
61
119
  const dollarTickers = input.matchAll(/\$([A-Za-z]{1,5})\b/g);
62
120
  for (const match of dollarTickers) {
63
- symbols.push(match[1].toUpperCase());
121
+ addSymbol(match[1]);
122
+ }
123
+
124
+ // Match explicit lowercase ticker contexts without treating arbitrary short
125
+ // words as symbols.
126
+ const lowercaseCompare = input.match(/\bcompare\s+([a-z]{1,5})\s+(?:and|vs\.?|versus)\s+([a-z]{1,5})\b/i);
127
+ if (lowercaseCompare) {
128
+ addSymbol(lowercaseCompare[1], { lowercaseContext: true });
129
+ addSymbol(lowercaseCompare[2], { lowercaseContext: true });
130
+ }
131
+ const lowercaseTickerContext = input.matchAll(/\b(?:analy[sz]e|quote|ticker)\s+\$?([a-z]{1,5})\b|\b\$?([a-z]{1,5})\s+(?:ticker|stock|shares?|quote|options?|calls?|puts?)\b/gi);
132
+ for (const match of lowercaseTickerContext) {
133
+ addSymbol(match[1] ?? match[2], { lowercaseContext: true });
134
+ }
135
+ const lowercaseHeldPosition = input.matchAll(/\b(?:own|hold|holding|long|protect|hedge|have)\s+\d+(?:,\d{3})*\s+shares?\s+(?:of\s+)?\$?([a-z]{1,5})\b|\b\d+(?:,\d{3})*\s+shares?\s+of\s+\$?([a-z]{1,5})\b/gi);
136
+ for (const match of lowercaseHeldPosition) {
137
+ const raw = match[1] ?? match[2];
138
+ if (raw && raw !== raw.toUpperCase()) {
139
+ addSymbol(raw, { lowercaseContext: true });
140
+ }
64
141
  }
65
142
 
66
143
  // Match standalone uppercase tickers (1-5 chars, all caps)
67
144
  const words = input.split(/[\s,]+/);
68
145
  for (const word of words) {
69
146
  const cleaned = word.replace(/[^A-Za-z]/g, "");
70
- if (
71
- cleaned.length >= 1 &&
72
- cleaned.length <= 5 &&
73
- cleaned === cleaned.toUpperCase() &&
74
- /^[A-Z]+$/.test(cleaned) &&
75
- !COMMON_WORDS.has(cleaned) &&
76
- !symbols.includes(cleaned)
77
- ) {
78
- symbols.push(cleaned);
147
+ if (cleaned === cleaned.toUpperCase()) {
148
+ addSymbol(cleaned);
79
149
  }
80
150
  }
81
151
 
82
152
  return symbols;
83
153
  }
84
154
 
155
+ export function isAmbiguousConceptUsage(input: string, symbol: string): boolean {
156
+ if (!AMBIGUOUS_CONCEPT_TICKERS.has(symbol)) return false;
157
+ if (new RegExp(`\\$${symbol}\\b`).test(input)) return false;
158
+ if (
159
+ new RegExp(
160
+ `\\b(?:analyze|quote|ticker|stock|shares?|options?|calls?|puts?)\\s+${symbol}\\b|\\b${symbol}\\s+(?:ticker|stock|shares?|quote|options?|calls?|puts?)\\b`,
161
+ "i",
162
+ ).test(input)
163
+ ) {
164
+ return false;
165
+ }
166
+ return true;
167
+ }
168
+
85
169
  function extractMaxPremium(input: string): number | undefined {
86
170
  const lower = input.toLowerCase();
87
171
  if (!/\bpremium\b/.test(lower)) return undefined;
@@ -103,6 +187,36 @@ function extractMaxPremium(input: string): number | undefined {
103
187
  return undefined;
104
188
  }
105
189
 
190
+ function extractHeldSymbol(input: string, symbols: string[]): string | undefined {
191
+ const patterns = [
192
+ /\b(?:i\s+)?(?:have|own|hold|holding|long)\s+\d+(?:,\d{3})*\s+shares?\s+(?:of\s+)?\$?([A-Za-z]{1,5})\b/i,
193
+ /\b(?:my\s+)?\d+(?:,\d{3})*\s+\$?([A-Za-z]{1,5})\s+shares?\b/i,
194
+ /\b(?:i\s+)?(?:have|own|hold|holding|long)\s+\$?([A-Za-z]{1,5})\b/i,
195
+ /\bmy\s+\$?([A-Za-z]{1,5})\s+(?:position|shares?|stock)\b/i,
196
+ ];
197
+ for (const pattern of patterns) {
198
+ const match = input.match(pattern);
199
+ const symbol = match?.[1]?.toUpperCase();
200
+ if (symbol && symbols.includes(symbol)) return symbol;
201
+ }
202
+ return undefined;
203
+ }
204
+
205
+ function extractShareQuantity(input: string): number | undefined {
206
+ const match = input.match(/\b(\d{1,3}(?:,\d{3})*|\d{1,6})\s+shares?\b/i);
207
+ if (!match) return undefined;
208
+ const value = parseInt(match[1].replace(/,/g, ""), 10);
209
+ return Number.isFinite(value) ? value : undefined;
210
+ }
211
+
212
+ function extractCostBasis(input: string): number | undefined {
213
+ const match = input.match(/\b(?:cost\s*basis|basis|entry(?:\s*price)?)\s*(?:is|at|of|:)?\s*\$?\s*([\d,]+(?:\.\d+)?)\b/i) ??
214
+ input.match(/\$\s*([\d,]+(?:\.\d+)?)\s*(?:cost\s*basis|basis|entry(?:\s*price)?)\b/i);
215
+ if (!match) return undefined;
216
+ const value = parseFloat(match[1].replace(/,/g, ""));
217
+ return Number.isFinite(value) ? value : undefined;
218
+ }
219
+
106
220
  function extractDirection(input: string): "bullish" | "bearish" | undefined {
107
221
  const lower = input.toLowerCase();
108
222
  if (/\bcalls?\b/.test(lower) || /\bbullish\b/.test(lower)) return "bullish";
@@ -126,15 +240,52 @@ function extractRiskProfile(input: string): string | undefined {
126
240
 
127
241
  function extractDteHint(input: string): string | undefined {
128
242
  const lower = input.toLowerCase();
129
- if (/\bleaps?\b/i.test(lower) || /\blong[\s-]*dated\b/.test(lower)) return "leaps";
243
+ const explicitDays = lower.match(/\b(\d+)\s*(?:-|to|or)\s*(\d+)\s*(?:dte|days?)\b/);
244
+ if (explicitDays) return `${explicitDays[1]}-${explicitDays[2]} days`;
130
245
  if (/\bmonth\b/.test(lower)) return "month";
246
+ if (/\bearnings?\b.*\b(?:today|tonight|this\s+week)\b|\b(?:today|tonight|this\s+week)\b.*\bearnings?\b/.test(lower)) return "event_week";
247
+ if (/\bleaps?\b/i.test(lower) || /\blong[\s-]*dated\b/.test(lower)) return "leaps";
131
248
  if (/\bweek(?:ly|s?)?\b/.test(lower)) return "week";
132
249
  return undefined;
133
250
  }
134
251
 
252
+ function extractOptionStrategy(input: string): ExtractedEntities["optionStrategy"] | undefined {
253
+ const lower = input.toLowerCase();
254
+ if (/\bcovered\s+calls?\b/.test(lower)) return "covered_call";
255
+ if (/\b(?:protective|married)\s+puts?\b/.test(lower)) return "protective_put";
256
+ if (
257
+ /\b(?:hedge|protect|protection|insurance)\b/.test(lower) &&
258
+ /\bputs?\b/.test(lower) &&
259
+ /\b(?:own|hold|holding|long|shares?|position)\b/.test(lower)
260
+ ) {
261
+ return "protective_put";
262
+ }
263
+ return undefined;
264
+ }
265
+
135
266
  function extractTimeHorizon(input: string): string | undefined {
136
267
  const lower = input.toLowerCase();
268
+ const explicitMonths = lower.match(/\b(\d+)\s*(?:month|months|mo|mos)\b/);
269
+ if (explicitMonths) return `${explicitMonths[1]}mo`;
270
+ const explicitYears = lower.match(/\b(\d+)\s*(?:year|years|yr|yrs)\b/);
271
+ if (explicitYears) return `${explicitYears[1]}_years`;
137
272
  if (/\bshort[\s-]*term\b/.test(lower) || /\bday[\s-]*trad/i.test(lower)) return "short";
138
273
  if (/\blong[\s-]*term\b/.test(lower) || /\bbuy[\s-]*and[\s-]*hold\b/.test(lower)) return "long";
139
274
  return undefined;
140
275
  }
276
+
277
+ function extractAssetScope(input: string): string | undefined {
278
+ const lower = input.toLowerCase();
279
+ if (/\betfs?\b/.test(lower)) return "etf_focused";
280
+ return undefined;
281
+ }
282
+
283
+ function extractCompareMetrics(input: string): string[] | undefined {
284
+ const lower = input.toLowerCase();
285
+ const metrics: string[] = [];
286
+ if (/\bsentiment\b/.test(lower)) metrics.push("sentiment");
287
+ if (/\b(?:macro\s*)?hedg(?:e|ing)\b/.test(lower)) metrics.push("macro_hedge");
288
+ if (/\b(?:rates?|rate\s*cuts?|fed|federal\s+funds?|interest\s+rates?)\b/.test(lower)) metrics.push("interest_rates");
289
+ if (/\b(?:overlap|same\s+stuff|same\s+holdings|concentration|too\s+much\s+of\s+the\s+same)\b/.test(lower)) metrics.push("overlap");
290
+ return metrics.length > 0 ? metrics : undefined;
291
+ }