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
package/src/page_read.rs
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
use anyhow::{Context, Result, anyhow};
|
|
2
|
+
use scraper::{Html, Selector};
|
|
3
|
+
use serde::Serialize;
|
|
4
|
+
use url::Url;
|
|
5
|
+
use wreq::{
|
|
6
|
+
Client,
|
|
7
|
+
header::{ACCEPT, CONTENT_TYPE},
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
use crate::cli::ReadUrlProvider;
|
|
11
|
+
use crate::http::utc_now;
|
|
12
|
+
|
|
13
|
+
#[derive(Debug, Clone, Serialize)]
|
|
14
|
+
pub struct PageReadReport {
|
|
15
|
+
pub url: String,
|
|
16
|
+
pub provider: String,
|
|
17
|
+
pub fetched_at_utc: String,
|
|
18
|
+
pub source_url: String,
|
|
19
|
+
pub title: Option<String>,
|
|
20
|
+
pub word_count: usize,
|
|
21
|
+
pub char_count: usize,
|
|
22
|
+
pub truncated: bool,
|
|
23
|
+
pub content: String,
|
|
24
|
+
pub errors: Vec<PageReadError>,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[derive(Debug, Clone, Serialize)]
|
|
28
|
+
pub struct PageReadError {
|
|
29
|
+
pub provider: String,
|
|
30
|
+
pub error: String,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pub async fn read_url(
|
|
34
|
+
client: &Client,
|
|
35
|
+
url: &str,
|
|
36
|
+
provider: ReadUrlProvider,
|
|
37
|
+
max_chars: usize,
|
|
38
|
+
) -> Result<PageReadReport> {
|
|
39
|
+
let normalized = normalize_url(url)?;
|
|
40
|
+
let providers = providers_for_url(provider, &normalized);
|
|
41
|
+
let mut errors = Vec::new();
|
|
42
|
+
|
|
43
|
+
for provider in providers {
|
|
44
|
+
match read_with_provider(client, &normalized, provider, max_chars).await {
|
|
45
|
+
Ok(mut report) => {
|
|
46
|
+
report.errors = errors;
|
|
47
|
+
return Ok(report);
|
|
48
|
+
}
|
|
49
|
+
Err(error) => errors.push(PageReadError {
|
|
50
|
+
provider: provider.label().to_string(),
|
|
51
|
+
error: format!("{error:#}"),
|
|
52
|
+
}),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Err(anyhow!(
|
|
57
|
+
"no URL reader provider returned usable content for {normalized}: {}",
|
|
58
|
+
errors
|
|
59
|
+
.iter()
|
|
60
|
+
.map(|error| format!("{}={}", error.provider, error.error))
|
|
61
|
+
.collect::<Vec<_>>()
|
|
62
|
+
.join("; ")
|
|
63
|
+
))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async fn read_with_provider(
|
|
67
|
+
client: &Client,
|
|
68
|
+
url: &str,
|
|
69
|
+
provider: ReadUrlProvider,
|
|
70
|
+
max_chars: usize,
|
|
71
|
+
) -> Result<PageReadReport> {
|
|
72
|
+
let source_url = provider_url(url, provider)?;
|
|
73
|
+
let response = client
|
|
74
|
+
.get(&source_url)
|
|
75
|
+
.header(ACCEPT, "text/markdown,text/plain,text/html,*/*")
|
|
76
|
+
.send()
|
|
77
|
+
.await
|
|
78
|
+
.with_context(|| format!("{} request failed", provider.label()))?;
|
|
79
|
+
let status = response.status();
|
|
80
|
+
let content_type = response
|
|
81
|
+
.headers()
|
|
82
|
+
.get(CONTENT_TYPE)
|
|
83
|
+
.and_then(|value| value.to_str().ok())
|
|
84
|
+
.map(str::to_string);
|
|
85
|
+
let body = response
|
|
86
|
+
.text()
|
|
87
|
+
.await
|
|
88
|
+
.with_context(|| format!("{} response body read failed", provider.label()))?;
|
|
89
|
+
if !status.is_success() {
|
|
90
|
+
return Err(anyhow!(
|
|
91
|
+
"{} returned HTTP {status}: {}",
|
|
92
|
+
provider.label(),
|
|
93
|
+
body.chars().take(500).collect::<String>()
|
|
94
|
+
));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let extracted = match provider {
|
|
98
|
+
ReadUrlProvider::Direct => direct_body_to_content(&body, content_type.as_deref()),
|
|
99
|
+
ReadUrlProvider::Defuddle | ReadUrlProvider::Jina => ExtractedContent {
|
|
100
|
+
title: title_from_content(&body),
|
|
101
|
+
content: body,
|
|
102
|
+
},
|
|
103
|
+
ReadUrlProvider::Auto => unreachable!("auto is expanded before provider fetch"),
|
|
104
|
+
};
|
|
105
|
+
let mut content = extracted.content;
|
|
106
|
+
content = normalize_content(&content);
|
|
107
|
+
ensure_usable_content(provider, &content)?;
|
|
108
|
+
|
|
109
|
+
let title = extracted.title.or_else(|| title_from_content(&content));
|
|
110
|
+
let word_count = content.split_whitespace().count();
|
|
111
|
+
let char_count = content.chars().count();
|
|
112
|
+
let (content, truncated) = truncate_chars(&content, max_chars);
|
|
113
|
+
|
|
114
|
+
Ok(PageReadReport {
|
|
115
|
+
url: url.to_string(),
|
|
116
|
+
provider: provider.label().to_string(),
|
|
117
|
+
fetched_at_utc: utc_now(),
|
|
118
|
+
source_url,
|
|
119
|
+
title,
|
|
120
|
+
word_count,
|
|
121
|
+
char_count,
|
|
122
|
+
truncated,
|
|
123
|
+
content,
|
|
124
|
+
errors: Vec::new(),
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fn providers_for_url(provider: ReadUrlProvider, url: &str) -> Vec<ReadUrlProvider> {
|
|
129
|
+
match provider {
|
|
130
|
+
ReadUrlProvider::Auto if is_sec_archive_url(url) => vec![
|
|
131
|
+
ReadUrlProvider::Jina,
|
|
132
|
+
ReadUrlProvider::Defuddle,
|
|
133
|
+
ReadUrlProvider::Direct,
|
|
134
|
+
],
|
|
135
|
+
ReadUrlProvider::Auto => vec![
|
|
136
|
+
ReadUrlProvider::Direct,
|
|
137
|
+
ReadUrlProvider::Jina,
|
|
138
|
+
ReadUrlProvider::Defuddle,
|
|
139
|
+
],
|
|
140
|
+
provider => vec![provider],
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn is_sec_archive_url(url: &str) -> bool {
|
|
145
|
+
Url::parse(url)
|
|
146
|
+
.ok()
|
|
147
|
+
.and_then(|url| {
|
|
148
|
+
let host_matches = url
|
|
149
|
+
.host_str()
|
|
150
|
+
.is_some_and(|host| host.eq_ignore_ascii_case("www.sec.gov"));
|
|
151
|
+
host_matches.then(|| url.path().starts_with("/Archives/"))
|
|
152
|
+
})
|
|
153
|
+
.unwrap_or(false)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn provider_url(url: &str, provider: ReadUrlProvider) -> Result<String> {
|
|
157
|
+
match provider {
|
|
158
|
+
ReadUrlProvider::Direct => Ok(url.to_string()),
|
|
159
|
+
ReadUrlProvider::Defuddle => {
|
|
160
|
+
let parsed = Url::parse(url)?;
|
|
161
|
+
let host = parsed
|
|
162
|
+
.host_str()
|
|
163
|
+
.ok_or_else(|| anyhow!("URL has no host: {url}"))?;
|
|
164
|
+
let mut target = host.to_string();
|
|
165
|
+
if let Some(port) = parsed.port() {
|
|
166
|
+
target.push(':');
|
|
167
|
+
target.push_str(&port.to_string());
|
|
168
|
+
}
|
|
169
|
+
target.push_str(parsed.path());
|
|
170
|
+
if let Some(query) = parsed.query() {
|
|
171
|
+
target.push('?');
|
|
172
|
+
target.push_str(query);
|
|
173
|
+
}
|
|
174
|
+
Ok(format!("https://defuddle.md/{target}"))
|
|
175
|
+
}
|
|
176
|
+
ReadUrlProvider::Jina => Ok(format!("https://r.jina.ai/{url}")),
|
|
177
|
+
ReadUrlProvider::Auto => Err(anyhow!("auto does not have a single provider URL")),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fn normalize_url(url: &str) -> Result<String> {
|
|
182
|
+
let trimmed = url.trim();
|
|
183
|
+
let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
|
184
|
+
trimmed.to_string()
|
|
185
|
+
} else {
|
|
186
|
+
format!("https://{trimmed}")
|
|
187
|
+
};
|
|
188
|
+
Url::parse(&with_scheme).with_context(|| format!("invalid URL: {url}"))?;
|
|
189
|
+
Ok(with_scheme)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#[derive(Debug)]
|
|
193
|
+
struct ExtractedContent {
|
|
194
|
+
content: String,
|
|
195
|
+
title: Option<String>,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fn direct_body_to_content(body: &str, content_type: Option<&str>) -> ExtractedContent {
|
|
199
|
+
if content_type
|
|
200
|
+
.map(|value| value.contains("text/html") || value.contains("application/xhtml"))
|
|
201
|
+
.unwrap_or_else(|| looks_like_html(body))
|
|
202
|
+
{
|
|
203
|
+
html_to_text(body)
|
|
204
|
+
} else {
|
|
205
|
+
ExtractedContent {
|
|
206
|
+
content: body.to_string(),
|
|
207
|
+
title: None,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fn html_to_text(body: &str) -> ExtractedContent {
|
|
213
|
+
let body = strip_html_tag_blocks(body, &["script", "style", "noscript"]);
|
|
214
|
+
let document = Html::parse_document(&body);
|
|
215
|
+
let title = title_from_html(&body);
|
|
216
|
+
let body_selector = Selector::parse("body").expect("valid body selector");
|
|
217
|
+
let mut text = String::new();
|
|
218
|
+
if let Some(title) = title.as_deref() {
|
|
219
|
+
text.push_str("# ");
|
|
220
|
+
text.push_str(title);
|
|
221
|
+
text.push_str("\n\n");
|
|
222
|
+
}
|
|
223
|
+
if let Some(body) = document.select(&body_selector).next() {
|
|
224
|
+
push_text_nodes(&mut text, body.text());
|
|
225
|
+
} else {
|
|
226
|
+
push_text_nodes(&mut text, document.root_element().text());
|
|
227
|
+
}
|
|
228
|
+
ExtractedContent {
|
|
229
|
+
content: text,
|
|
230
|
+
title,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
fn ensure_usable_content(provider: ReadUrlProvider, content: &str) -> Result<()> {
|
|
235
|
+
let words = content.split_whitespace().count();
|
|
236
|
+
if words < 40 {
|
|
237
|
+
return Err(anyhow!(
|
|
238
|
+
"{} returned too little readable content: {words} words",
|
|
239
|
+
provider.label()
|
|
240
|
+
));
|
|
241
|
+
}
|
|
242
|
+
if contains_blocked_marker(content) {
|
|
243
|
+
return Err(anyhow!(
|
|
244
|
+
"{} returned likely anti-bot or blocked content",
|
|
245
|
+
provider.label()
|
|
246
|
+
));
|
|
247
|
+
}
|
|
248
|
+
Ok(())
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fn push_text_nodes<'a>(output: &mut String, nodes: impl Iterator<Item = &'a str>) {
|
|
252
|
+
for text in nodes {
|
|
253
|
+
let text = text.trim();
|
|
254
|
+
if text.is_empty() {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if !output.ends_with([' ', '\n']) {
|
|
258
|
+
output.push(' ');
|
|
259
|
+
}
|
|
260
|
+
output.push_str(text);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
fn contains_blocked_marker(content: &str) -> bool {
|
|
265
|
+
[
|
|
266
|
+
"access denied",
|
|
267
|
+
"captcha",
|
|
268
|
+
"cloudflare",
|
|
269
|
+
"checking your browser",
|
|
270
|
+
"please enable javascript",
|
|
271
|
+
]
|
|
272
|
+
.iter()
|
|
273
|
+
.any(|needle| contains_ascii_case_insensitive(content, needle))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
|
|
277
|
+
haystack
|
|
278
|
+
.as_bytes()
|
|
279
|
+
.windows(needle.len())
|
|
280
|
+
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
fn title_from_html(body: &str) -> Option<String> {
|
|
284
|
+
let document = Html::parse_document(body);
|
|
285
|
+
let selector = Selector::parse("title").ok()?;
|
|
286
|
+
let title = document
|
|
287
|
+
.select(&selector)
|
|
288
|
+
.next()?
|
|
289
|
+
.text()
|
|
290
|
+
.collect::<Vec<_>>()
|
|
291
|
+
.join(" ");
|
|
292
|
+
let title = normalize_inline_text(&title);
|
|
293
|
+
(!title.is_empty()).then_some(title)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
fn title_from_content(content: &str) -> Option<String> {
|
|
297
|
+
for line in content.lines().take(8) {
|
|
298
|
+
let line = line.trim();
|
|
299
|
+
if let Some(title) = line.strip_prefix("title:") {
|
|
300
|
+
return Some(title.trim().trim_matches('"').to_string());
|
|
301
|
+
}
|
|
302
|
+
if let Some(title) = line.strip_prefix("Title:") {
|
|
303
|
+
return Some(title.trim().to_string());
|
|
304
|
+
}
|
|
305
|
+
if let Some(title) = line.strip_prefix("# ") {
|
|
306
|
+
return Some(title.trim().to_string());
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
None
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
fn truncate_chars(content: &str, max_chars: usize) -> (String, bool) {
|
|
313
|
+
if max_chars == 0 {
|
|
314
|
+
return (content.to_string(), false);
|
|
315
|
+
}
|
|
316
|
+
let mut end = 0;
|
|
317
|
+
for (count, (index, character)) in content.char_indices().enumerate() {
|
|
318
|
+
if count >= max_chars {
|
|
319
|
+
return (content[..end].to_string(), true);
|
|
320
|
+
}
|
|
321
|
+
end = index + character.len_utf8();
|
|
322
|
+
}
|
|
323
|
+
(content.to_string(), false)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
fn normalize_content(value: &str) -> String {
|
|
327
|
+
let mut output = Vec::new();
|
|
328
|
+
let mut previous_blank = false;
|
|
329
|
+
for line in value.lines() {
|
|
330
|
+
let line = normalize_inline_text(line);
|
|
331
|
+
let blank = line.is_empty();
|
|
332
|
+
if blank {
|
|
333
|
+
if !previous_blank {
|
|
334
|
+
output.push(String::new());
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
output.push(line);
|
|
338
|
+
}
|
|
339
|
+
previous_blank = blank;
|
|
340
|
+
}
|
|
341
|
+
output.join("\n").trim().to_string()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
fn normalize_inline_text(value: &str) -> String {
|
|
345
|
+
value.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
fn looks_like_html(body: &str) -> bool {
|
|
349
|
+
let lower = body
|
|
350
|
+
.chars()
|
|
351
|
+
.take(500)
|
|
352
|
+
.collect::<String>()
|
|
353
|
+
.to_ascii_lowercase();
|
|
354
|
+
lower.contains("<html") || lower.contains("<body") || lower.contains("<!doctype")
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
fn strip_html_tag_blocks(input: &str, tags: &[&str]) -> String {
|
|
358
|
+
let mut output = input.to_string();
|
|
359
|
+
for tag in tags {
|
|
360
|
+
loop {
|
|
361
|
+
let lower = output.to_ascii_lowercase();
|
|
362
|
+
let Some(start) = lower.find(&format!("<{tag}")) else {
|
|
363
|
+
break;
|
|
364
|
+
};
|
|
365
|
+
let Some(relative_end) = lower[start..].find(&format!("</{tag}>")) else {
|
|
366
|
+
break;
|
|
367
|
+
};
|
|
368
|
+
let end = start + relative_end + tag.len() + 3;
|
|
369
|
+
output.replace_range(start..end, " ");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
output
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#[cfg(test)]
|
|
376
|
+
mod tests {
|
|
377
|
+
use super::*;
|
|
378
|
+
|
|
379
|
+
#[test]
|
|
380
|
+
fn defuddle_url_keeps_sec_archive_path_without_double_scheme() {
|
|
381
|
+
let url = "https://www.sec.gov/Archives/edgar/data/0001807794/000162828026014017/crdo.htm";
|
|
382
|
+
let provider_url = provider_url(url, ReadUrlProvider::Defuddle).expect("provider URL");
|
|
383
|
+
assert_eq!(
|
|
384
|
+
provider_url,
|
|
385
|
+
"https://defuddle.md/www.sec.gov/Archives/edgar/data/0001807794/000162828026014017/crdo.htm"
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#[test]
|
|
390
|
+
fn direct_html_extracts_title_and_visible_text() {
|
|
391
|
+
let html = r#"
|
|
392
|
+
<html>
|
|
393
|
+
<head><title>Credo 10-Q</title></head>
|
|
394
|
+
<body><script>ignored()</script><h1>FORM 10-Q</h1><p>Revenue increased with hyperscale data center customers.</p></body>
|
|
395
|
+
</html>
|
|
396
|
+
"#;
|
|
397
|
+
let extracted = html_to_text(html);
|
|
398
|
+
assert_eq!(extracted.title.as_deref(), Some("Credo 10-Q"));
|
|
399
|
+
assert!(extracted.content.contains("# Credo 10-Q"));
|
|
400
|
+
assert!(extracted.content.contains("FORM 10-Q"));
|
|
401
|
+
assert!(
|
|
402
|
+
extracted
|
|
403
|
+
.content
|
|
404
|
+
.contains("Revenue increased with hyperscale data center customers.")
|
|
405
|
+
);
|
|
406
|
+
assert!(!extracted.content.contains("ignored()"));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#[test]
|
|
410
|
+
fn unusable_block_pages_are_rejected() {
|
|
411
|
+
let content = "
|
|
412
|
+
This page contains enough words to avoid the short-content guard and verify the blocked
|
|
413
|
+
marker path directly. The response keeps repeating filler words for a normal-looking
|
|
414
|
+
paragraph, but it still says Access Denied and asks for a Cloudflare captcha challenge
|
|
415
|
+
before any useful filing or article text becomes available to the reader.
|
|
416
|
+
";
|
|
417
|
+
let error = ensure_usable_content(ReadUrlProvider::Direct, content).expect_err("blocked");
|
|
418
|
+
assert!(error.to_string().contains("anti-bot"));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
#[test]
|
|
422
|
+
fn short_content_is_rejected_before_anti_bot_markers() {
|
|
423
|
+
let error = ensure_usable_content(ReadUrlProvider::Direct, "short readable page")
|
|
424
|
+
.expect_err("short");
|
|
425
|
+
assert!(error.to_string().contains("too little"));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
#[test]
|
|
429
|
+
fn sec_archive_auto_prefers_reader_fallbacks_before_direct() {
|
|
430
|
+
let providers = providers_for_url(
|
|
431
|
+
ReadUrlProvider::Auto,
|
|
432
|
+
"https://www.sec.gov/Archives/edgar/data/0001807794/000162828026014017/crdo.htm",
|
|
433
|
+
);
|
|
434
|
+
assert_eq!(
|
|
435
|
+
providers,
|
|
436
|
+
vec![
|
|
437
|
+
ReadUrlProvider::Jina,
|
|
438
|
+
ReadUrlProvider::Defuddle,
|
|
439
|
+
ReadUrlProvider::Direct
|
|
440
|
+
]
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
package/src/price.rs
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
use std::collections::BTreeMap;
|
|
2
|
+
|
|
3
|
+
use chrono::{DateTime, Utc};
|
|
4
|
+
use wreq::Client;
|
|
5
|
+
|
|
6
|
+
use crate::cli::SessionMode;
|
|
7
|
+
use crate::http::{change_pct, utc_now};
|
|
8
|
+
use crate::model::{
|
|
9
|
+
PricePoint, PriceSummary, Quote, RegularBasis, SESSION_EXTENDED, SESSION_OVERNIGHT,
|
|
10
|
+
SESSION_POST, SESSION_PRE, SESSION_REGULAR,
|
|
11
|
+
};
|
|
12
|
+
use crate::providers::{self, binance_futures, cnbc, robinhood, yahoo};
|
|
13
|
+
use crate::time::{now_local, utc_to_local};
|
|
14
|
+
|
|
15
|
+
pub async fn fetch_price_summary(
|
|
16
|
+
client: &Client,
|
|
17
|
+
symbol: &str,
|
|
18
|
+
timezone: &str,
|
|
19
|
+
mode: SessionMode,
|
|
20
|
+
proxy_symbol: Option<&str>,
|
|
21
|
+
) -> PriceSummary {
|
|
22
|
+
let normalized = symbol.trim().to_uppercase();
|
|
23
|
+
let fetched_at_utc = utc_now();
|
|
24
|
+
let fetched_at_local = now_local(timezone);
|
|
25
|
+
let mut errors = BTreeMap::new();
|
|
26
|
+
let mut sessions = Vec::new();
|
|
27
|
+
|
|
28
|
+
match yahoo::fetch_session_points(client, &normalized, timezone).await {
|
|
29
|
+
Ok(points) => sessions.extend(points),
|
|
30
|
+
Err(error) => {
|
|
31
|
+
errors.insert("yahoo-boats".to_string(), format!("{error:#}"));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if sessions.is_empty() {
|
|
36
|
+
match providers::fetch_quote_without_boats(client, &normalized, "fallback").await {
|
|
37
|
+
Ok(quote) => sessions.push(quote_to_point(
|
|
38
|
+
quote,
|
|
39
|
+
"Current price",
|
|
40
|
+
timezone,
|
|
41
|
+
Some("Yahoo/Stooq fallback".to_string()),
|
|
42
|
+
)),
|
|
43
|
+
Err(error) => {
|
|
44
|
+
errors.insert("auto".to_string(), format!("{error:#}"));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if matches!(mode, SessionMode::All) {
|
|
50
|
+
match cnbc::fetch_quote(client, &normalized).await {
|
|
51
|
+
Ok(quote) => sessions.push(quote_to_point(
|
|
52
|
+
quote,
|
|
53
|
+
"CNBC extended cross-check",
|
|
54
|
+
timezone,
|
|
55
|
+
Some("CNBC ExtendedMktQuote cross-check".to_string()),
|
|
56
|
+
)),
|
|
57
|
+
Err(error) => {
|
|
58
|
+
errors.insert("cnbc-extended".to_string(), format!("{error:#}"));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
match robinhood::fetch_quote(client, &normalized).await {
|
|
62
|
+
Ok(quote) => sessions.push(quote_to_point(
|
|
63
|
+
quote,
|
|
64
|
+
"Robinhood extended cross-check",
|
|
65
|
+
timezone,
|
|
66
|
+
Some("Robinhood public quote cross-check".to_string()),
|
|
67
|
+
)),
|
|
68
|
+
Err(error) => {
|
|
69
|
+
errors.insert("robinhood".to_string(), format!("{error:#}"));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let proxy = if let Some(proxy_symbol) = proxy_symbol {
|
|
75
|
+
match binance_futures::fetch_quote(client, proxy_symbol).await {
|
|
76
|
+
Ok(quote) => Some(quote_to_point(
|
|
77
|
+
quote,
|
|
78
|
+
"Binance proxy price",
|
|
79
|
+
timezone,
|
|
80
|
+
Some("Proxy price is for price discovery and sentiment monitoring; it is not the stock or legal-equity price".to_string()),
|
|
81
|
+
)),
|
|
82
|
+
Err(error) => {
|
|
83
|
+
errors.insert(
|
|
84
|
+
format!("binance-futures:{proxy_symbol}"),
|
|
85
|
+
format!("{error:#}"),
|
|
86
|
+
);
|
|
87
|
+
None
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
None
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
let regular_basis = regular_basis(&sessions);
|
|
95
|
+
let current = choose_current(&sessions, mode).cloned();
|
|
96
|
+
|
|
97
|
+
PriceSummary {
|
|
98
|
+
symbol: normalized,
|
|
99
|
+
timezone: timezone.to_string(),
|
|
100
|
+
fetched_at_utc,
|
|
101
|
+
fetched_at_local,
|
|
102
|
+
current,
|
|
103
|
+
regular_basis,
|
|
104
|
+
sessions,
|
|
105
|
+
proxy,
|
|
106
|
+
errors,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
pub fn quote_to_point(
|
|
111
|
+
quote: Quote,
|
|
112
|
+
label: &str,
|
|
113
|
+
timezone: &str,
|
|
114
|
+
note: Option<String>,
|
|
115
|
+
) -> PricePoint {
|
|
116
|
+
PricePoint {
|
|
117
|
+
label: label.to_string(),
|
|
118
|
+
symbol: quote.symbol,
|
|
119
|
+
price: Some(quote.price),
|
|
120
|
+
currency: quote.currency,
|
|
121
|
+
provider: quote.provider,
|
|
122
|
+
session: quote.session,
|
|
123
|
+
market_time_local: utc_to_local(quote.market_time.as_deref(), timezone),
|
|
124
|
+
market_time_utc: quote.market_time,
|
|
125
|
+
change_pct: quote
|
|
126
|
+
.change_pct
|
|
127
|
+
.or_else(|| change_pct(quote.price, quote.previous_close)),
|
|
128
|
+
previous_close: quote.previous_close,
|
|
129
|
+
open: quote.open,
|
|
130
|
+
high: quote.high,
|
|
131
|
+
low: quote.low,
|
|
132
|
+
volume: quote.volume,
|
|
133
|
+
exchange: quote.exchange,
|
|
134
|
+
note,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fn choose_current(sessions: &[PricePoint], mode: SessionMode) -> Option<&PricePoint> {
|
|
139
|
+
match mode {
|
|
140
|
+
SessionMode::Regular => sessions
|
|
141
|
+
.iter()
|
|
142
|
+
.find(|point| has_session(point, SESSION_REGULAR)),
|
|
143
|
+
SessionMode::Extended => sessions
|
|
144
|
+
.iter()
|
|
145
|
+
.filter(|point| {
|
|
146
|
+
has_session(point, SESSION_PRE)
|
|
147
|
+
|| has_session(point, SESSION_POST)
|
|
148
|
+
|| has_session(point, SESSION_EXTENDED)
|
|
149
|
+
})
|
|
150
|
+
.max_by_key(|point| point_time(point))
|
|
151
|
+
.or_else(|| {
|
|
152
|
+
sessions
|
|
153
|
+
.iter()
|
|
154
|
+
.find(|point| has_session(point, SESSION_REGULAR))
|
|
155
|
+
}),
|
|
156
|
+
SessionMode::Overnight => sessions
|
|
157
|
+
.iter()
|
|
158
|
+
.find(|point| has_session(point, SESSION_OVERNIGHT))
|
|
159
|
+
.or_else(|| choose_current(sessions, SessionMode::Extended)),
|
|
160
|
+
SessionMode::Smart | SessionMode::All => sessions
|
|
161
|
+
.iter()
|
|
162
|
+
.max_by_key(|point| point_time(point))
|
|
163
|
+
.or_else(|| {
|
|
164
|
+
[
|
|
165
|
+
SESSION_OVERNIGHT,
|
|
166
|
+
SESSION_POST,
|
|
167
|
+
SESSION_PRE,
|
|
168
|
+
SESSION_EXTENDED,
|
|
169
|
+
SESSION_REGULAR,
|
|
170
|
+
]
|
|
171
|
+
.iter()
|
|
172
|
+
.find_map(|session| sessions.iter().find(|point| has_session(point, session)))
|
|
173
|
+
}),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fn regular_basis(sessions: &[PricePoint]) -> RegularBasis {
|
|
178
|
+
let regular = sessions
|
|
179
|
+
.iter()
|
|
180
|
+
.find(|point| has_session(point, SESSION_REGULAR));
|
|
181
|
+
let fallback = sessions.first();
|
|
182
|
+
let source = regular.or(fallback);
|
|
183
|
+
RegularBasis {
|
|
184
|
+
previous_close: source.and_then(|point| point.previous_close),
|
|
185
|
+
open: source.and_then(|point| point.open),
|
|
186
|
+
high: source.and_then(|point| point.high),
|
|
187
|
+
low: source.and_then(|point| point.low),
|
|
188
|
+
volume: source.and_then(|point| point.volume),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fn has_session(point: &PricePoint, expected: &str) -> bool {
|
|
193
|
+
point
|
|
194
|
+
.session
|
|
195
|
+
.as_deref()
|
|
196
|
+
.map(|session| session.eq_ignore_ascii_case(expected))
|
|
197
|
+
.unwrap_or(false)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fn point_time(point: &PricePoint) -> i64 {
|
|
201
|
+
point
|
|
202
|
+
.market_time_utc
|
|
203
|
+
.as_deref()
|
|
204
|
+
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
|
205
|
+
.map(|value| value.with_timezone(&Utc).timestamp())
|
|
206
|
+
.unwrap_or(0)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#[cfg(test)]
|
|
210
|
+
mod tests {
|
|
211
|
+
use super::*;
|
|
212
|
+
|
|
213
|
+
fn point(label: &str, session: &str, utc: &str, price: f64) -> PricePoint {
|
|
214
|
+
PricePoint {
|
|
215
|
+
label: label.to_string(),
|
|
216
|
+
symbol: "CRDO".to_string(),
|
|
217
|
+
price: Some(price),
|
|
218
|
+
currency: Some("USD".to_string()),
|
|
219
|
+
provider: "fixture".to_string(),
|
|
220
|
+
session: Some(session.to_string()),
|
|
221
|
+
market_time_utc: Some(utc.to_string()),
|
|
222
|
+
market_time_local: None,
|
|
223
|
+
change_pct: None,
|
|
224
|
+
previous_close: Some(200.0),
|
|
225
|
+
open: None,
|
|
226
|
+
high: None,
|
|
227
|
+
low: None,
|
|
228
|
+
volume: None,
|
|
229
|
+
exchange: None,
|
|
230
|
+
note: None,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn smart_mode_uses_latest_observable_session_not_fixed_priority() {
|
|
236
|
+
let sessions = vec![
|
|
237
|
+
point("Regular", "regular", "2026-06-01T20:00:00Z", 226.1),
|
|
238
|
+
point("Overnight", "overnight", "2026-06-02T07:00:00Z", 206.5),
|
|
239
|
+
];
|
|
240
|
+
let current = choose_current(&sessions, SessionMode::Smart).unwrap();
|
|
241
|
+
assert_eq!(current.session.as_deref(), Some("overnight"));
|
|
242
|
+
assert_eq!(current.price, Some(206.5));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#[test]
|
|
246
|
+
fn regular_mode_ignores_later_overnight_quote() {
|
|
247
|
+
let sessions = vec![
|
|
248
|
+
point("Regular", "regular", "2026-06-01T20:00:00Z", 226.1),
|
|
249
|
+
point("Overnight", "overnight", "2026-06-02T07:00:00Z", 206.5),
|
|
250
|
+
];
|
|
251
|
+
let current = choose_current(&sessions, SessionMode::Regular).unwrap();
|
|
252
|
+
assert_eq!(current.session.as_deref(), Some("regular"));
|
|
253
|
+
assert_eq!(current.price, Some(226.1));
|
|
254
|
+
}
|
|
255
|
+
}
|