agent-finance-cli 0.1.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 (48) hide show
  1. package/Cargo.lock +2632 -0
  2. package/Cargo.toml +31 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/LICENSE-MIT +21 -0
  5. package/README.md +119 -0
  6. package/bin/agent-finance.js +27 -0
  7. package/npm/check-binary-links.js +50 -0
  8. package/npm/check-package.js +39 -0
  9. package/npm/create-platform-package.js +90 -0
  10. package/npm/platform.js +33 -0
  11. package/npm/postinstall.js +62 -0
  12. package/npm/resolve-binary.js +38 -0
  13. package/package.json +54 -0
  14. package/skills/core-full.md +74 -0
  15. package/skills/core.md +59 -0
  16. package/skills/futures.md +18 -0
  17. package/skills/history-indicators.md +42 -0
  18. package/skills/price.md +40 -0
  19. package/skills/providers.md +25 -0
  20. package/skills/research-data.md +34 -0
  21. package/src/app.rs +642 -0
  22. package/src/cache.rs +67 -0
  23. package/src/cli.rs +651 -0
  24. package/src/history.rs +150 -0
  25. package/src/http.rs +76 -0
  26. package/src/indicators.rs +82 -0
  27. package/src/lib.rs +15 -0
  28. package/src/main.rs +4 -0
  29. package/src/model.rs +347 -0
  30. package/src/output.rs +544 -0
  31. package/src/page_read.rs +443 -0
  32. package/src/price.rs +255 -0
  33. package/src/providers/binance_futures.rs +342 -0
  34. package/src/providers/capabilities.rs +322 -0
  35. package/src/providers/cnbc.rs +302 -0
  36. package/src/providers/mod.rs +117 -0
  37. package/src/providers/robinhood.rs +580 -0
  38. package/src/providers/sec_edgar.rs +399 -0
  39. package/src/providers/stooq/catalog.rs +159 -0
  40. package/src/providers/stooq.rs +904 -0
  41. package/src/providers/yahoo.rs +836 -0
  42. package/src/research/fetchers.rs +111 -0
  43. package/src/research/highlights.rs +345 -0
  44. package/src/research/mod.rs +943 -0
  45. package/src/research/tests.rs +42 -0
  46. package/src/skills.rs +58 -0
  47. package/src/stream.rs +356 -0
  48. package/src/time.rs +21 -0
