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,580 @@
1
+ use anyhow::{Context, Result, anyhow};
2
+ use serde::Deserialize;
3
+ use serde_json::{Value, json};
4
+ use url::Url;
5
+ use wreq::Client;
6
+
7
+ use crate::http::{change_pct, parse_optional_f64, utc_now};
8
+ use crate::model::{
9
+ HistoryBatch, OhlcBar, Quote, ResearchHighlight, SESSION_EXTENDED, SESSION_REGULAR,
10
+ research_value_string,
11
+ };
12
+
13
+ #[derive(Debug, Deserialize)]
14
+ struct RobinhoodQuote {
15
+ symbol: String,
16
+ last_trade_price: Option<String>,
17
+ last_extended_hours_trade_price: Option<String>,
18
+ last_non_reg_trade_price: Option<String>,
19
+ venue_last_trade_time: Option<String>,
20
+ venue_last_non_reg_trade_time: Option<String>,
21
+ previous_close: Option<String>,
22
+ updated_at: Option<String>,
23
+ }
24
+
25
+ #[derive(Debug, Deserialize)]
26
+ struct RobinhoodInstrumentPage {
27
+ results: Vec<Value>,
28
+ }
29
+
30
+ #[derive(Debug, Deserialize)]
31
+ struct RobinhoodHistorical {
32
+ symbol: String,
33
+ interval: String,
34
+ historicals: Vec<RobinhoodHistoricalBar>,
35
+ }
36
+
37
+ #[derive(Debug, Deserialize)]
38
+ struct RobinhoodHistoricalBar {
39
+ begins_at: Option<String>,
40
+ open_price: Option<String>,
41
+ close_price: Option<String>,
42
+ high_price: Option<String>,
43
+ low_price: Option<String>,
44
+ volume: Option<u64>,
45
+ interpolated: Option<bool>,
46
+ }
47
+
48
+ pub async fn fetch_quote(client: &Client, symbol: &str) -> Result<Quote> {
49
+ let provider_symbol = symbol.to_uppercase();
50
+ let url = format!("https://api.robinhood.com/quotes/{provider_symbol}/");
51
+ let quote: RobinhoodQuote = client
52
+ .get(url)
53
+ .send()
54
+ .await
55
+ .context("Robinhood quote request failed")?
56
+ .error_for_status()
57
+ .context("Robinhood quote returned HTTP error")?
58
+ .json()
59
+ .await
60
+ .context("Robinhood quote JSON parse failed")?;
61
+
62
+ let extended_price = parse_optional_f64(quote.last_extended_hours_trade_price.as_deref())
63
+ .or_else(|| parse_optional_f64(quote.last_non_reg_trade_price.as_deref()));
64
+ let regular_price = parse_optional_f64(quote.last_trade_price.as_deref());
65
+ let price = extended_price
66
+ .or(regular_price)
67
+ .ok_or_else(|| anyhow!("Robinhood quote missing usable price"))?;
68
+ let previous_close = parse_optional_f64(quote.previous_close.as_deref());
69
+ let session = if extended_price.is_some() {
70
+ SESSION_EXTENDED
71
+ } else {
72
+ SESSION_REGULAR
73
+ };
74
+ let market_time = if extended_price.is_some() {
75
+ quote
76
+ .venue_last_non_reg_trade_time
77
+ .or(quote.updated_at)
78
+ .or(quote.venue_last_trade_time)
79
+ } else {
80
+ quote
81
+ .venue_last_trade_time
82
+ .or(quote.updated_at)
83
+ .or(quote.venue_last_non_reg_trade_time)
84
+ };
85
+
86
+ Ok(Quote {
87
+ symbol: quote.symbol,
88
+ price,
89
+ currency: Some("USD".to_string()),
90
+ provider: "robinhood".to_string(),
91
+ session: Some(session.to_string()),
92
+ fetched_at_utc: utc_now(),
93
+ market_time,
94
+ previous_close,
95
+ open: None,
96
+ high: None,
97
+ low: None,
98
+ volume: None,
99
+ exchange: None,
100
+ provider_symbol: Some(provider_symbol),
101
+ change_pct: change_pct(price, previous_close),
102
+ })
103
+ }
104
+
105
+ pub async fn fetch_fundamentals_bundle(client: &Client, symbol: &str) -> Result<Value> {
106
+ let instrument = fetch_instrument(client, symbol).await?;
107
+ let fundamentals = fetch_json(
108
+ client,
109
+ &format!(
110
+ "https://api.robinhood.com/fundamentals/{}/",
111
+ symbol.trim().to_uppercase()
112
+ ),
113
+ "Robinhood fundamentals",
114
+ )
115
+ .await?;
116
+ let market = instrument
117
+ .get("market")
118
+ .and_then(Value::as_str)
119
+ .map(|url| fetch_json(client, url, "Robinhood market"));
120
+ let market = match market {
121
+ Some(future) => future.await.ok(),
122
+ None => None,
123
+ };
124
+
125
+ Ok(json!({
126
+ "symbol": symbol.trim().to_uppercase(),
127
+ "instrument": instrument,
128
+ "fundamentals": fundamentals,
129
+ "market": market,
130
+ }))
131
+ }
132
+
133
+ pub async fn fetch_events_bundle(client: &Client, symbol: &str) -> Result<Value> {
134
+ let instrument = fetch_instrument(client, symbol).await?;
135
+ let splits_url = instrument
136
+ .get("splits")
137
+ .and_then(Value::as_str)
138
+ .ok_or_else(|| anyhow!("Robinhood instrument missing splits URL"))?;
139
+ let splits = fetch_json(client, splits_url, "Robinhood splits").await?;
140
+ let market = instrument
141
+ .get("market")
142
+ .and_then(Value::as_str)
143
+ .map(|url| fetch_json(client, url, "Robinhood market"));
144
+ let market = match market {
145
+ Some(future) => future.await.ok(),
146
+ None => None,
147
+ };
148
+ let hours = market
149
+ .as_ref()
150
+ .and_then(|market| market.get("todays_hours"))
151
+ .and_then(Value::as_str)
152
+ .map(|url| fetch_json(client, url, "Robinhood market hours"));
153
+ let hours = match hours {
154
+ Some(future) => future.await.ok(),
155
+ None => None,
156
+ };
157
+
158
+ Ok(json!({
159
+ "symbol": symbol.trim().to_uppercase(),
160
+ "instrument": instrument,
161
+ "splits": splits,
162
+ "market": market,
163
+ "market_hours": hours,
164
+ }))
165
+ }
166
+
167
+ pub async fn fetch_options_bundle(
168
+ client: &Client,
169
+ symbol: &str,
170
+ expiration_date: Option<&str>,
171
+ count: usize,
172
+ ) -> Result<Value> {
173
+ let instrument = fetch_instrument(client, symbol).await?;
174
+ let chain_id = instrument
175
+ .get("tradable_chain_id")
176
+ .and_then(Value::as_str)
177
+ .ok_or_else(|| anyhow!("Robinhood instrument missing tradable_chain_id"))?;
178
+ let chain = fetch_json(
179
+ client,
180
+ &format!("https://api.robinhood.com/options/chains/{chain_id}/"),
181
+ "Robinhood options chain",
182
+ )
183
+ .await?;
184
+ let selected_expiration = expiration_date.map(ToString::to_string).or_else(|| {
185
+ chain
186
+ .get("expiration_dates")
187
+ .and_then(Value::as_array)
188
+ .and_then(|dates| dates.first())
189
+ .and_then(Value::as_str)
190
+ .map(ToString::to_string)
191
+ });
192
+ let instruments = match selected_expiration.as_deref() {
193
+ Some(expiration) => fetch_option_instruments(client, chain_id, expiration, count).await?,
194
+ None => Vec::new(),
195
+ };
196
+ let coverage_gaps = vec![
197
+ "Robinhood option quote marketdata endpoints are not reliably anonymous; this payload includes chain and contract metadata, not bid/ask/IV quotes.",
198
+ ];
199
+
200
+ Ok(json!({
201
+ "symbol": symbol.trim().to_uppercase(),
202
+ "instrument": instrument,
203
+ "chain": chain,
204
+ "selected_expiration": selected_expiration,
205
+ "option_instruments": instruments,
206
+ "coverage_gaps": coverage_gaps,
207
+ }))
208
+ }
209
+
210
+ pub async fn fetch_history(
211
+ client: &Client,
212
+ symbol: &str,
213
+ interval: &str,
214
+ range: &str,
215
+ extended: bool,
216
+ limit: usize,
217
+ ) -> Result<HistoryBatch> {
218
+ let provider_symbol = symbol.trim().to_uppercase();
219
+ let interval = robinhood_interval(interval)?;
220
+ let span = robinhood_span(range)?;
221
+ let bounds = if extended { "trading" } else { "regular" };
222
+ let mut url = Url::parse(&format!(
223
+ "https://api.robinhood.com/quotes/historicals/{provider_symbol}/"
224
+ ))
225
+ .context("invalid Robinhood historicals URL")?;
226
+ url.query_pairs_mut()
227
+ .append_pair("interval", interval)
228
+ .append_pair("span", span)
229
+ .append_pair("bounds", bounds);
230
+ let history: RobinhoodHistorical = client
231
+ .get(url.as_str())
232
+ .send()
233
+ .await
234
+ .context("Robinhood historicals request failed")?
235
+ .error_for_status()
236
+ .context("Robinhood historicals returned HTTP error")?
237
+ .json()
238
+ .await
239
+ .context("Robinhood historicals JSON parse failed")?;
240
+ let mut bars = history
241
+ .historicals
242
+ .into_iter()
243
+ .filter_map(|row| historical_bar_to_ohlc(&history.symbol, row))
244
+ .collect::<Vec<_>>();
245
+ if bars.len() > limit {
246
+ let start = bars.len().saturating_sub(limit);
247
+ bars = bars.split_off(start);
248
+ }
249
+
250
+ Ok(HistoryBatch {
251
+ symbol: history.symbol,
252
+ provider: "robinhood".to_string(),
253
+ interval: history.interval,
254
+ adjustment: "raw".to_string(),
255
+ actions_included: false,
256
+ repair_requested: false,
257
+ repair_applied: false,
258
+ bars,
259
+ })
260
+ }
261
+
262
+ pub fn fundamentals_highlights(payload: &Value) -> Vec<ResearchHighlight> {
263
+ let mut rows = Vec::new();
264
+ for (label, path, module) in [
265
+ ("Company", "/instrument/name", "instrument"),
266
+ ("List date", "/instrument/list_date", "instrument"),
267
+ ("Tradability", "/instrument/tradability", "instrument"),
268
+ (
269
+ "Fractional trading",
270
+ "/instrument/fractional_tradability",
271
+ "instrument",
272
+ ),
273
+ (
274
+ "Shorting status",
275
+ "/instrument/short_selling_tradability",
276
+ "instrument",
277
+ ),
278
+ (
279
+ "High-risk flag",
280
+ "/instrument/is_high_investment_risk",
281
+ "instrument",
282
+ ),
283
+ ("SPAC flag", "/instrument/is_spac", "instrument"),
284
+ ("Market cap", "/fundamentals/market_cap", "fundamentals"),
285
+ ("PE", "/fundamentals/pe_ratio", "fundamentals"),
286
+ ("PB", "/fundamentals/pb_ratio", "fundamentals"),
287
+ (
288
+ "Dividend yield",
289
+ "/fundamentals/dividend_yield",
290
+ "fundamentals",
291
+ ),
292
+ ("Float", "/fundamentals/float", "fundamentals"),
293
+ (
294
+ "Shares outstanding",
295
+ "/fundamentals/shares_outstanding",
296
+ "fundamentals",
297
+ ),
298
+ (
299
+ "52-week high",
300
+ "/fundamentals/high_52_weeks",
301
+ "fundamentals",
302
+ ),
303
+ ("52-week low", "/fundamentals/low_52_weeks", "fundamentals"),
304
+ (
305
+ "30-day avg volume",
306
+ "/fundamentals/average_volume_30_days",
307
+ "fundamentals",
308
+ ),
309
+ ("Market", "/market/name", "market"),
310
+ ("Timezone", "/market/timezone", "market"),
311
+ ] {
312
+ push_path(&mut rows, payload, label, path, module);
313
+ }
314
+ if let Some(description) = payload
315
+ .pointer("/fundamentals/description")
316
+ .and_then(Value::as_str)
317
+ .filter(|value| !value.trim().is_empty())
318
+ {
319
+ rows.push(ResearchHighlight::new(
320
+ "Company description",
321
+ truncate(description.trim(), 220),
322
+ "robinhood",
323
+ "fundamentals",
324
+ ));
325
+ }
326
+ rows
327
+ }
328
+
329
+ pub fn events_highlights(payload: &Value) -> Vec<ResearchHighlight> {
330
+ let mut rows = Vec::new();
331
+ push_path(
332
+ &mut rows,
333
+ payload,
334
+ "Company",
335
+ "/instrument/name",
336
+ "instrument",
337
+ );
338
+ push_path(
339
+ &mut rows,
340
+ payload,
341
+ "Status",
342
+ "/instrument/state",
343
+ "instrument",
344
+ );
345
+ push_path(
346
+ &mut rows,
347
+ payload,
348
+ "Market open today",
349
+ "/market_hours/is_open",
350
+ "market_hours",
351
+ );
352
+ push_path(
353
+ &mut rows,
354
+ payload,
355
+ "Regular open",
356
+ "/market_hours/opens_at",
357
+ "market_hours",
358
+ );
359
+ push_path(
360
+ &mut rows,
361
+ payload,
362
+ "Regular close",
363
+ "/market_hours/closes_at",
364
+ "market_hours",
365
+ );
366
+ push_path(
367
+ &mut rows,
368
+ payload,
369
+ "Extended open",
370
+ "/market_hours/extended_opens_at",
371
+ "market_hours",
372
+ );
373
+ push_path(
374
+ &mut rows,
375
+ payload,
376
+ "Extended close",
377
+ "/market_hours/extended_closes_at",
378
+ "market_hours",
379
+ );
380
+ if let Some(splits) = payload.pointer("/splits/results").and_then(Value::as_array) {
381
+ for (index, split) in splits.iter().take(5).enumerate() {
382
+ let date = string_at(split, "/execution_date").unwrap_or_else(|| "-".to_string());
383
+ let multiplier = string_at(split, "/multiplier").unwrap_or_else(|| "-".to_string());
384
+ let divisor = string_at(split, "/divisor").unwrap_or_else(|| "-".to_string());
385
+ rows.push(ResearchHighlight::new(
386
+ &format!("Split {}", index + 1),
387
+ format!("{date} multiplier={multiplier} divisor={divisor}"),
388
+ "robinhood",
389
+ "splits",
390
+ ));
391
+ }
392
+ }
393
+ rows
394
+ }
395
+
396
+ pub fn options_highlights(payload: &Value) -> Vec<ResearchHighlight> {
397
+ let mut rows = Vec::new();
398
+ push_path(&mut rows, payload, "Chain", "/chain/id", "options");
399
+ push_path(
400
+ &mut rows,
401
+ payload,
402
+ "Selected Expiration",
403
+ "/selected_expiration",
404
+ "options",
405
+ );
406
+ if let Some(expirations) = payload
407
+ .pointer("/chain/expiration_dates")
408
+ .and_then(Value::as_array)
409
+ {
410
+ rows.push(ResearchHighlight::new(
411
+ "Expiry count",
412
+ expirations.len().to_string(),
413
+ "robinhood",
414
+ "options",
415
+ ));
416
+ rows.push(ResearchHighlight::new(
417
+ "Nearest expiry",
418
+ expirations
419
+ .iter()
420
+ .take(8)
421
+ .filter_map(Value::as_str)
422
+ .collect::<Vec<_>>()
423
+ .join(", "),
424
+ "robinhood",
425
+ "options",
426
+ ));
427
+ }
428
+ if let Some(instruments) = payload
429
+ .pointer("/option_instruments")
430
+ .and_then(Value::as_array)
431
+ {
432
+ rows.push(ResearchHighlight::new(
433
+ "Contract count",
434
+ instruments.len().to_string(),
435
+ "robinhood",
436
+ "option_instruments",
437
+ ));
438
+ for contract in instruments.iter().take(6) {
439
+ let strike = string_at(contract, "/strike_price").unwrap_or_else(|| "-".to_string());
440
+ let contract_type = string_at(contract, "/type").unwrap_or_else(|| "-".to_string());
441
+ let tradability =
442
+ string_at(contract, "/tradability").unwrap_or_else(|| "-".to_string());
443
+ rows.push(ResearchHighlight::new(
444
+ "Contract sample",
445
+ format!("{contract_type} strike={strike} tradability={tradability}"),
446
+ "robinhood",
447
+ "option_instruments",
448
+ ));
449
+ }
450
+ }
451
+ rows
452
+ }
453
+
454
+ async fn fetch_instrument(client: &Client, symbol: &str) -> Result<Value> {
455
+ let provider_symbol = symbol.trim().to_uppercase();
456
+ let url = format!("https://api.robinhood.com/instruments/?symbol={provider_symbol}");
457
+ let page: RobinhoodInstrumentPage = client
458
+ .get(url)
459
+ .send()
460
+ .await
461
+ .context("Robinhood instruments request failed")?
462
+ .error_for_status()
463
+ .context("Robinhood instruments returned HTTP error")?
464
+ .json()
465
+ .await
466
+ .context("Robinhood instruments JSON parse failed")?;
467
+ page.results
468
+ .into_iter()
469
+ .next()
470
+ .ok_or_else(|| anyhow!("Robinhood instruments did not contain {provider_symbol}"))
471
+ }
472
+
473
+ async fn fetch_json(client: &Client, url: &str, label: &str) -> Result<Value> {
474
+ client
475
+ .get(url)
476
+ .send()
477
+ .await
478
+ .with_context(|| format!("{label} request failed"))?
479
+ .error_for_status()
480
+ .with_context(|| format!("{label} returned HTTP error"))?
481
+ .json()
482
+ .await
483
+ .with_context(|| format!("{label} JSON parse failed"))
484
+ }
485
+
486
+ async fn fetch_option_instruments(
487
+ client: &Client,
488
+ chain_id: &str,
489
+ expiration: &str,
490
+ count: usize,
491
+ ) -> Result<Vec<Value>> {
492
+ let mut url = Url::parse("https://api.robinhood.com/options/instruments/")
493
+ .context("invalid Robinhood option instruments URL")?;
494
+ url.query_pairs_mut()
495
+ .append_pair("chain_id", chain_id)
496
+ .append_pair("expiration_dates", expiration)
497
+ .append_pair("state", "active");
498
+ let page = fetch_json(client, url.as_str(), "Robinhood option instruments").await?;
499
+ Ok(page
500
+ .get("results")
501
+ .and_then(Value::as_array)
502
+ .cloned()
503
+ .unwrap_or_default()
504
+ .into_iter()
505
+ .take(count.max(1))
506
+ .collect())
507
+ }
508
+
509
+ fn robinhood_interval(interval: &str) -> Result<&'static str> {
510
+ match interval {
511
+ "5m" | "5minute" => Ok("5minute"),
512
+ "10m" | "10minute" => Ok("10minute"),
513
+ "1h" | "hour" => Ok("hour"),
514
+ "1d" | "day" => Ok("day"),
515
+ "1w" | "week" => Ok("week"),
516
+ _ => Err(anyhow!(
517
+ "Robinhood history supports 5m, 10m, 1h, 1d, and 1w intervals"
518
+ )),
519
+ }
520
+ }
521
+
522
+ fn robinhood_span(range: &str) -> Result<&'static str> {
523
+ match range {
524
+ "1d" | "day" => Ok("day"),
525
+ "1w" | "5d" | "week" => Ok("week"),
526
+ "1mo" | "month" => Ok("month"),
527
+ "3mo" | "3month" => Ok("3month"),
528
+ "1y" | "year" => Ok("year"),
529
+ _ => Err(anyhow!(
530
+ "Robinhood history supports 1d, 5d/1w, 1mo, 3mo, and 1y ranges"
531
+ )),
532
+ }
533
+ }
534
+
535
+ fn historical_bar_to_ohlc(symbol: &str, row: RobinhoodHistoricalBar) -> Option<OhlcBar> {
536
+ let close = parse_optional_f64(row.close_price.as_deref())?;
537
+ Some(OhlcBar {
538
+ symbol: symbol.to_uppercase(),
539
+ provider: "robinhood".to_string(),
540
+ open_time: row.begins_at?,
541
+ close_time: None,
542
+ open: parse_optional_f64(row.open_price.as_deref()),
543
+ high: parse_optional_f64(row.high_price.as_deref()),
544
+ low: parse_optional_f64(row.low_price.as_deref()),
545
+ close,
546
+ adj_close: None,
547
+ volume: row.volume.map(|value| value as f64),
548
+ quote_volume: None,
549
+ trades: None,
550
+ dividend: None,
551
+ stock_split: None,
552
+ capital_gain: None,
553
+ repaired: row.interpolated.unwrap_or(false),
554
+ })
555
+ }
556
+
557
+ fn push_path(
558
+ rows: &mut Vec<ResearchHighlight>,
559
+ payload: &Value,
560
+ label: &str,
561
+ path: &str,
562
+ module: &str,
563
+ ) {
564
+ if let Some(row) = ResearchHighlight::from_path(Some(payload), label, path, "robinhood", module)
565
+ .filter(|row| !row.value.trim().is_empty())
566
+ {
567
+ rows.push(row);
568
+ }
569
+ }
570
+
571
+ fn string_at(payload: &Value, path: &str) -> Option<String> {
572
+ research_value_string(payload.pointer(path)).filter(|value| !value.trim().is_empty())
573
+ }
574
+
575
+ fn truncate(value: &str, max_chars: usize) -> String {
576
+ if value.chars().count() <= max_chars {
577
+ return value.to_string();
578
+ }
579
+ value.chars().take(max_chars).collect::<String>() + "..."
580
+ }