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.
- package/Cargo.lock +2632 -0
- package/Cargo.toml +31 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +119 -0
- package/bin/agent-finance.js +27 -0
- package/npm/check-binary-links.js +50 -0
- package/npm/check-package.js +39 -0
- package/npm/create-platform-package.js +90 -0
- package/npm/platform.js +33 -0
- package/npm/postinstall.js +62 -0
- package/npm/resolve-binary.js +38 -0
- package/package.json +54 -0
- package/skills/core-full.md +74 -0
- package/skills/core.md +59 -0
- package/skills/futures.md +18 -0
- package/skills/history-indicators.md +42 -0
- package/skills/price.md +40 -0
- package/skills/providers.md +25 -0
- package/skills/research-data.md +34 -0
- package/src/app.rs +642 -0
- package/src/cache.rs +67 -0
- package/src/cli.rs +651 -0
- package/src/history.rs +150 -0
- package/src/http.rs +76 -0
- package/src/indicators.rs +82 -0
- package/src/lib.rs +15 -0
- package/src/main.rs +4 -0
- package/src/model.rs +347 -0
- package/src/output.rs +544 -0
- package/src/page_read.rs +443 -0
- package/src/price.rs +255 -0
- package/src/providers/binance_futures.rs +342 -0
- package/src/providers/capabilities.rs +322 -0
- package/src/providers/cnbc.rs +302 -0
- package/src/providers/mod.rs +117 -0
- package/src/providers/robinhood.rs +580 -0
- package/src/providers/sec_edgar.rs +399 -0
- package/src/providers/stooq/catalog.rs +159 -0
- package/src/providers/stooq.rs +904 -0
- package/src/providers/yahoo.rs +836 -0
- package/src/research/fetchers.rs +111 -0
- package/src/research/highlights.rs +345 -0
- package/src/research/mod.rs +943 -0
- package/src/research/tests.rs +42 -0
- package/src/skills.rs +58 -0
- package/src/stream.rs +356 -0
- 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;
|