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,399 @@
1
+ use anyhow::{Context, Result, anyhow};
2
+ use serde_json::{Value, json};
3
+ use wreq::{
4
+ Client,
5
+ header::{ACCEPT, USER_AGENT},
6
+ };
7
+
8
+ use crate::model::{ResearchHighlight, research_value_string};
9
+
10
+ const SEC_TICKERS_URL: &str = "https://www.sec.gov/files/company_tickers.json";
11
+ const SEC_SUBMISSIONS_BASE_URL: &str = "https://data.sec.gov/submissions";
12
+ const SEC_COMPANYFACTS_BASE_URL: &str = "https://data.sec.gov/api/xbrl/companyfacts";
13
+ const SEC_USER_AGENT_ENV: &str = "AGENT_FINANCE_SEC_USER_AGENT";
14
+ const DEFAULT_SEC_USER_AGENT: &str =
15
+ "agent-finance-cli/0.1 (+https://github.com/M4n5ter/agent-finance-cli)";
16
+
17
+ #[derive(Debug, Clone, PartialEq, Eq)]
18
+ struct SecCompany {
19
+ cik: u64,
20
+ ticker: String,
21
+ title: String,
22
+ }
23
+
24
+ pub async fn fetch_company_bundle(
25
+ client: &Client,
26
+ symbol: &str,
27
+ include_companyfacts: bool,
28
+ ) -> Result<Value> {
29
+ let normalized = symbol.trim().to_uppercase();
30
+ let tickers = fetch_json(client, SEC_TICKERS_URL, "SEC company tickers").await?;
31
+ let company = find_company(&tickers, &normalized)?;
32
+ let cik = format!("{:010}", company.cik);
33
+ let submissions_url = format!("{SEC_SUBMISSIONS_BASE_URL}/CIK{cik}.json");
34
+ let companyfacts_url =
35
+ include_companyfacts.then(|| format!("{SEC_COMPANYFACTS_BASE_URL}/CIK{cik}.json"));
36
+ let (submissions, companyfacts) = match companyfacts_url.as_deref() {
37
+ Some(companyfacts_url) => {
38
+ let (submissions, companyfacts) = tokio::try_join!(
39
+ fetch_json(client, &submissions_url, "SEC submissions"),
40
+ fetch_json(client, companyfacts_url, "SEC companyfacts")
41
+ )?;
42
+ (submissions, Some(companyfacts))
43
+ }
44
+ None => (
45
+ fetch_json(client, &submissions_url, "SEC submissions").await?,
46
+ None,
47
+ ),
48
+ };
49
+ let mut payload = json!({
50
+ "symbol": normalized,
51
+ "cik": cik,
52
+ "company": {
53
+ "ticker": company.ticker,
54
+ "title": company.title,
55
+ },
56
+ "submissions": submissions,
57
+ });
58
+
59
+ if let Some(companyfacts) = companyfacts {
60
+ payload["companyfacts"] = companyfacts;
61
+ }
62
+
63
+ Ok(payload)
64
+ }
65
+
66
+ async fn fetch_json(client: &Client, url: &str, label: &str) -> Result<Value> {
67
+ let response = client
68
+ .get(url)
69
+ .header(ACCEPT, "application/json")
70
+ .header(USER_AGENT, sec_user_agent())
71
+ .send()
72
+ .await
73
+ .with_context(|| format!("{label} request failed"))?;
74
+ let status = response.status();
75
+ if status.is_success() {
76
+ return response
77
+ .json::<Value>()
78
+ .await
79
+ .with_context(|| format!("{label} JSON parse failed"));
80
+ }
81
+ let body = response
82
+ .text()
83
+ .await
84
+ .with_context(|| format!("{label} response text parse failed"))?;
85
+ Err(anyhow!("{label} returned HTTP {status}: {body}"))
86
+ }
87
+
88
+ fn sec_user_agent() -> String {
89
+ std::env::var(SEC_USER_AGENT_ENV).unwrap_or_else(|_| DEFAULT_SEC_USER_AGENT.to_string())
90
+ }
91
+
92
+ fn find_company(tickers: &Value, symbol: &str) -> Result<SecCompany> {
93
+ let companies = tickers
94
+ .as_object()
95
+ .ok_or_else(|| anyhow!("SEC company tickers payload is not an object"))?;
96
+ companies
97
+ .values()
98
+ .filter_map(parse_company)
99
+ .find(|company| company.ticker.eq_ignore_ascii_case(symbol))
100
+ .ok_or_else(|| anyhow!("SEC company tickers did not contain {symbol}"))
101
+ }
102
+
103
+ fn parse_company(value: &Value) -> Option<SecCompany> {
104
+ let cik = value
105
+ .get("cik_str")
106
+ .and_then(|value| value.as_u64().or_else(|| value.as_str()?.parse().ok()))?;
107
+ let ticker = value.get("ticker")?.as_str()?.to_uppercase();
108
+ let title = value.get("title")?.as_str()?.to_string();
109
+ Some(SecCompany { cik, ticker, title })
110
+ }
111
+
112
+ pub fn fundamentals_highlights(payload: &Value) -> Vec<ResearchHighlight> {
113
+ let mut rows = Vec::new();
114
+ push_path(
115
+ &mut rows,
116
+ payload,
117
+ "Company",
118
+ "/company/title",
119
+ "submissions",
120
+ );
121
+ push_path(
122
+ &mut rows,
123
+ payload,
124
+ "SEC Entity",
125
+ "/companyfacts/entityName",
126
+ "companyfacts",
127
+ );
128
+ push_path(&mut rows, payload, "CIK", "/cik", "submissions");
129
+ push_path(
130
+ &mut rows,
131
+ payload,
132
+ "SIC",
133
+ "/submissions/sicDescription",
134
+ "submissions",
135
+ );
136
+ push_path(
137
+ &mut rows,
138
+ payload,
139
+ "Fiscal year end",
140
+ "/submissions/fiscalYearEnd",
141
+ "submissions",
142
+ );
143
+
144
+ for (label, tags) in [
145
+ (
146
+ "Latest revenue",
147
+ &[
148
+ "RevenueFromContractWithCustomerExcludingAssessedTax",
149
+ "Revenues",
150
+ "SalesRevenueNet",
151
+ ][..],
152
+ ),
153
+ ("Latest net income", &["NetIncomeLoss"][..]),
154
+ ("Latest assets", &["Assets"][..]),
155
+ ("Latest liabilities", &["Liabilities"][..]),
156
+ (
157
+ "Latest operating cash flow",
158
+ &["NetCashProvidedByUsedInOperatingActivities"][..],
159
+ ),
160
+ (
161
+ "Latest cash and equivalents",
162
+ &["CashAndCashEquivalentsAtCarryingValue"][..],
163
+ ),
164
+ ("Latest R&D expense", &["ResearchAndDevelopmentExpense"][..]),
165
+ ] {
166
+ if let Some(point) = latest_usd_fact(payload, tags) {
167
+ rows.push(ResearchHighlight::new(
168
+ label,
169
+ point.display_value(),
170
+ "sec-edgar",
171
+ "companyfacts",
172
+ ));
173
+ }
174
+ }
175
+ rows
176
+ }
177
+
178
+ pub fn events_highlights(payload: &Value) -> Vec<ResearchHighlight> {
179
+ let mut rows = Vec::new();
180
+ push_path(
181
+ &mut rows,
182
+ payload,
183
+ "Company",
184
+ "/company/title",
185
+ "submissions",
186
+ );
187
+ push_path(&mut rows, payload, "CIK", "/cik", "submissions");
188
+
189
+ let recent = payload.pointer("/submissions/filings/recent");
190
+ let forms = recent
191
+ .and_then(|value| value.pointer("/form"))
192
+ .and_then(Value::as_array);
193
+ let Some(forms) = forms else {
194
+ return rows;
195
+ };
196
+ for (index, form) in forms.iter().take(8).enumerate() {
197
+ let form = research_value_string(Some(form)).unwrap_or_else(|| "-".to_string());
198
+ let filed =
199
+ recent_array_string(recent, "filingDate", index).unwrap_or_else(|| "-".to_string());
200
+ let accession = recent_array_string(recent, "accessionNumber", index)
201
+ .unwrap_or_else(|| "-".to_string());
202
+ let document = recent_array_string(recent, "primaryDocument", index)
203
+ .unwrap_or_else(|| "-".to_string());
204
+ rows.push(ResearchHighlight::new(
205
+ &format!("SEC filing {}", index + 1),
206
+ format!("{form} filed={filed} accession={accession} doc={document}"),
207
+ "sec-edgar",
208
+ "submissions",
209
+ ));
210
+ }
211
+ rows
212
+ }
213
+
214
+ #[derive(Debug, Clone, PartialEq)]
215
+ struct SecFactPoint {
216
+ tag: String,
217
+ value: f64,
218
+ form: Option<String>,
219
+ end: Option<String>,
220
+ filed: Option<String>,
221
+ }
222
+
223
+ impl SecFactPoint {
224
+ fn display_value(&self) -> String {
225
+ let form = self.form.as_deref().unwrap_or("-");
226
+ let end = self.end.as_deref().unwrap_or("-");
227
+ let filed = self.filed.as_deref().unwrap_or("-");
228
+ format!(
229
+ "{} | form={form} end={end} filed={filed} tag={}",
230
+ compact_usd(self.value),
231
+ self.tag
232
+ )
233
+ }
234
+ }
235
+
236
+ fn latest_usd_fact(payload: &Value, tags: &[&str]) -> Option<SecFactPoint> {
237
+ let us_gaap = payload.pointer("/companyfacts/facts/us-gaap")?;
238
+ tags.iter()
239
+ .filter_map(|tag| us_gaap.get(*tag).map(|node| (*tag, node)))
240
+ .flat_map(|(tag, node)| {
241
+ node.pointer("/units/USD")
242
+ .and_then(Value::as_array)
243
+ .into_iter()
244
+ .flatten()
245
+ .filter_map(move |entry| sec_fact_point(tag, entry))
246
+ })
247
+ .max_by(|left, right| {
248
+ (
249
+ left.filed.as_deref().unwrap_or(""),
250
+ left.end.as_deref().unwrap_or(""),
251
+ )
252
+ .cmp(&(
253
+ right.filed.as_deref().unwrap_or(""),
254
+ right.end.as_deref().unwrap_or(""),
255
+ ))
256
+ })
257
+ }
258
+
259
+ fn sec_fact_point(tag: &str, entry: &Value) -> Option<SecFactPoint> {
260
+ Some(SecFactPoint {
261
+ tag: tag.to_string(),
262
+ value: entry.get("val")?.as_f64()?,
263
+ form: research_value_string(entry.get("form")),
264
+ end: research_value_string(entry.get("end")),
265
+ filed: research_value_string(entry.get("filed")),
266
+ })
267
+ }
268
+
269
+ fn compact_usd(value: f64) -> String {
270
+ let sign = if value < 0.0 { "-" } else { "" };
271
+ let value = value.abs();
272
+ let (scaled, suffix) = if value >= 1_000_000_000_000.0 {
273
+ (value / 1_000_000_000_000.0, "T")
274
+ } else if value >= 1_000_000_000.0 {
275
+ (value / 1_000_000_000.0, "B")
276
+ } else if value >= 1_000_000.0 {
277
+ (value / 1_000_000.0, "M")
278
+ } else if value >= 1_000.0 {
279
+ (value / 1_000.0, "K")
280
+ } else {
281
+ (value, "")
282
+ };
283
+ format!("{sign}${scaled:.2}{suffix}")
284
+ }
285
+
286
+ fn push_path(
287
+ rows: &mut Vec<ResearchHighlight>,
288
+ root: &Value,
289
+ label: &str,
290
+ path: &str,
291
+ module: &str,
292
+ ) {
293
+ if let Some(row) = ResearchHighlight::from_path(Some(root), label, path, "sec-edgar", module) {
294
+ rows.push(row);
295
+ }
296
+ }
297
+
298
+ fn recent_array_string(root: Option<&Value>, field: &str, index: usize) -> Option<String> {
299
+ root.and_then(|value| value.get(field))
300
+ .and_then(Value::as_array)
301
+ .and_then(|values| values.get(index))
302
+ .and_then(|value| research_value_string(Some(value)))
303
+ }
304
+
305
+ #[cfg(test)]
306
+ mod tests {
307
+ use super::*;
308
+ use serde_json::json;
309
+
310
+ #[test]
311
+ fn finds_ticker_case_insensitively_and_formats_cik_later() {
312
+ let tickers = json!({
313
+ "0": {"cik_str": 320193, "ticker": "AAPL", "title": "Apple Inc."},
314
+ "1": {"cik_str": "1730168", "ticker": "CRDO", "title": "Credo Technology Group Holding Ltd"}
315
+ });
316
+
317
+ let company = find_company(&tickers, "crdo").expect("CRDO should be found");
318
+
319
+ assert_eq!(
320
+ company,
321
+ SecCompany {
322
+ cik: 1_730_168,
323
+ ticker: "CRDO".to_string(),
324
+ title: "Credo Technology Group Holding Ltd".to_string(),
325
+ }
326
+ );
327
+ }
328
+
329
+ #[test]
330
+ fn latest_usd_fact_prefers_latest_filing_across_accepted_tags() {
331
+ let payload = json!({
332
+ "companyfacts": {
333
+ "facts": {
334
+ "us-gaap": {
335
+ "RevenueFromContractWithCustomerExcludingAssessedTax": {
336
+ "units": {
337
+ "USD": [
338
+ {"val": 100.0, "form": "10-K", "end": "2025-12-31", "filed": "2026-02-01"}
339
+ ]
340
+ }
341
+ },
342
+ "Revenues": {
343
+ "units": {
344
+ "USD": [
345
+ {"val": 130.0, "form": "10-Q", "end": "2026-03-31", "filed": "2026-05-05"}
346
+ ],
347
+ "EUR": [
348
+ {"val": 999.0, "form": "10-Q", "end": "2026-03-31", "filed": "2026-05-06"}
349
+ ]
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+ });
356
+
357
+ let point = latest_usd_fact(
358
+ &payload,
359
+ &[
360
+ "RevenueFromContractWithCustomerExcludingAssessedTax",
361
+ "Revenues",
362
+ ],
363
+ )
364
+ .expect("latest USD revenue fact");
365
+
366
+ assert_eq!(point.tag, "Revenues");
367
+ assert_eq!(point.value, 130.0);
368
+ assert_eq!(point.form.as_deref(), Some("10-Q"));
369
+ }
370
+
371
+ #[test]
372
+ fn events_highlights_keep_filing_fields_aligned_by_index() {
373
+ let payload = json!({
374
+ "cik": "0001730168",
375
+ "company": {"title": "Credo Technology Group Holding Ltd"},
376
+ "submissions": {
377
+ "filings": {
378
+ "recent": {
379
+ "form": ["8-K", "10-Q"],
380
+ "filingDate": ["2026-06-01", "2026-05-20"],
381
+ "accessionNumber": ["0001", "0002"],
382
+ "primaryDocument": ["current.htm", "quarterly.htm"]
383
+ }
384
+ }
385
+ }
386
+ });
387
+
388
+ let highlights = events_highlights(&payload);
389
+
390
+ assert!(highlights.iter().any(|row| {
391
+ row.label == "SEC filing 2"
392
+ && row.value.contains("10-Q")
393
+ && row.value.contains("filed=2026-05-20")
394
+ && row.value.contains("accession=0002")
395
+ && row.provider == "sec-edgar"
396
+ && row.module == "submissions"
397
+ }));
398
+ }
399
+ }
@@ -0,0 +1,159 @@
1
+ use std::path::PathBuf;
2
+
3
+ use anyhow::Result;
4
+
5
+ use crate::cache;
6
+ use crate::cli::{StooqAsset, StooqFrequency, StooqMarket};
7
+ use crate::http::utc_now;
8
+ use crate::model::{StooqCatalog, StooqCatalogEntry};
9
+
10
+ pub fn catalog() -> StooqCatalog {
11
+ StooqCatalog {
12
+ fetched_at_utc: utc_now(),
13
+ source_url: "https://stooq.com/db/h/".to_string(),
14
+ entries: catalog_entries()
15
+ .into_iter()
16
+ .map(|mut entry| {
17
+ entry.cached_zip_path = cached_zip_path(&entry.cache_key)
18
+ .ok()
19
+ .filter(|path| path.exists())
20
+ .map(|path| path.display().to_string());
21
+ entry
22
+ })
23
+ .collect(),
24
+ }
25
+ }
26
+
27
+ pub(super) fn catalog_entries() -> Vec<StooqCatalogEntry> {
28
+ PACKAGES
29
+ .iter()
30
+ .map(|package| StooqCatalogEntry {
31
+ frequency: package.frequency.label().to_string(),
32
+ market: package.market.label().to_string(),
33
+ asset: package.asset.label().to_string(),
34
+ label: package.label.to_string(),
35
+ approx_size_mb: package.approx_size_mb,
36
+ listing_url: "https://stooq.com/db/h/".to_string(),
37
+ direct_download_requires_captcha: true,
38
+ cache_key: package.cache_key(),
39
+ cached_zip_path: None,
40
+ })
41
+ .collect()
42
+ }
43
+
44
+ #[derive(Clone, Copy)]
45
+ pub(super) struct StooqPackage {
46
+ pub(super) frequency: StooqFrequency,
47
+ pub(super) market: StooqMarket,
48
+ pub(super) asset: StooqAsset,
49
+ pub(super) label: &'static str,
50
+ pub(super) approx_size_mb: Option<f64>,
51
+ }
52
+
53
+ impl StooqPackage {
54
+ pub(super) fn cache_key(self) -> String {
55
+ stooq_cache_key(self.frequency, self.market, self.asset)
56
+ }
57
+ }
58
+
59
+ const PACKAGES: &[StooqPackage] = &[
60
+ StooqPackage {
61
+ frequency: StooqFrequency::Daily,
62
+ market: StooqMarket::Us,
63
+ asset: StooqAsset::Stocks,
64
+ label: "U.S. stocks daily",
65
+ approx_size_mb: Some(509.0),
66
+ },
67
+ StooqPackage {
68
+ frequency: StooqFrequency::Daily,
69
+ market: StooqMarket::Us,
70
+ asset: StooqAsset::Etfs,
71
+ label: "U.S. ETFs daily",
72
+ approx_size_mb: Some(509.0),
73
+ },
74
+ StooqPackage {
75
+ frequency: StooqFrequency::Hourly,
76
+ market: StooqMarket::Us,
77
+ asset: StooqAsset::Stocks,
78
+ label: "U.S. stocks hourly",
79
+ approx_size_mb: Some(426.0),
80
+ },
81
+ StooqPackage {
82
+ frequency: StooqFrequency::Hourly,
83
+ market: StooqMarket::Us,
84
+ asset: StooqAsset::Etfs,
85
+ label: "U.S. ETFs hourly",
86
+ approx_size_mb: Some(426.0),
87
+ },
88
+ StooqPackage {
89
+ frequency: StooqFrequency::FiveMin,
90
+ market: StooqMarket::Us,
91
+ asset: StooqAsset::Stocks,
92
+ label: "U.S. stocks 5 minute",
93
+ approx_size_mb: Some(597.0),
94
+ },
95
+ StooqPackage {
96
+ frequency: StooqFrequency::FiveMin,
97
+ market: StooqMarket::Us,
98
+ asset: StooqAsset::Etfs,
99
+ label: "U.S. ETFs 5 minute",
100
+ approx_size_mb: Some(597.0),
101
+ },
102
+ StooqPackage {
103
+ frequency: StooqFrequency::Daily,
104
+ market: StooqMarket::World,
105
+ asset: StooqAsset::Currencies,
106
+ label: "World currencies daily",
107
+ approx_size_mb: Some(182.0),
108
+ },
109
+ StooqPackage {
110
+ frequency: StooqFrequency::Daily,
111
+ market: StooqMarket::World,
112
+ asset: StooqAsset::Crypto,
113
+ label: "World crypto daily",
114
+ approx_size_mb: Some(182.0),
115
+ },
116
+ StooqPackage {
117
+ frequency: StooqFrequency::Hourly,
118
+ market: StooqMarket::World,
119
+ asset: StooqAsset::Currencies,
120
+ label: "World currencies hourly",
121
+ approx_size_mb: Some(249.0),
122
+ },
123
+ StooqPackage {
124
+ frequency: StooqFrequency::FiveMin,
125
+ market: StooqMarket::World,
126
+ asset: StooqAsset::Currencies,
127
+ label: "World currencies 5 minute",
128
+ approx_size_mb: Some(467.0),
129
+ },
130
+ StooqPackage {
131
+ frequency: StooqFrequency::Daily,
132
+ market: StooqMarket::Macro,
133
+ asset: StooqAsset::Macro,
134
+ label: "Macro daily",
135
+ approx_size_mb: Some(0.9),
136
+ },
137
+ ];
138
+
139
+ pub(super) fn catalog_package(
140
+ frequency: StooqFrequency,
141
+ market: StooqMarket,
142
+ asset: StooqAsset,
143
+ ) -> Option<StooqPackage> {
144
+ PACKAGES.iter().copied().find(|package| {
145
+ package.frequency == frequency && package.market == market && package.asset == asset
146
+ })
147
+ }
148
+
149
+ fn stooq_cache_key(frequency: StooqFrequency, market: StooqMarket, asset: StooqAsset) -> String {
150
+ format!("{}_{}_{}", frequency.label(), market.label(), asset.label())
151
+ }
152
+
153
+ pub(super) fn cached_zip_path(cache_key: &str) -> Result<PathBuf> {
154
+ Ok(cache_root()?.join(format!("{cache_key}.zip")))
155
+ }
156
+
157
+ fn cache_root() -> Result<PathBuf> {
158
+ Ok(cache::agent_finance_cache_root()?.join("stooq-bulk"))
159
+ }