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,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
+ }