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,42 @@
1
+ use super::*;
2
+ use serde_json::json;
3
+
4
+ #[test]
5
+ fn fundamentals_highlights_extract_raw_and_fmt_values() {
6
+ let payload = json!({
7
+ "quoteSummary": {
8
+ "result": [{
9
+ "price": {
10
+ "longName": "Credo Technology Group Holding Ltd",
11
+ "marketCap": {"raw": 41704132608_i64, "fmt": "41.7B"}
12
+ },
13
+ "summaryProfile": {"industry": "Semiconductors"},
14
+ "financialData": {
15
+ "revenueGrowth": {"raw": 2.015, "fmt": "201.5%"},
16
+ "freeCashflow": {"raw": 172241120_i64}
17
+ }
18
+ }]
19
+ }
20
+ });
21
+ let highlights = fundamentals_highlights(quote_summary_root(&payload));
22
+ assert!(
23
+ highlights
24
+ .iter()
25
+ .any(|row| row.label == "Company" && row.value == "Credo Technology Group Holding Ltd")
26
+ );
27
+ assert!(
28
+ highlights
29
+ .iter()
30
+ .any(|row| row.label == "Market cap" && row.value == "41.7B")
31
+ );
32
+ assert!(
33
+ highlights
34
+ .iter()
35
+ .any(|row| row.label == "Revenue growth" && row.value == "201.5%")
36
+ );
37
+ assert!(
38
+ highlights
39
+ .iter()
40
+ .any(|row| row.label == "Free cash flow" && row.value == "172241120")
41
+ );
42
+ }
package/src/skills.rs ADDED
@@ -0,0 +1,58 @@
1
+ pub struct BuiltinSkill {
2
+ pub name: &'static str,
3
+ pub description: &'static str,
4
+ }
5
+
6
+ const SKILLS: &[BuiltinSkill] = &[
7
+ BuiltinSkill {
8
+ name: "core",
9
+ description: "Entry guide for price, sessions, history, research, providers, proxy data, and safe source handling.",
10
+ },
11
+ BuiltinSkill {
12
+ name: "price",
13
+ description: "Fetch current price summaries, regular-market basis, pre/post/overnight sessions, streams, and proxy prices.",
14
+ },
15
+ BuiltinSkill {
16
+ name: "research-data",
17
+ description: "Fetch no-key Yahoo/SEC EDGAR/Robinhood/CNBC research data and read URL text with fallback readers.",
18
+ },
19
+ BuiltinSkill {
20
+ name: "providers",
21
+ description: "Understand Yahoo, SEC EDGAR, CNBC, Robinhood, Stooq, Binance futures, and proxy quote capabilities.",
22
+ },
23
+ BuiltinSkill {
24
+ name: "history-indicators",
25
+ description: "Fetch OHLCV, understand intervals, adjustments, repair, actions, and local technical indicators.",
26
+ },
27
+ BuiltinSkill {
28
+ name: "futures",
29
+ description: "Use Binance USD-M futures / TradFi perps for proxy price, funding, open interest, and mark price.",
30
+ },
31
+ ];
32
+
33
+ pub fn print_list() {
34
+ for skill in SKILLS {
35
+ println!("{:<20} {}", skill.name, skill.description);
36
+ }
37
+ }
38
+
39
+ pub fn get(name: &str, full: bool) -> Option<&'static str> {
40
+ match (name, full) {
41
+ ("core", false) => Some(CORE),
42
+ ("core", true) => Some(CORE_FULL),
43
+ ("price", _) => Some(PRICE),
44
+ ("research-data", _) => Some(RESEARCH_DATA),
45
+ ("providers", _) => Some(PROVIDERS),
46
+ ("history-indicators", _) => Some(HISTORY_INDICATORS),
47
+ ("futures", _) => Some(FUTURES),
48
+ _ => None,
49
+ }
50
+ }
51
+
52
+ const CORE: &str = include_str!("../skills/core.md");
53
+ const CORE_FULL: &str = include_str!("../skills/core-full.md");
54
+ const PRICE: &str = include_str!("../skills/price.md");
55
+ const RESEARCH_DATA: &str = include_str!("../skills/research-data.md");
56
+ const PROVIDERS: &str = include_str!("../skills/providers.md");
57
+ const HISTORY_INDICATORS: &str = include_str!("../skills/history-indicators.md");
58
+ const FUTURES: &str = include_str!("../skills/futures.md");
package/src/stream.rs ADDED
@@ -0,0 +1,356 @@
1
+ use std::time::Duration;
2
+
3
+ use anyhow::{Context, Result, anyhow};
4
+ use base64::{Engine as _, engine::general_purpose};
5
+ use futures_util::{SinkExt, StreamExt};
6
+ use prost::Message as _;
7
+ use serde::Serialize;
8
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
9
+ use tokio::net::TcpStream;
10
+ use tokio_tungstenite::client_async_tls_with_config;
11
+ use tokio_tungstenite::connect_async;
12
+ use tokio_tungstenite::tungstenite::Message;
13
+ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
14
+ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
15
+ use url::Url;
16
+
17
+ use crate::http::selected_proxy;
18
+ use crate::http::timestamp_ms_to_utc;
19
+ use crate::model::StreamQuote;
20
+ use crate::time::utc_to_local;
21
+
22
+ #[derive(Serialize)]
23
+ struct SubscribeMessage {
24
+ subscribe: Vec<String>,
25
+ }
26
+
27
+ pub async fn stream_quotes(options: StreamOptions<'_>) -> Result<Vec<StreamQuote>> {
28
+ let mut updates = Vec::new();
29
+ stream_quotes_each(options, |quote| {
30
+ updates.push(quote);
31
+ Ok(())
32
+ })
33
+ .await?;
34
+ Ok(updates)
35
+ }
36
+
37
+ #[derive(Debug)]
38
+ pub struct StreamOptions<'a> {
39
+ pub url: &'a str,
40
+ pub symbols: Vec<String>,
41
+ pub message_limit: usize,
42
+ pub read_timeout: Duration,
43
+ pub timezone: &'a str,
44
+ pub proxy: Option<&'a str>,
45
+ pub no_proxy: bool,
46
+ }
47
+
48
+ pub async fn stream_quotes_each<F>(options: StreamOptions<'_>, mut on_quote: F) -> Result<()>
49
+ where
50
+ F: FnMut(StreamQuote) -> Result<()>,
51
+ {
52
+ let _ = rustls::crypto::ring::default_provider().install_default();
53
+ let symbols = options
54
+ .symbols
55
+ .into_iter()
56
+ .map(|symbol| symbol.trim().to_uppercase())
57
+ .filter(|symbol| !symbol.is_empty())
58
+ .collect::<Vec<_>>();
59
+ if symbols.is_empty() {
60
+ return Err(anyhow!("at least one symbol is required"));
61
+ }
62
+
63
+ let mut request = options
64
+ .url
65
+ .into_client_request()
66
+ .with_context(|| format!("invalid websocket URL: {}", options.url))?;
67
+ request.headers_mut().insert(
68
+ "Origin",
69
+ "https://finance.yahoo.com"
70
+ .parse()
71
+ .context("invalid websocket Origin header")?,
72
+ );
73
+ request.headers_mut().insert(
74
+ "User-Agent",
75
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
76
+ .parse()
77
+ .context("invalid websocket User-Agent header")?,
78
+ );
79
+
80
+ let proxy = selected_proxy(options.proxy, options.no_proxy);
81
+ let (socket, _) = connect_websocket(request, proxy.as_deref())
82
+ .await
83
+ .context("Yahoo websocket connect failed")?;
84
+ let (mut write, mut read) = socket.split();
85
+ write
86
+ .send(Message::Text(
87
+ serde_json::to_string(&SubscribeMessage { subscribe: symbols })?.into(),
88
+ ))
89
+ .await
90
+ .context("Yahoo websocket subscribe failed")?;
91
+
92
+ let mut seen = 0usize;
93
+ while options.message_limit == 0 || seen < options.message_limit {
94
+ let Some(message) = tokio::time::timeout(options.read_timeout, read.next())
95
+ .await
96
+ .context("Yahoo websocket read timed out")?
97
+ else {
98
+ break;
99
+ };
100
+ let message = message.context("Yahoo websocket frame failed")?;
101
+ match message {
102
+ Message::Text(text) => {
103
+ if let Ok(update) = decode_text_message(&text, options.timezone) {
104
+ on_quote(update)?;
105
+ seen += 1;
106
+ }
107
+ }
108
+ Message::Binary(bytes) => {
109
+ if let Ok(text) = std::str::from_utf8(&bytes)
110
+ && let Ok(update) = decode_text_message(text, options.timezone)
111
+ {
112
+ on_quote(update)?;
113
+ seen += 1;
114
+ continue;
115
+ }
116
+ if let Ok(update) = decode_pricing_data(&bytes, options.timezone) {
117
+ on_quote(update)?;
118
+ seen += 1;
119
+ }
120
+ }
121
+ Message::Ping(_) | Message::Pong(_) => {}
122
+ Message::Close(_) => break,
123
+ _ => {}
124
+ }
125
+ }
126
+
127
+ if seen == 0 {
128
+ return Err(anyhow!(
129
+ "Yahoo websocket returned no pricing updates before timeout"
130
+ ));
131
+ }
132
+ Ok(())
133
+ }
134
+
135
+ async fn connect_websocket(
136
+ request: tokio_tungstenite::tungstenite::handshake::client::Request,
137
+ proxy: Option<&str>,
138
+ ) -> Result<(
139
+ WebSocketStream<MaybeTlsStream<TcpStream>>,
140
+ tokio_tungstenite::tungstenite::handshake::client::Response,
141
+ )> {
142
+ let Some(proxy) = proxy else {
143
+ return connect_async(request)
144
+ .await
145
+ .context("direct websocket connect failed");
146
+ };
147
+ let proxy =
148
+ Url::parse(proxy).with_context(|| format!("invalid websocket proxy URL: {proxy}"))?;
149
+ if proxy.scheme() != "http" {
150
+ return Err(anyhow!(
151
+ "websocket stream currently supports http proxy only; got {}",
152
+ proxy.scheme()
153
+ ));
154
+ }
155
+ let proxy_host = proxy
156
+ .host_str()
157
+ .ok_or_else(|| anyhow!("websocket proxy URL missing host"))?;
158
+ let proxy_port = proxy
159
+ .port_or_known_default()
160
+ .ok_or_else(|| anyhow!("websocket proxy URL missing port"))?;
161
+ let target_host = request
162
+ .uri()
163
+ .host()
164
+ .ok_or_else(|| anyhow!("websocket target URL missing host"))?
165
+ .to_string();
166
+ let target_port = request
167
+ .uri()
168
+ .port_u16()
169
+ .or_else(|| match request.uri().scheme_str() {
170
+ Some("wss") => Some(443),
171
+ Some("ws") => Some(80),
172
+ _ => None,
173
+ })
174
+ .ok_or_else(|| anyhow!("websocket target URL missing port"))?;
175
+ let target = format!("{target_host}:{target_port}");
176
+ let mut stream = TcpStream::connect((proxy_host, proxy_port))
177
+ .await
178
+ .with_context(|| format!("failed to connect websocket proxy {proxy_host}:{proxy_port}"))?;
179
+ let connect = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
180
+ stream
181
+ .write_all(connect.as_bytes())
182
+ .await
183
+ .context("failed to write websocket proxy CONNECT")?;
184
+ let mut response = Vec::new();
185
+ let mut buffer = [0_u8; 1024];
186
+ loop {
187
+ let read = stream
188
+ .read(&mut buffer)
189
+ .await
190
+ .context("failed to read websocket proxy CONNECT response")?;
191
+ if read == 0 {
192
+ return Err(anyhow!("websocket proxy closed before CONNECT completed"));
193
+ }
194
+ response.extend_from_slice(&buffer[..read]);
195
+ if response.windows(4).any(|window| window == b"\r\n\r\n") {
196
+ break;
197
+ }
198
+ if response.len() > 8192 {
199
+ return Err(anyhow!("websocket proxy CONNECT response was too large"));
200
+ }
201
+ }
202
+ let response_text = String::from_utf8_lossy(&response);
203
+ if !(response_text.starts_with("HTTP/1.1 200") || response_text.starts_with("HTTP/1.0 200")) {
204
+ return Err(anyhow!(
205
+ "websocket proxy CONNECT failed: {}",
206
+ response_text.lines().next().unwrap_or("<empty>")
207
+ ));
208
+ }
209
+ client_async_tls_with_config(request, stream, None, None)
210
+ .await
211
+ .context("websocket TLS handshake through proxy failed")
212
+ }
213
+
214
+ pub fn decode_text_message(text: &str, timezone: &str) -> Result<StreamQuote> {
215
+ let text = text.trim();
216
+ let message = if text.starts_with('{') {
217
+ serde_json::from_str::<serde_json::Value>(text)?
218
+ .get("message")
219
+ .and_then(|value| value.as_str())
220
+ .ok_or_else(|| anyhow!("websocket JSON frame missing message field"))?
221
+ .to_string()
222
+ } else {
223
+ text.to_string()
224
+ };
225
+ let bytes = general_purpose::STANDARD
226
+ .decode(message.as_bytes())
227
+ .context("websocket base64 decode failed")?;
228
+ decode_pricing_data(&bytes, timezone)
229
+ }
230
+
231
+ fn decode_pricing_data(bytes: &[u8], timezone: &str) -> Result<StreamQuote> {
232
+ let data = PricingData::decode(bytes).context("websocket protobuf decode failed")?;
233
+ let time_utc = (data.time > 0)
234
+ .then_some(data.time)
235
+ .and_then(timestamp_ms_to_utc);
236
+ Ok(StreamQuote {
237
+ symbol: data.id,
238
+ price: data.price as f64,
239
+ time_local: utc_to_local(time_utc.as_deref(), timezone),
240
+ time_utc,
241
+ currency: non_empty(data.currency),
242
+ exchange: non_empty(data.exchange),
243
+ quote_type: Some(data.quote_type).filter(|value| *value != 0),
244
+ market_hours: Some(data.market_hours).filter(|value| *value != 0),
245
+ change_pct: non_zero_f32(data.change_percent),
246
+ day_volume: Some(data.day_volume).filter(|value| *value != 0),
247
+ day_high: non_zero_f32(data.day_high),
248
+ day_low: non_zero_f32(data.day_low),
249
+ change: non_zero_f32(data.change),
250
+ short_name: non_empty(data.short_name),
251
+ open: non_zero_f32(data.open_price),
252
+ previous_close: non_zero_f32(data.previous_close),
253
+ provider: "yahoo-websocket".to_string(),
254
+ })
255
+ }
256
+
257
+ fn non_empty(value: String) -> Option<String> {
258
+ (!value.is_empty()).then_some(value)
259
+ }
260
+
261
+ fn non_zero_f32(value: f32) -> Option<f64> {
262
+ (value != 0.0).then_some(value as f64)
263
+ }
264
+
265
+ #[derive(Clone, PartialEq, ::prost::Message)]
266
+ struct PricingData {
267
+ #[prost(string, tag = "1")]
268
+ id: String,
269
+ #[prost(float, tag = "2")]
270
+ price: f32,
271
+ #[prost(sint64, tag = "3")]
272
+ time: i64,
273
+ #[prost(string, tag = "4")]
274
+ currency: String,
275
+ #[prost(string, tag = "5")]
276
+ exchange: String,
277
+ #[prost(int32, tag = "6")]
278
+ quote_type: i32,
279
+ #[prost(int32, tag = "7")]
280
+ market_hours: i32,
281
+ #[prost(float, tag = "8")]
282
+ change_percent: f32,
283
+ #[prost(sint64, tag = "9")]
284
+ day_volume: i64,
285
+ #[prost(float, tag = "10")]
286
+ day_high: f32,
287
+ #[prost(float, tag = "11")]
288
+ day_low: f32,
289
+ #[prost(float, tag = "12")]
290
+ change: f32,
291
+ #[prost(string, tag = "13")]
292
+ short_name: String,
293
+ #[prost(sint64, tag = "14")]
294
+ expire_date: i64,
295
+ #[prost(float, tag = "15")]
296
+ open_price: f32,
297
+ #[prost(float, tag = "16")]
298
+ previous_close: f32,
299
+ #[prost(float, tag = "17")]
300
+ strike_price: f32,
301
+ #[prost(string, tag = "18")]
302
+ underlying_symbol: String,
303
+ #[prost(sint64, tag = "19")]
304
+ open_interest: i64,
305
+ #[prost(sint64, tag = "20")]
306
+ options_type: i64,
307
+ #[prost(sint64, tag = "21")]
308
+ mini_option: i64,
309
+ #[prost(sint64, tag = "22")]
310
+ last_size: i64,
311
+ #[prost(float, tag = "23")]
312
+ bid: f32,
313
+ #[prost(sint64, tag = "24")]
314
+ bid_size: i64,
315
+ #[prost(float, tag = "25")]
316
+ ask: f32,
317
+ #[prost(sint64, tag = "26")]
318
+ ask_size: i64,
319
+ #[prost(sint64, tag = "27")]
320
+ price_hint: i64,
321
+ #[prost(sint64, tag = "28")]
322
+ vol_24hr: i64,
323
+ #[prost(sint64, tag = "29")]
324
+ vol_all_currencies: i64,
325
+ #[prost(string, tag = "30")]
326
+ from_currency: String,
327
+ #[prost(string, tag = "31")]
328
+ last_market: String,
329
+ #[prost(double, tag = "32")]
330
+ circulating_supply: f64,
331
+ #[prost(double, tag = "33")]
332
+ market_cap: f64,
333
+ }
334
+
335
+ #[cfg(test)]
336
+ mod tests {
337
+ use super::*;
338
+
339
+ #[test]
340
+ fn decodes_yahoo_json_wrapped_base64_pricing_frame() {
341
+ let frame = r#"{"message":"CgRDUkRPFQCATkMYgLaO8tBnIgNVU0QqCE5hc2RhcUdTMAg4AkVcjwrBSPa4ggFVwzVzQ109il9DZc3MnMFqEENyZWRvIFRlY2hub2xvZ3l9mhluQ4UBrgdsQw=="}"#;
342
+ let quote = decode_text_message(frame, "Asia/Singapore").unwrap();
343
+ assert_eq!(quote.symbol, "CRDO");
344
+ assert_eq!(quote.price, 206.5);
345
+ assert_eq!(quote.currency.as_deref(), Some("USD"));
346
+ assert_eq!(quote.exchange.as_deref(), Some("NasdaqGS"));
347
+ assert_eq!(quote.market_hours, Some(2));
348
+ assert_eq!(quote.day_volume, Some(1_068_603));
349
+ assert_eq!(quote.short_name.as_deref(), Some("Credo Technology"));
350
+ assert_eq!(quote.time_utc.as_deref(), Some("2026-06-02T07:00:00Z"));
351
+ assert_eq!(
352
+ quote.time_local.as_deref(),
353
+ Some("2026-06-02T15:00:00+08:00")
354
+ );
355
+ }
356
+ }
package/src/time.rs ADDED
@@ -0,0 +1,21 @@
1
+ use chrono::{DateTime, SecondsFormat, Utc};
2
+ use chrono_tz::Tz;
3
+
4
+ pub const DEFAULT_TIMEZONE: &str = "Asia/Singapore";
5
+
6
+ pub fn utc_to_local(value: Option<&str>, timezone: &str) -> Option<String> {
7
+ let value = value?;
8
+ let datetime = DateTime::parse_from_rfc3339(value).ok()?;
9
+ Some(format_local(datetime.with_timezone(&Utc), timezone))
10
+ }
11
+
12
+ pub fn now_local(timezone: &str) -> String {
13
+ format_local(Utc::now(), timezone)
14
+ }
15
+
16
+ pub fn format_local(datetime: DateTime<Utc>, timezone: &str) -> String {
17
+ let timezone = timezone.parse::<Tz>().unwrap_or(chrono_tz::Asia::Singapore);
18
+ datetime
19
+ .with_timezone(&timezone)
20
+ .to_rfc3339_opts(SecondsFormat::Secs, true)
21
+ }