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,302 @@
|
|
|
1
|
+
use anyhow::{Context, Result, anyhow};
|
|
2
|
+
use serde::Deserialize;
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
use url::Url;
|
|
5
|
+
use wreq::Client;
|
|
6
|
+
|
|
7
|
+
use crate::http::{change_pct, utc_now};
|
|
8
|
+
use crate::model::{Quote, ResearchHighlight, research_value_string};
|
|
9
|
+
|
|
10
|
+
const BASE_URL: &str = "https://quote.cnbc.com/quote-html-webservice/quote.htm";
|
|
11
|
+
|
|
12
|
+
#[derive(Debug, Deserialize)]
|
|
13
|
+
struct CnbcResponse {
|
|
14
|
+
#[serde(rename = "ITVQuoteResult")]
|
|
15
|
+
result: CnbcQuoteResult,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[derive(Debug, Deserialize)]
|
|
19
|
+
struct CnbcQuoteResult {
|
|
20
|
+
#[serde(rename = "ITVQuote")]
|
|
21
|
+
quote: Option<OneOrMany<CnbcQuote>>,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[derive(Debug, Deserialize)]
|
|
25
|
+
#[serde(untagged)]
|
|
26
|
+
enum OneOrMany<T> {
|
|
27
|
+
One(T),
|
|
28
|
+
Many(Vec<T>),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
impl<T> OneOrMany<T> {
|
|
32
|
+
fn into_vec(self) -> Vec<T> {
|
|
33
|
+
match self {
|
|
34
|
+
OneOrMany::One(value) => vec![value],
|
|
35
|
+
OneOrMany::Many(values) => values,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#[derive(Debug, Deserialize)]
|
|
41
|
+
struct CnbcQuote {
|
|
42
|
+
symbol: Option<String>,
|
|
43
|
+
last: Option<String>,
|
|
44
|
+
last_timedate: Option<String>,
|
|
45
|
+
exchange: Option<String>,
|
|
46
|
+
open: Option<String>,
|
|
47
|
+
high: Option<String>,
|
|
48
|
+
low: Option<String>,
|
|
49
|
+
volume: Option<String>,
|
|
50
|
+
#[serde(rename = "currencyCode")]
|
|
51
|
+
currency_code: Option<String>,
|
|
52
|
+
previous_day_closing: Option<String>,
|
|
53
|
+
change_pct: Option<String>,
|
|
54
|
+
#[serde(rename = "feedSymbol")]
|
|
55
|
+
feed_symbol: Option<String>,
|
|
56
|
+
alt_symbol: Option<String>,
|
|
57
|
+
curmktstatus: Option<String>,
|
|
58
|
+
#[serde(rename = "ExtendedMktQuote")]
|
|
59
|
+
extended_market_quote: Option<CnbcExtendedQuote>,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#[derive(Debug, Deserialize)]
|
|
63
|
+
struct CnbcExtendedQuote {
|
|
64
|
+
#[serde(rename = "type")]
|
|
65
|
+
quote_type: Option<String>,
|
|
66
|
+
last: Option<String>,
|
|
67
|
+
last_timedate: Option<String>,
|
|
68
|
+
last_time: Option<String>,
|
|
69
|
+
change_pct: Option<String>,
|
|
70
|
+
volume: Option<String>,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pub async fn fetch_quote(client: &Client, symbol: &str) -> Result<Quote> {
|
|
74
|
+
let provider_symbol = symbol.to_uppercase();
|
|
75
|
+
let mut url = Url::parse(BASE_URL).context("invalid CNBC base URL")?;
|
|
76
|
+
url.query_pairs_mut()
|
|
77
|
+
.append_pair("symbols", &provider_symbol)
|
|
78
|
+
.append_pair("requestMethod", "itv")
|
|
79
|
+
.append_pair("noform", "1")
|
|
80
|
+
.append_pair("partnerId", "2")
|
|
81
|
+
.append_pair("fund", "1")
|
|
82
|
+
.append_pair("exthrs", "1")
|
|
83
|
+
.append_pair("output", "json");
|
|
84
|
+
let response: CnbcResponse = client
|
|
85
|
+
.get(url.as_str())
|
|
86
|
+
.send()
|
|
87
|
+
.await
|
|
88
|
+
.context("CNBC quote request failed")?
|
|
89
|
+
.error_for_status()
|
|
90
|
+
.context("CNBC quote returned HTTP error")?
|
|
91
|
+
.json()
|
|
92
|
+
.await
|
|
93
|
+
.context("CNBC quote JSON parse failed")?;
|
|
94
|
+
let quote = response
|
|
95
|
+
.result
|
|
96
|
+
.quote
|
|
97
|
+
.map(OneOrMany::into_vec)
|
|
98
|
+
.and_then(|mut quotes| quotes.pop())
|
|
99
|
+
.ok_or_else(|| anyhow!("CNBC response missing quote"))?;
|
|
100
|
+
|
|
101
|
+
let extended = quote.extended_market_quote.as_ref();
|
|
102
|
+
let extended_price = extended.and_then(|quote| parse_market_f64(quote.last.as_deref()));
|
|
103
|
+
let regular_price = parse_market_f64(quote.last.as_deref());
|
|
104
|
+
let price = extended_price
|
|
105
|
+
.or(regular_price)
|
|
106
|
+
.ok_or_else(|| anyhow!("CNBC quote missing usable price"))?;
|
|
107
|
+
let previous_close = parse_market_f64(quote.previous_day_closing.as_deref());
|
|
108
|
+
let using_extended = extended_price.is_some();
|
|
109
|
+
let session = if using_extended {
|
|
110
|
+
session_label(extended.and_then(|quote| quote.quote_type.as_deref()))
|
|
111
|
+
} else {
|
|
112
|
+
session_label(quote.curmktstatus.as_deref())
|
|
113
|
+
};
|
|
114
|
+
let market_time = if using_extended {
|
|
115
|
+
extended
|
|
116
|
+
.and_then(|quote| {
|
|
117
|
+
quote
|
|
118
|
+
.last_timedate
|
|
119
|
+
.clone()
|
|
120
|
+
.or_else(|| quote.last_time.clone())
|
|
121
|
+
})
|
|
122
|
+
.or_else(|| quote.last_timedate.clone())
|
|
123
|
+
} else {
|
|
124
|
+
quote.last_timedate.clone()
|
|
125
|
+
};
|
|
126
|
+
let provider_change_pct = if using_extended {
|
|
127
|
+
extended.and_then(|quote| parse_market_f64(quote.change_pct.as_deref()))
|
|
128
|
+
} else {
|
|
129
|
+
parse_market_f64(quote.change_pct.as_deref())
|
|
130
|
+
};
|
|
131
|
+
let change_pct_value = change_pct(price, previous_close).or(provider_change_pct);
|
|
132
|
+
let volume = if using_extended {
|
|
133
|
+
extended.and_then(|quote| parse_market_u64(quote.volume.as_deref()))
|
|
134
|
+
} else {
|
|
135
|
+
parse_market_u64(quote.volume.as_deref())
|
|
136
|
+
};
|
|
137
|
+
let open = non_zero(parse_market_f64(quote.open.as_deref()));
|
|
138
|
+
let high = non_zero(parse_market_f64(quote.high.as_deref()));
|
|
139
|
+
let low = non_zero(parse_market_f64(quote.low.as_deref()));
|
|
140
|
+
let symbol = quote.symbol.unwrap_or_else(|| provider_symbol.clone());
|
|
141
|
+
let currency = quote.currency_code;
|
|
142
|
+
let exchange = quote.exchange;
|
|
143
|
+
let provider_symbol = quote
|
|
144
|
+
.feed_symbol
|
|
145
|
+
.or(quote.alt_symbol)
|
|
146
|
+
.or(Some(provider_symbol));
|
|
147
|
+
|
|
148
|
+
Ok(Quote {
|
|
149
|
+
symbol,
|
|
150
|
+
price,
|
|
151
|
+
currency,
|
|
152
|
+
provider: "cnbc-extended".to_string(),
|
|
153
|
+
session: Some(session),
|
|
154
|
+
fetched_at_utc: utc_now(),
|
|
155
|
+
market_time,
|
|
156
|
+
previous_close,
|
|
157
|
+
open,
|
|
158
|
+
high,
|
|
159
|
+
low,
|
|
160
|
+
volume,
|
|
161
|
+
exchange,
|
|
162
|
+
provider_symbol,
|
|
163
|
+
change_pct: change_pct_value,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
pub async fn fetch_quote_payload(client: &Client, symbol: &str) -> Result<Value> {
|
|
168
|
+
let provider_symbol = symbol.to_uppercase();
|
|
169
|
+
let mut url = Url::parse(BASE_URL).context("invalid CNBC base URL")?;
|
|
170
|
+
url.query_pairs_mut()
|
|
171
|
+
.append_pair("symbols", &provider_symbol)
|
|
172
|
+
.append_pair("requestMethod", "itv")
|
|
173
|
+
.append_pair("noform", "1")
|
|
174
|
+
.append_pair("partnerId", "2")
|
|
175
|
+
.append_pair("fund", "1")
|
|
176
|
+
.append_pair("exthrs", "1")
|
|
177
|
+
.append_pair("output", "json");
|
|
178
|
+
client
|
|
179
|
+
.get(url.as_str())
|
|
180
|
+
.send()
|
|
181
|
+
.await
|
|
182
|
+
.context("CNBC quote payload request failed")?
|
|
183
|
+
.error_for_status()
|
|
184
|
+
.context("CNBC quote payload returned HTTP error")?
|
|
185
|
+
.json()
|
|
186
|
+
.await
|
|
187
|
+
.context("CNBC quote payload JSON parse failed")
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pub fn fundamentals_highlights(payload: &Value) -> Vec<ResearchHighlight> {
|
|
191
|
+
let mut rows = Vec::new();
|
|
192
|
+
let Some(quote) = first_quote(payload) else {
|
|
193
|
+
return rows;
|
|
194
|
+
};
|
|
195
|
+
for (label, path) in [
|
|
196
|
+
("Company", "/name"),
|
|
197
|
+
("Exchange", "/exchange"),
|
|
198
|
+
("Quote source", "/provider"),
|
|
199
|
+
("Current status", "/curmktstatus"),
|
|
200
|
+
("Market cap", "/mktcapView"),
|
|
201
|
+
("PE", "/pe"),
|
|
202
|
+
("Forward PE", "/fpe"),
|
|
203
|
+
("EPS", "/eps"),
|
|
204
|
+
("Forward EPS", "/feps"),
|
|
205
|
+
("Dividend", "/dividend"),
|
|
206
|
+
("Dividend yield", "/dividendyield"),
|
|
207
|
+
("Beta", "/beta"),
|
|
208
|
+
("10-day avg volume", "/tendayavgvol"),
|
|
209
|
+
("Revenue TTM", "/revenuettm"),
|
|
210
|
+
("Price/Sales", "/psales"),
|
|
211
|
+
("Forward Sales", "/fsales"),
|
|
212
|
+
("Shares Out", "/sharesout"),
|
|
213
|
+
("ROE TTM", "/ROETTM"),
|
|
214
|
+
("Net margin TTM", "/NETPROFTTM"),
|
|
215
|
+
("Gross margin TTM", "/GROSMGNTTM"),
|
|
216
|
+
("EBITDA TTM", "/TTMEBITD"),
|
|
217
|
+
("Debt/Equity", "/DEBTEQTYQ"),
|
|
218
|
+
("52-week high", "/yrhiprice"),
|
|
219
|
+
("52-week low", "/yrloprice"),
|
|
220
|
+
] {
|
|
221
|
+
push_path(&mut rows, quote, label, path);
|
|
222
|
+
}
|
|
223
|
+
if let Some(extended) = quote.get("ExtendedMktQuote") {
|
|
224
|
+
for (label, path) in [
|
|
225
|
+
("Extended type", "/type"),
|
|
226
|
+
("Extended price", "/last"),
|
|
227
|
+
("Extended change", "/change"),
|
|
228
|
+
("Extended change pct", "/change_pct"),
|
|
229
|
+
("Extended volume", "/volume"),
|
|
230
|
+
("Extended time", "/last_time"),
|
|
231
|
+
] {
|
|
232
|
+
if let Some(value) = string_at(extended, path) {
|
|
233
|
+
rows.push(ResearchHighlight::new(
|
|
234
|
+
label,
|
|
235
|
+
value,
|
|
236
|
+
"cnbc",
|
|
237
|
+
"fundamentals-lite",
|
|
238
|
+
));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
rows
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
fn first_quote(payload: &Value) -> Option<&Value> {
|
|
246
|
+
payload
|
|
247
|
+
.pointer("/ITVQuoteResult/ITVQuote")
|
|
248
|
+
.and_then(|quote| match quote {
|
|
249
|
+
Value::Array(quotes) => quotes.first(),
|
|
250
|
+
Value::Object(_) => Some(quote),
|
|
251
|
+
_ => None,
|
|
252
|
+
})
|
|
253
|
+
.or_else(|| {
|
|
254
|
+
payload
|
|
255
|
+
.pointer("/FormattedQuoteResult/FormattedQuote")
|
|
256
|
+
.and_then(|quote| match quote {
|
|
257
|
+
Value::Array(quotes) => quotes.first(),
|
|
258
|
+
Value::Object(_) => Some(quote),
|
|
259
|
+
_ => None,
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
fn push_path(rows: &mut Vec<ResearchHighlight>, payload: &Value, label: &str, path: &str) {
|
|
265
|
+
if let Some(row) =
|
|
266
|
+
ResearchHighlight::from_path(Some(payload), label, path, "cnbc", "fundamentals-lite")
|
|
267
|
+
.filter(|row| !row.value.trim().is_empty())
|
|
268
|
+
{
|
|
269
|
+
rows.push(row);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fn string_at(payload: &Value, path: &str) -> Option<String> {
|
|
274
|
+
research_value_string(payload.pointer(path)).filter(|value| !value.trim().is_empty())
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
fn parse_market_f64(value: Option<&str>) -> Option<f64> {
|
|
278
|
+
let value = value?.trim();
|
|
279
|
+
if value.is_empty() || value == "N/D" || value == "--" {
|
|
280
|
+
return None;
|
|
281
|
+
}
|
|
282
|
+
let cleaned = value
|
|
283
|
+
.trim_start_matches('$')
|
|
284
|
+
.trim_end_matches('%')
|
|
285
|
+
.replace(',', "");
|
|
286
|
+
cleaned.parse::<f64>().ok()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
fn parse_market_u64(value: Option<&str>) -> Option<u64> {
|
|
290
|
+
parse_market_f64(value).map(|value| value as u64)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
fn non_zero(value: Option<f64>) -> Option<f64> {
|
|
294
|
+
value.filter(|value| *value != 0.0)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
fn session_label(value: Option<&str>) -> String {
|
|
298
|
+
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
299
|
+
return "regular".to_string();
|
|
300
|
+
};
|
|
301
|
+
value.to_ascii_lowercase()
|
|
302
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
use anyhow::{Result, anyhow};
|
|
2
|
+
use wreq::Client;
|
|
3
|
+
|
|
4
|
+
use crate::cli::{HistoryAdjustment, Provider, StooqAsset, StooqMarket};
|
|
5
|
+
use crate::model::{HistoryBatch, Quote};
|
|
6
|
+
|
|
7
|
+
pub mod binance_futures;
|
|
8
|
+
pub mod capabilities;
|
|
9
|
+
pub mod cnbc;
|
|
10
|
+
pub mod robinhood;
|
|
11
|
+
pub mod sec_edgar;
|
|
12
|
+
pub mod stooq;
|
|
13
|
+
pub mod yahoo;
|
|
14
|
+
|
|
15
|
+
#[derive(Debug, Clone)]
|
|
16
|
+
pub struct HistoryRequest {
|
|
17
|
+
pub symbol: String,
|
|
18
|
+
pub interval: String,
|
|
19
|
+
pub range: String,
|
|
20
|
+
pub limit: usize,
|
|
21
|
+
pub extended_session: bool,
|
|
22
|
+
pub adjustment: HistoryAdjustment,
|
|
23
|
+
pub actions: bool,
|
|
24
|
+
pub repair: bool,
|
|
25
|
+
pub stooq_market: StooqMarket,
|
|
26
|
+
pub stooq_asset: StooqAsset,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pub async fn fetch_quote_without_boats(
|
|
30
|
+
client: &Client,
|
|
31
|
+
symbol: &str,
|
|
32
|
+
label: &str,
|
|
33
|
+
) -> Result<Quote> {
|
|
34
|
+
let extended_error = match yahoo::fetch_extended_quote(client, symbol).await {
|
|
35
|
+
Ok(quote) => return Ok(quote),
|
|
36
|
+
Err(error) => error.to_string(),
|
|
37
|
+
};
|
|
38
|
+
let yahoo_error = match yahoo::fetch_quote(client, symbol).await {
|
|
39
|
+
Ok(quote) => return Ok(quote),
|
|
40
|
+
Err(error) => error.to_string(),
|
|
41
|
+
};
|
|
42
|
+
match stooq::fetch_quote(client, symbol).await {
|
|
43
|
+
Ok(quote) => Ok(quote),
|
|
44
|
+
Err(stooq_error) => Err(anyhow!(
|
|
45
|
+
"{}: yahoo-extended: {}; yahoo: {}; stooq: {}",
|
|
46
|
+
label,
|
|
47
|
+
extended_error,
|
|
48
|
+
yahoo_error,
|
|
49
|
+
stooq_error
|
|
50
|
+
)),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
pub async fn fetch_history(
|
|
55
|
+
client: &Client,
|
|
56
|
+
provider: Provider,
|
|
57
|
+
request: &HistoryRequest,
|
|
58
|
+
) -> Result<HistoryBatch> {
|
|
59
|
+
match provider {
|
|
60
|
+
Provider::Yahoo => yahoo::fetch_history(client, request).await,
|
|
61
|
+
Provider::YahooExtended => yahoo::fetch_extended_history(client, request).await,
|
|
62
|
+
Provider::YahooBoats => Err(anyhow!(
|
|
63
|
+
"yahoo-boats does not support history; use yahoo-extended for pre/post chart bars"
|
|
64
|
+
)),
|
|
65
|
+
Provider::Stooq => {
|
|
66
|
+
stooq::fetch_history(
|
|
67
|
+
client,
|
|
68
|
+
&request.symbol,
|
|
69
|
+
&request.interval,
|
|
70
|
+
request.limit,
|
|
71
|
+
request.stooq_market,
|
|
72
|
+
request.stooq_asset,
|
|
73
|
+
)
|
|
74
|
+
.await
|
|
75
|
+
}
|
|
76
|
+
Provider::CnbcExtended => Err(anyhow!("cnbc-extended does not support history")),
|
|
77
|
+
Provider::Robinhood => {
|
|
78
|
+
robinhood::fetch_history(
|
|
79
|
+
client,
|
|
80
|
+
&request.symbol,
|
|
81
|
+
&request.interval,
|
|
82
|
+
&request.range,
|
|
83
|
+
request.extended_session,
|
|
84
|
+
request.limit,
|
|
85
|
+
)
|
|
86
|
+
.await
|
|
87
|
+
}
|
|
88
|
+
Provider::BinanceFutures => {
|
|
89
|
+
binance_futures::fetch_history(
|
|
90
|
+
client,
|
|
91
|
+
&request.symbol,
|
|
92
|
+
&request.interval,
|
|
93
|
+
request.limit,
|
|
94
|
+
)
|
|
95
|
+
.await
|
|
96
|
+
}
|
|
97
|
+
Provider::Auto => {
|
|
98
|
+
let yahoo_error = match yahoo::fetch_history(client, request).await {
|
|
99
|
+
Ok(history) => return Ok(history),
|
|
100
|
+
Err(error) => error.to_string(),
|
|
101
|
+
};
|
|
102
|
+
match stooq::fetch_history(
|
|
103
|
+
client,
|
|
104
|
+
&request.symbol,
|
|
105
|
+
&request.interval,
|
|
106
|
+
request.limit,
|
|
107
|
+
request.stooq_market,
|
|
108
|
+
request.stooq_asset,
|
|
109
|
+
)
|
|
110
|
+
.await
|
|
111
|
+
{
|
|
112
|
+
Ok(history) => Ok(history),
|
|
113
|
+
Err(stooq_error) => Err(anyhow!("yahoo: {}; stooq: {}", yahoo_error, stooq_error)),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|