@@ -0,0 +1,943 @@
1
+ use anyhow::{Result, anyhow};
2
+ use serde_json::Value;
3
+ use wreq::Client;
4
+
5
+ mod fetchers;
6
+ mod highlights;
7
+
8
+ use fetchers::*;
9
+ use highlights::*;
10
+
11
+ use crate::cache;
12
+ use crate::cli::{OptionsProvider, ResearchProvider};
13
+ use crate::model::{
14
+ ResearchCoverageGap, ResearchHighlight, ResearchModule, ResearchReport, ResearchSource,
15
+ SearchReport,
16
+ };
17
+ use crate::providers::{cnbc, robinhood, sec_edgar};
18
+ use crate::time::{now_local, utc_to_local};
19
+
20
+ const FUNDAMENTALS_MODULES: &[&str] = &[
21
+ "price",
22
+ "summaryProfile",
23
+ "summaryDetail",
24
+ "defaultKeyStatistics",
25
+ "financialData",
26
+ "incomeStatementHistory",
27
+ "incomeStatementHistoryQuarterly",
28
+ "balanceSheetHistory",
29
+ "balanceSheetHistoryQuarterly",
30
+ "cashflowStatementHistory",
31
+ "cashflowStatementHistoryQuarterly",
32
+ "earnings",
33
+ ];
34
+
35
+ const ANALYSIS_MODULES: &[&str] = &[
36
+ "financialData",
37
+ "recommendationTrend",
38
+ "upgradeDowngradeHistory",
39
+ "earningsTrend",
40
+ "earningsHistory",
41
+ ];
42
+
43
+ const OWNERSHIP_MODULES: &[&str] = &[
44
+ "majorHoldersBreakdown",
45
+ "institutionOwnership",
46
+ "fundOwnership",
47
+ "insiderHolders",
48
+ "insiderTransactions",
49
+ "netSharePurchaseActivity",
50
+ ];
51
+
52
+ const EVENTS_MODULES: &[&str] = &[
53
+ "calendarEvents",
54
+ "secFilings",
55
+ "summaryDetail",
56
+ "earnings",
57
+ "price",
58
+ ];
59
+
60
+ #[derive(Debug, Clone, Copy)]
61
+ pub enum QuoteSummaryKind {
62
+ Fundamentals,
63
+ Analysis,
64
+ Ownership,
65
+ Events,
66
+ }
67
+
68
+ impl QuoteSummaryKind {
69
+ pub fn label(self) -> &'static str {
70
+ match self {
71
+ QuoteSummaryKind::Fundamentals => "fundamentals",
72
+ QuoteSummaryKind::Analysis => "analysis",
73
+ QuoteSummaryKind::Ownership => "ownership",
74
+ QuoteSummaryKind::Events => "events",
75
+ }
76
+ }
77
+
78
+ fn modules(self) -> &'static [&'static str] {
79
+ match self {
80
+ QuoteSummaryKind::Fundamentals => FUNDAMENTALS_MODULES,
81
+ QuoteSummaryKind::Analysis => ANALYSIS_MODULES,
82
+ QuoteSummaryKind::Ownership => OWNERSHIP_MODULES,
83
+ QuoteSummaryKind::Events => EVENTS_MODULES,
84
+ }
85
+ }
86
+
87
+ fn highlights(self, root: Option<&Value>) -> Vec<ResearchHighlight> {
88
+ match self {
89
+ QuoteSummaryKind::Fundamentals => fundamentals_highlights(root),
90
+ QuoteSummaryKind::Analysis => analysis_highlights(root),
91
+ QuoteSummaryKind::Ownership => ownership_highlights(root),
92
+ QuoteSummaryKind::Events => events_highlights(root),
93
+ }
94
+ }
95
+
96
+ fn sec_supported(self) -> bool {
97
+ matches!(
98
+ self,
99
+ QuoteSummaryKind::Fundamentals | QuoteSummaryKind::Events
100
+ )
101
+ }
102
+ }
103
+
104
+ pub async fn quote_summary_report(
105
+ client: &Client,
106
+ symbol: &str,
107
+ kind: QuoteSummaryKind,
108
+ provider: ResearchProvider,
109
+ timezone: &str,
110
+ refresh: bool,
111
+ ttl_seconds: u64,
112
+ ) -> Result<ResearchReport> {
113
+ match provider {
114
+ ResearchProvider::Yahoo => {
115
+ yahoo_quote_summary_report(client, symbol, kind, timezone, refresh, ttl_seconds).await
116
+ }
117
+ ResearchProvider::SecEdgar => {
118
+ sec_edgar_report(client, symbol, kind, timezone, refresh, ttl_seconds).await
119
+ }
120
+ ResearchProvider::Robinhood => {
121
+ robinhood_report(client, symbol, kind, timezone, refresh, ttl_seconds).await
122
+ }
123
+ ResearchProvider::Cnbc => {
124
+ cnbc_report(client, symbol, kind, timezone, refresh, ttl_seconds).await
125
+ }
126
+ ResearchProvider::Auto => {
127
+ auto_quote_summary_report(client, symbol, kind, timezone, refresh, ttl_seconds).await
128
+ }
129
+ }
130
+ }
131
+
132
+ async fn yahoo_quote_summary_report(
133
+ client: &Client,
134
+ symbol: &str,
135
+ kind: QuoteSummaryKind,
136
+ timezone: &str,
137
+ refresh: bool,
138
+ ttl_seconds: u64,
139
+ ) -> Result<ResearchReport> {
140
+ let normalized = symbol.to_uppercase();
141
+ let category = kind.label();
142
+ let modules = kind.modules();
143
+ let key = format!("{}:{}", category, normalized);
144
+ let (fetched_at_utc, cache_status, payload) = if !refresh {
145
+ if let Some((fetched_at_utc, payload)) =
146
+ cache::read_json("yahoo-quote-summary", &key, ttl_seconds)
147
+ {
148
+ (fetched_at_utc, "hit".to_string(), payload)
149
+ } else {
150
+ fetch_quote_summary_live(client, &normalized, modules, &key).await?
151
+ }
152
+ } else {
153
+ fetch_quote_summary_live(client, &normalized, modules, &key).await?
154
+ };
155
+
156
+ Ok(ResearchReport {
157
+ symbol: normalized,
158
+ category: category.to_string(),
159
+ fetched_at_local: fetched_local(&fetched_at_utc, timezone),
160
+ fetched_at_utc: fetched_at_utc.clone(),
161
+ sources: vec![source(
162
+ ResearchProvider::Yahoo.label(),
163
+ &cache_status,
164
+ &fetched_at_utc,
165
+ timezone,
166
+ "Yahoo Finance quoteSummary payload",
167
+ )],
168
+ modules: modules
169
+ .iter()
170
+ .map(|module| module_status(module, ResearchProvider::Yahoo.label(), "requested", None))
171
+ .collect(),
172
+ coverage_gaps: Vec::new(),
173
+ highlights: kind.highlights(quote_summary_root(&payload)),
174
+ payload,
175
+ })
176
+ }
177
+
178
+ async fn auto_quote_summary_report(
179
+ client: &Client,
180
+ symbol: &str,
181
+ kind: QuoteSummaryKind,
182
+ timezone: &str,
183
+ refresh: bool,
184
+ ttl_seconds: u64,
185
+ ) -> Result<ResearchReport> {
186
+ let mut reports = Vec::new();
187
+ let mut provider_gaps = Vec::new();
188
+
189
+ match kind {
190
+ QuoteSummaryKind::Fundamentals => {
191
+ let (yahoo_result, sec_result, robinhood_result, cnbc_result) = tokio::join!(
192
+ yahoo_quote_summary_report(client, symbol, kind, timezone, refresh, ttl_seconds),
193
+ sec_edgar_report(client, symbol, kind, timezone, refresh, ttl_seconds),
194
+ robinhood_report(client, symbol, kind, timezone, refresh, ttl_seconds),
195
+ cnbc_report(client, symbol, kind, timezone, refresh, ttl_seconds)
196
+ );
197
+ collect_report_result(
198
+ ResearchProvider::Yahoo.label(),
199
+ yahoo_result,
200
+ &mut reports,
201
+ &mut provider_gaps,
202
+ );
203
+ collect_report_result(
204
+ ResearchProvider::SecEdgar.label(),
205
+ sec_result,
206
+ &mut reports,
207
+ &mut provider_gaps,
208
+ );
209
+ collect_report_result(
210
+ ResearchProvider::Robinhood.label(),
211
+ robinhood_result,
212
+ &mut reports,
213
+ &mut provider_gaps,
214
+ );
215
+ collect_report_result(
216
+ ResearchProvider::Cnbc.label(),
217
+ cnbc_result,
218
+ &mut reports,
219
+ &mut provider_gaps,
220
+ );
221
+ }
222
+ QuoteSummaryKind::Events => {
223
+ let (yahoo_result, sec_result, robinhood_result) = tokio::join!(
224
+ yahoo_quote_summary_report(client, symbol, kind, timezone, refresh, ttl_seconds),
225
+ sec_edgar_report(client, symbol, kind, timezone, refresh, ttl_seconds),
226
+ robinhood_report(client, symbol, kind, timezone, refresh, ttl_seconds)
227
+ );
228
+ collect_report_result(
229
+ ResearchProvider::Yahoo.label(),
230
+ yahoo_result,
231
+ &mut reports,
232
+ &mut provider_gaps,
233
+ );
234
+ collect_report_result(
235
+ ResearchProvider::SecEdgar.label(),
236
+ sec_result,
237
+ &mut reports,
238
+ &mut provider_gaps,
239
+ );
240
+ collect_report_result(
241
+ ResearchProvider::Robinhood.label(),
242
+ robinhood_result,
243
+ &mut reports,
244
+ &mut provider_gaps,
245
+ );
246
+ }
247
+ QuoteSummaryKind::Analysis | QuoteSummaryKind::Ownership => {
248
+ collect_report_result(
249
+ ResearchProvider::Yahoo.label(),
250
+ yahoo_quote_summary_report(client, symbol, kind, timezone, refresh, ttl_seconds)
251
+ .await,
252
+ &mut reports,
253
+ &mut provider_gaps,
254
+ );
255
+ }
256
+ }
257
+
258
+ if reports.is_empty() {
259
+ return Err(anyhow!(
260
+ "all research providers failed for {} {}: {}",
261
+ symbol.to_uppercase(),
262
+ kind.label(),
263
+ provider_gaps
264
+ .iter()
265
+ .map(|gap| format!("{}={}", gap.module, gap.reason))
266
+ .collect::<Vec<_>>()
267
+ .join("; ")
268
+ ));
269
+ }
270
+ Ok(merge_reports(reports, provider_gaps, timezone))
271
+ }
272
+
273
+ fn collect_report_result(
274
+ provider: &str,
275
+ result: Result<ResearchReport>,
276
+ reports: &mut Vec<ResearchReport>,
277
+ provider_gaps: &mut Vec<ResearchCoverageGap>,
278
+ ) {
279
+ match result {
280
+ Ok(report) => reports.push(report),
281
+ Err(error) => provider_gaps.push(gap(
282
+ provider,
283
+ format!("{provider} supplement failed: {error:#}"),
284
+ )),
285
+ }
286
+ }
287
+
288
+ async fn sec_edgar_report(
289
+ client: &Client,
290
+ symbol: &str,
291
+ kind: QuoteSummaryKind,
292
+ timezone: &str,
293
+ refresh: bool,
294
+ ttl_seconds: u64,
295
+ ) -> Result<ResearchReport> {
296
+ if !kind.sec_supported() {
297
+ return Err(anyhow!(
298
+ "sec-edgar does not support {}; use --provider yahoo or --provider auto",
299
+ kind.label()
300
+ ));
301
+ }
302
+
303
+ let normalized = symbol.to_uppercase();
304
+ let key = format!("{}:company:{}", kind.label(), normalized);
305
+ let include_companyfacts = matches!(kind, QuoteSummaryKind::Fundamentals);
306
+ let (fetched_at_utc, cache_status, payload) = if !refresh {
307
+ if let Some((fetched_at_utc, payload)) =
308
+ cache::read_json("sec-edgar-company", &key, ttl_seconds)
309
+ {
310
+ (fetched_at_utc, "hit".to_string(), payload)
311
+ } else {
312
+ fetch_sec_company_live(client, &normalized, include_companyfacts, &key).await?
313
+ }
314
+ } else {
315
+ fetch_sec_company_live(client, &normalized, include_companyfacts, &key).await?
316
+ };
317
+
318
+ let highlights = match kind {
319
+ QuoteSummaryKind::Fundamentals => sec_edgar::fundamentals_highlights(&payload),
320
+ QuoteSummaryKind::Events => sec_edgar::events_highlights(&payload),
321
+ QuoteSummaryKind::Analysis | QuoteSummaryKind::Ownership => unreachable!(),
322
+ };
323
+ let modules = match kind {
324
+ QuoteSummaryKind::Fundamentals => vec![
325
+ module_status(
326
+ "companyfacts",
327
+ ResearchProvider::SecEdgar.label(),
328
+ "available",
329
+ Some("official XBRL companyfacts"),
330
+ ),
331
+ module_status(
332
+ "submissions",
333
+ ResearchProvider::SecEdgar.label(),
334
+ "available",
335
+ Some("official company submissions"),
336
+ ),
337
+ ],
338
+ QuoteSummaryKind::Events => vec![module_status(
339
+ "submissions",
340
+ ResearchProvider::SecEdgar.label(),
341
+ "available",
342
+ Some("official recent filings"),
343
+ )],
344
+ QuoteSummaryKind::Analysis | QuoteSummaryKind::Ownership => unreachable!(),
345
+ };
346
+
347
+ Ok(ResearchReport {
348
+ symbol: normalized,
349
+ category: kind.label().to_string(),
350
+ fetched_at_local: fetched_local(&fetched_at_utc, timezone),
351
+ fetched_at_utc: fetched_at_utc.clone(),
352
+ sources: vec![source(
353
+ ResearchProvider::SecEdgar.label(),
354
+ &cache_status,
355
+ &fetched_at_utc,
356
+ timezone,
357
+ "SEC official submissions and XBRL companyfacts",
358
+ )],
359
+ modules,
360
+ coverage_gaps: sec_coverage_gaps(kind),
361
+ highlights,
362
+ payload,
363
+ })
364
+ }
365
+
366
+ async fn robinhood_report(
367
+ client: &Client,
368
+ symbol: &str,
369
+ kind: QuoteSummaryKind,
370
+ timezone: &str,
371
+ refresh: bool,
372
+ ttl_seconds: u64,
373
+ ) -> Result<ResearchReport> {
374
+ if !matches!(
375
+ kind,
376
+ QuoteSummaryKind::Fundamentals | QuoteSummaryKind::Events
377
+ ) {
378
+ return Err(anyhow!(
379
+ "robinhood does not support {}; use --provider yahoo or --provider auto",
380
+ kind.label()
381
+ ));
382
+ }
383
+
384
+ let normalized = symbol.to_uppercase();
385
+ let key = format!("{}:{}", kind.label(), normalized);
386
+ let (fetched_at_utc, cache_status, payload) = if !refresh {
387
+ if let Some((fetched_at_utc, payload)) =
388
+ cache::read_json("robinhood-research", &key, ttl_seconds)
389
+ {
390
+ (fetched_at_utc, "hit".to_string(), payload)
391
+ } else {
392
+ fetch_robinhood_live(client, &normalized, kind, &key).await?
393
+ }
394
+ } else {
395
+ fetch_robinhood_live(client, &normalized, kind, &key).await?
396
+ };
397
+
398
+ let highlights = match kind {
399
+ QuoteSummaryKind::Fundamentals => robinhood::fundamentals_highlights(&payload),
400
+ QuoteSummaryKind::Events => robinhood::events_highlights(&payload),
401
+ QuoteSummaryKind::Analysis | QuoteSummaryKind::Ownership => unreachable!(),
402
+ };
403
+ let modules = match kind {
404
+ QuoteSummaryKind::Fundamentals => vec![
405
+ module_status(
406
+ "instrument",
407
+ ResearchProvider::Robinhood.label(),
408
+ "available",
409
+ Some("public instrument profile"),
410
+ ),
411
+ module_status(
412
+ "fundamentals",
413
+ ResearchProvider::Robinhood.label(),
414
+ "available",
415
+ Some("public Robinhood fundamentals endpoint"),
416
+ ),
417
+ ],
418
+ QuoteSummaryKind::Events => vec![
419
+ module_status(
420
+ "splits",
421
+ ResearchProvider::Robinhood.label(),
422
+ "available",
423
+ Some("instrument split records"),
424
+ ),
425
+ module_status(
426
+ "market_hours",
427
+ ResearchProvider::Robinhood.label(),
428
+ "available",
429
+ Some("market session hours"),
430
+ ),
431
+ ],
432
+ QuoteSummaryKind::Analysis | QuoteSummaryKind::Ownership => unreachable!(),
433
+ };
434
+
435
+ Ok(ResearchReport {
436
+ symbol: normalized,
437
+ category: kind.label().to_string(),
438
+ fetched_at_local: fetched_local(&fetched_at_utc, timezone),
439
+ fetched_at_utc: fetched_at_utc.clone(),
440
+ sources: vec![source(
441
+ ResearchProvider::Robinhood.label(),
442
+ &cache_status,
443
+ &fetched_at_utc,
444
+ timezone,
445
+ "Robinhood public stock endpoints",
446
+ )],
447
+ modules,
448
+ coverage_gaps: robinhood_coverage_gaps(kind),
449
+ highlights,
450
+ payload,
451
+ })
452
+ }
453
+
454
+ async fn cnbc_report(
455
+ client: &Client,
456
+ symbol: &str,
457
+ kind: QuoteSummaryKind,
458
+ timezone: &str,
459
+ refresh: bool,
460
+ ttl_seconds: u64,
461
+ ) -> Result<ResearchReport> {
462
+ if !matches!(kind, QuoteSummaryKind::Fundamentals) {
463
+ return Err(anyhow!(
464
+ "cnbc does not support {}; use --provider yahoo or --provider auto",
465
+ kind.label()
466
+ ));
467
+ }
468
+
469
+ let normalized = symbol.to_uppercase();
470
+ let key = format!("fundamentals:{}", normalized);
471
+ let (fetched_at_utc, cache_status, payload) = if !refresh {
472
+ if let Some((fetched_at_utc, payload)) =
473
+ cache::read_json("cnbc-fundamentals-lite", &key, ttl_seconds)
474
+ {
475
+ (fetched_at_utc, "hit".to_string(), payload)
476
+ } else {
477
+ fetch_cnbc_live(client, &normalized, &key).await?
478
+ }
479
+ } else {
480
+ fetch_cnbc_live(client, &normalized, &key).await?
481
+ };
482
+
483
+ Ok(ResearchReport {
484
+ symbol: normalized,
485
+ category: kind.label().to_string(),
486
+ fetched_at_local: fetched_local(&fetched_at_utc, timezone),
487
+ fetched_at_utc: fetched_at_utc.clone(),
488
+ sources: vec![source(
489
+ ResearchProvider::Cnbc.label(),
490
+ &cache_status,
491
+ &fetched_at_utc,
492
+ timezone,
493
+ "CNBC quote payload fundamentals-lite fields",
494
+ )],
495
+ modules: vec![module_status(
496
+ "fundamentals-lite",
497
+ ResearchProvider::Cnbc.label(),
498
+ "available",
499
+ Some("valuation and TTM summary fields from quote payload"),
500
+ )],
501
+ coverage_gaps: vec![gap(
502
+ "statements/ownership/options",
503
+ "CNBC public quote payload is a fundamentals-lite cross-check, not a full company research API",
504
+ )],
505
+ highlights: cnbc::fundamentals_highlights(&payload),
506
+ payload,
507
+ })
508
+ }
509
+
510
+ pub struct OptionsReportRequest<'a> {
511
+ pub client: &'a Client,
512
+ pub symbol: &'a str,
513
+ pub provider: OptionsProvider,
514
+ pub expiry: Option<i64>,
515
+ pub expiration_date: Option<&'a str>,
516
+ pub count: usize,
517
+ pub timezone: &'a str,
518
+ pub refresh: bool,
519
+ pub ttl_seconds: u64,
520
+ }
521
+
522
+ pub async fn options_report(request: OptionsReportRequest<'_>) -> Result<ResearchReport> {
523
+ let OptionsReportRequest {
524
+ client,
525
+ symbol,
526
+ provider,
527
+ expiry,
528
+ expiration_date,
529
+ count,
530
+ timezone,
531
+ refresh,
532
+ ttl_seconds,
533
+ } = request;
534
+
535
+ match provider {
536
+ OptionsProvider::Yahoo => {
537
+ yahoo_options_report(client, symbol, expiry, timezone, refresh, ttl_seconds).await
538
+ }
539
+ OptionsProvider::Robinhood => {
540
+ let expiration_from_epoch = expiry.and_then(epoch_to_date);
541
+ let expiration_date = expiration_date.or(expiration_from_epoch.as_deref());
542
+ robinhood_options_report(
543
+ client,
544
+ symbol,
545
+ expiration_date,
546
+ count,
547
+ timezone,
548
+ refresh,
549
+ ttl_seconds,
550
+ )
551
+ .await
552
+ }
553
+ OptionsProvider::Auto => {
554
+ let expiration = expiration_date
555
+ .map(ToString::to_string)
556
+ .or_else(|| expiry.and_then(epoch_to_date));
557
+ let (yahoo_result, robinhood_result) = tokio::join!(
558
+ yahoo_options_report(client, symbol, expiry, timezone, refresh, ttl_seconds),
559
+ robinhood_options_report(
560
+ client,
561
+ symbol,
562
+ expiration.as_deref(),
563
+ count,
564
+ timezone,
565
+ refresh,
566
+ ttl_seconds
567
+ )
568
+ );
569
+ let mut reports = Vec::new();
570
+ let mut gaps = Vec::new();
571
+ for (provider, result) in [
572
+ (OptionsProvider::Yahoo.label(), yahoo_result),
573
+ (OptionsProvider::Robinhood.label(), robinhood_result),
574
+ ] {
575
+ match result {
576
+ Ok(report) => reports.push(report),
577
+ Err(error) => gaps.push(gap(
578
+ provider,
579
+ format!("{provider} options failed or unsupported: {error:#}"),
580
+ )),
581
+ }
582
+ }
583
+ if reports.is_empty() {
584
+ return Err(anyhow!(
585
+ "all options providers failed for {}: {}",
586
+ symbol.to_uppercase(),
587
+ gaps.iter()
588
+ .map(|gap| format!("{}={}", gap.module, gap.reason))
589
+ .collect::<Vec<_>>()
590
+ .join("; ")
591
+ ));
592
+ }
593
+ Ok(merge_reports(reports, gaps, timezone))
594
+ }
595
+ }
596
+ }
597
+
598
+ async fn yahoo_options_report(
599
+ client: &Client,
600
+ symbol: &str,
601
+ expiry: Option<i64>,
602
+ timezone: &str,
603
+ refresh: bool,
604
+ ttl_seconds: u64,
605
+ ) -> Result<ResearchReport> {
606
+ let normalized = symbol.to_uppercase();
607
+ let key = format!("{}:{}", normalized, expiry.unwrap_or_default());
608
+ let (fetched_at_utc, cache_status, payload) = if !refresh {
609
+ if let Some((fetched_at_utc, payload)) =
610
+ cache::read_json("yahoo-options", &key, ttl_seconds)
611
+ {
612
+ (fetched_at_utc, "hit".to_string(), payload)
613
+ } else {
614
+ fetch_options_live(client, &normalized, expiry, &key).await?
615
+ }
616
+ } else {
617
+ fetch_options_live(client, &normalized, expiry, &key).await?
618
+ };
619
+
620
+ Ok(ResearchReport {
621
+ symbol: normalized,
622
+ category: "options".to_string(),
623
+ fetched_at_local: fetched_local(&fetched_at_utc, timezone),
624
+ fetched_at_utc: fetched_at_utc.clone(),
625
+ sources: vec![source(
626
+ ResearchProvider::Yahoo.label(),
627
+ &cache_status,
628
+ &fetched_at_utc,
629
+ timezone,
630
+ "Yahoo Finance optionChain payload",
631
+ )],
632
+ modules: vec![module_status(
633
+ "options",
634
+ ResearchProvider::Yahoo.label(),
635
+ "available",
636
+ None,
637
+ )],
638
+ coverage_gaps: vec![gap(
639
+ ResearchProvider::SecEdgar.label(),
640
+ "SEC EDGAR does not provide option chains",
641
+ )],
642
+ highlights: options_highlights(&payload),
643
+ payload,
644
+ })
645
+ }
646
+
647
+ async fn robinhood_options_report(
648
+ client: &Client,
649
+ symbol: &str,
650
+ expiration_date: Option<&str>,
651
+ count: usize,
652
+ timezone: &str,
653
+ refresh: bool,
654
+ ttl_seconds: u64,
655
+ ) -> Result<ResearchReport> {
656
+ let normalized = symbol.to_uppercase();
657
+ let key = format!(
658
+ "{}:{}:{}",
659
+ normalized,
660
+ expiration_date.unwrap_or("nearest"),
661
+ count.max(1)
662
+ );
663
+ let (fetched_at_utc, cache_status, payload) = if !refresh {
664
+ if let Some((fetched_at_utc, payload)) =
665
+ cache::read_json("robinhood-options", &key, ttl_seconds)
666
+ {
667
+ (fetched_at_utc, "hit".to_string(), payload)
668
+ } else {
669
+ fetch_robinhood_options_live(client, &normalized, expiration_date, count, &key).await?
670
+ }
671
+ } else {
672
+ fetch_robinhood_options_live(client, &normalized, expiration_date, count, &key).await?
673
+ };
674
+
675
+ Ok(ResearchReport {
676
+ symbol: normalized,
677
+ category: "options".to_string(),
678
+ fetched_at_local: fetched_local(&fetched_at_utc, timezone),
679
+ fetched_at_utc: fetched_at_utc.clone(),
680
+ sources: vec![source(
681
+ ResearchProvider::Robinhood.label(),
682
+ &cache_status,
683
+ &fetched_at_utc,
684
+ timezone,
685
+ "Robinhood public option chain and contract metadata",
686
+ )],
687
+ modules: vec![module_status(
688
+ "option_instruments",
689
+ ResearchProvider::Robinhood.label(),
690
+ "available",
691
+ Some("contract metadata; not quote/IV marketdata"),
692
+ )],
693
+ coverage_gaps: vec![gap(
694
+ "option quotes/IV/greeks",
695
+ "Robinhood anonymous chain metadata does not reliably expose option bid/ask, IV, or greeks",
696
+ )],
697
+ highlights: robinhood::options_highlights(&payload),
698
+ payload,
699
+ })
700
+ }
701
+
702
+ pub async fn news_report(
703
+ client: &Client,
704
+ symbol: &str,
705
+ count: usize,
706
+ timezone: &str,
707
+ refresh: bool,
708
+ ttl_seconds: u64,
709
+ ) -> Result<SearchReport> {
710
+ search_report_inner(SearchReportRequest {
711
+ category: "news",
712
+ client,
713
+ query: symbol,
714
+ quotes_count: 0,
715
+ news_count: count,
716
+ timezone,
717
+ refresh,
718
+ ttl_seconds,
719
+ })
720
+ .await
721
+ }
722
+
723
+ pub async fn search_report(
724
+ client: &Client,
725
+ query: &str,
726
+ quotes_count: usize,
727
+ news_count: usize,
728
+ timezone: &str,
729
+ refresh: bool,
730
+ ttl_seconds: u64,
731
+ ) -> Result<SearchReport> {
732
+ search_report_inner(SearchReportRequest {
733
+ category: "search",
734
+ client,
735
+ query,
736
+ quotes_count,
737
+ news_count,
738
+ timezone,
739
+ refresh,
740
+ ttl_seconds,
741
+ })
742
+ .await
743
+ }
744
+
745
+ struct SearchReportRequest<'a> {
746
+ category: &'static str,
747
+ client: &'a Client,
748
+ query: &'a str,
749
+ quotes_count: usize,
750
+ news_count: usize,
751
+ timezone: &'a str,
752
+ refresh: bool,
753
+ ttl_seconds: u64,
754
+ }
755
+
756
+ async fn search_report_inner(request: SearchReportRequest<'_>) -> Result<SearchReport> {
757
+ let quotes_count = request.quotes_count.clamp(0, 50);
758
+ let news_count = request.news_count.clamp(0, 50);
759
+ let key = format!(
760
+ "{}:{}:{}:{}",
761
+ request.category, request.query, quotes_count, news_count
762
+ );
763
+ let (fetched_at_utc, cache_status, payload) = if !request.refresh {
764
+ if let Some((fetched_at_utc, payload)) =
765
+ cache::read_json("yahoo-search", &key, request.ttl_seconds)
766
+ {
767
+ (fetched_at_utc, "hit".to_string(), payload)
768
+ } else {
769
+ fetch_search_live(
770
+ request.client,
771
+ request.query,
772
+ quotes_count,
773
+ news_count,
774
+ &key,
775
+ )
776
+ .await?
777
+ }
778
+ } else {
779
+ fetch_search_live(
780
+ request.client,
781
+ request.query,
782
+ quotes_count,
783
+ news_count,
784
+ &key,
785
+ )
786
+ .await?
787
+ };
788
+
789
+ Ok(SearchReport {
790
+ category: request.category.to_string(),
791
+ query: request.query.to_string(),
792
+ provider: ResearchProvider::Yahoo.label().to_string(),
793
+ fetched_at_local: fetched_local(&fetched_at_utc, request.timezone),
794
+ fetched_at_utc,
795
+ cache_status,
796
+ highlights: search_highlights(&payload),
797
+ payload,
798
+ })
799
+ }
800
+
801
+ pub async fn screen_report(
802
+ client: &Client,
803
+ screener: &str,
804
+ count: usize,
805
+ timezone: &str,
806
+ refresh: bool,
807
+ ttl_seconds: u64,
808
+ ) -> Result<SearchReport> {
809
+ let count = count.clamp(1, 250);
810
+ let key = format!("screen:{}:{}", screener, count);
811
+ let (fetched_at_utc, cache_status, payload) = if !refresh {
812
+ if let Some((fetched_at_utc, payload)) = cache::read_json("yahoo-screen", &key, ttl_seconds)
813
+ {
814
+ (fetched_at_utc, "hit".to_string(), payload)
815
+ } else {
816
+ fetch_screen_live(client, screener, count, &key).await?
817
+ }
818
+ } else {
819
+ fetch_screen_live(client, screener, count, &key).await?
820
+ };
821
+
822
+ Ok(SearchReport {
823
+ category: "screen".to_string(),
824
+ query: screener.to_string(),
825
+ provider: ResearchProvider::Yahoo.label().to_string(),
826
+ fetched_at_local: fetched_local(&fetched_at_utc, timezone),
827
+ fetched_at_utc,
828
+ cache_status,
829
+ highlights: screen_highlights(&payload),
830
+ payload,
831
+ })
832
+ }
833
+
834
+ fn merge_reports(
835
+ reports: Vec<ResearchReport>,
836
+ provider_gaps: Vec<ResearchCoverageGap>,
837
+ timezone: &str,
838
+ ) -> ResearchReport {
839
+ let mut reports = reports.into_iter();
840
+ let mut primary = reports
841
+ .next()
842
+ .expect("merge_reports requires at least one report");
843
+ let mut payloads = serde_json::Map::new();
844
+ payloads.insert(provider_payload_key(&primary.sources), primary.payload);
845
+
846
+ for report in reports {
847
+ payloads.insert(provider_payload_key(&report.sources), report.payload);
848
+ primary.sources.extend(report.sources);
849
+ primary.modules.extend(report.modules);
850
+ primary.coverage_gaps.extend(report.coverage_gaps);
851
+ primary.highlights.extend(report.highlights);
852
+ }
853
+ primary.coverage_gaps.extend(provider_gaps);
854
+ if let Some(latest_source_time) = primary
855
+ .sources
856
+ .iter()
857
+ .map(|source| source.fetched_at_utc.as_str())
858
+ .max()
859
+ {
860
+ primary.fetched_at_utc = latest_source_time.to_string();
861
+ primary.fetched_at_local = fetched_local(latest_source_time, timezone);
862
+ }
863
+ primary.payload = Value::Object(payloads);
864
+ primary
865
+ }
866
+
867
+ fn provider_payload_key(sources: &[ResearchSource]) -> String {
868
+ sources
869
+ .first()
870
+ .map(|source| source.provider.replace('-', "_"))
871
+ .unwrap_or_else(|| "unknown".to_string())
872
+ }
873
+
874
+ fn epoch_to_date(timestamp: i64) -> Option<String> {
875
+ chrono::DateTime::from_timestamp(timestamp, 0).map(|dt| dt.date_naive().to_string())
876
+ }
877
+
878
+ fn source(
879
+ provider: &str,
880
+ cache_status: &str,
881
+ fetched_at_utc: &str,
882
+ timezone: &str,
883
+ note: &str,
884
+ ) -> ResearchSource {
885
+ ResearchSource {
886
+ provider: provider.to_string(),
887
+ cache_status: cache_status.to_string(),
888
+ fetched_at_utc: fetched_at_utc.to_string(),
889
+ fetched_at_local: fetched_local(fetched_at_utc, timezone),
890
+ note: Some(note.to_string()),
891
+ }
892
+ }
893
+
894
+ fn module_status(name: &str, provider: &str, status: &str, note: Option<&str>) -> ResearchModule {
895
+ ResearchModule {
896
+ name: name.to_string(),
897
+ provider: provider.to_string(),
898
+ status: status.to_string(),
899
+ note: note.map(ToString::to_string),
900
+ }
901
+ }
902
+
903
+ fn gap(module: impl Into<String>, reason: impl Into<String>) -> ResearchCoverageGap {
904
+ ResearchCoverageGap {
905
+ module: module.into(),
906
+ reason: reason.into(),
907
+ }
908
+ }
909
+
910
+ fn sec_coverage_gaps(kind: QuoteSummaryKind) -> Vec<ResearchCoverageGap> {
911
+ match kind {
912
+ QuoteSummaryKind::Fundamentals => vec![gap(
913
+ "valuation/analyst metrics",
914
+ "SEC EDGAR is official filings data and does not provide market valuation, analyst targets, or forward estimates",
915
+ )],
916
+ QuoteSummaryKind::Events => vec![gap(
917
+ "earnings calendar",
918
+ "SEC EDGAR provides filings after dissemination; it does not predict earnings dates",
919
+ )],
920
+ QuoteSummaryKind::Analysis | QuoteSummaryKind::Ownership => Vec::new(),
921
+ }
922
+ }
923
+
924
+ fn robinhood_coverage_gaps(kind: QuoteSummaryKind) -> Vec<ResearchCoverageGap> {
925
+ match kind {
926
+ QuoteSummaryKind::Fundamentals => vec![gap(
927
+ "statements/analyst estimates",
928
+ "Robinhood fundamentals are profile and valuation snapshots; use Yahoo/SEC for statements and analyst estimates",
929
+ )],
930
+ QuoteSummaryKind::Events => vec![gap(
931
+ "earnings/dividend calendar",
932
+ "Robinhood public events coverage is splits and market hours; use Yahoo/SEC for earnings, dividends, and filings",
933
+ )],
934
+ QuoteSummaryKind::Analysis | QuoteSummaryKind::Ownership => Vec::new(),
935
+ }
936
+ }
937
+
938
+ fn fetched_local(fetched_at_utc: &str, timezone: &str) -> String {
939
+ utc_to_local(Some(fetched_at_utc), timezone).unwrap_or_else(|| now_local(timezone))
940
+ }
941
+
942
+ #[cfg(test)]
943
+ mod tests;