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,836 @@
1
+ use anyhow::{Context, Result, anyhow};
2
+ use serde::Deserialize;
3
+ use serde::de::DeserializeOwned;
4
+ use serde_json::Value;
5
+ use url::Url;
6
+ use wreq::{Client, header::ACCEPT};
7
+
8
+ use crate::history::apply_history_adjustment_and_repair;
9
+ use crate::http::{change_pct, timestamp_sec_to_utc, utc_now};
10
+ use crate::model::{
11
+ HistoryBatch, OhlcBar, PricePoint, Quote, SESSION_EXTENDED, SESSION_OVERNIGHT, SESSION_POST,
12
+ SESSION_PRE, SESSION_REGULAR,
13
+ };
14
+ use crate::providers::HistoryRequest;
15
+ use crate::time::utc_to_local;
16
+
17
+ const YAHOO_COOKIE_URL: &str = "https://fc.yahoo.com/consent";
18
+ const YAHOO_CRUMB_URL: &str = "https://query1.finance.yahoo.com/v1/test/getcrumb";
19
+ const YAHOO_QUOTE_V7_URL: &str = "https://query1.finance.yahoo.com/v7/finance/quote";
20
+ const YAHOO_QUOTE_SUMMARY_BASE_URL: &str =
21
+ "https://query2.finance.yahoo.com/v10/finance/quoteSummary";
22
+ const YAHOO_OPTIONS_BASE_URL: &str = "https://query2.finance.yahoo.com/v7/finance/options";
23
+ const YAHOO_SEARCH_URL: &str = "https://query1.finance.yahoo.com/v1/finance/search";
24
+ const YAHOO_SCREENER_URL: &str =
25
+ "https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved";
26
+
27
+ #[derive(Debug, Deserialize)]
28
+ struct YahooResponse {
29
+ chart: YahooChart,
30
+ }
31
+
32
+ #[derive(Debug, Deserialize)]
33
+ struct YahooChart {
34
+ result: Option<Vec<YahooResult>>,
35
+ error: Option<serde_json::Value>,
36
+ }
37
+
38
+ #[derive(Debug, Deserialize)]
39
+ struct YahooResult {
40
+ meta: YahooMeta,
41
+ timestamp: Option<Vec<i64>>,
42
+ indicators: Option<YahooIndicators>,
43
+ events: Option<YahooEvents>,
44
+ }
45
+
46
+ #[derive(Debug, Deserialize)]
47
+ #[serde(rename_all = "camelCase")]
48
+ struct YahooMeta {
49
+ currency: Option<String>,
50
+ exchange_name: Option<String>,
51
+ regular_market_price: Option<f64>,
52
+ regular_market_time: Option<i64>,
53
+ previous_close: Option<f64>,
54
+ chart_previous_close: Option<f64>,
55
+ regular_market_previous_close: Option<f64>,
56
+ current_trading_period: Option<YahooCurrentTradingPeriod>,
57
+ }
58
+
59
+ #[derive(Debug, Deserialize)]
60
+ struct YahooCurrentTradingPeriod {
61
+ pre: Option<YahooTradingPeriod>,
62
+ regular: Option<YahooTradingPeriod>,
63
+ post: Option<YahooTradingPeriod>,
64
+ }
65
+
66
+ #[derive(Debug, Deserialize)]
67
+ struct YahooTradingPeriod {
68
+ start: Option<i64>,
69
+ end: Option<i64>,
70
+ }
71
+
72
+ #[derive(Debug, Deserialize)]
73
+ struct YahooIndicators {
74
+ quote: Option<Vec<YahooQuoteBlock>>,
75
+ adjclose: Option<Vec<YahooAdjCloseBlock>>,
76
+ }
77
+
78
+ #[derive(Debug, Deserialize)]
79
+ struct YahooAdjCloseBlock {
80
+ adjclose: Option<Vec<Option<f64>>>,
81
+ }
82
+
83
+ #[derive(Debug, Deserialize)]
84
+ struct YahooQuoteBlock {
85
+ open: Option<Vec<Option<f64>>>,
86
+ high: Option<Vec<Option<f64>>>,
87
+ low: Option<Vec<Option<f64>>>,
88
+ close: Option<Vec<Option<f64>>>,
89
+ volume: Option<Vec<Option<u64>>>,
90
+ }
91
+
92
+ #[derive(Debug, Deserialize)]
93
+ #[serde(rename_all = "camelCase")]
94
+ struct YahooEvents {
95
+ dividends: Option<std::collections::BTreeMap<String, YahooDividendEvent>>,
96
+ splits: Option<std::collections::BTreeMap<String, YahooSplitEvent>>,
97
+ capital_gains: Option<std::collections::BTreeMap<String, YahooCapitalGainEvent>>,
98
+ }
99
+
100
+ #[derive(Debug, Deserialize)]
101
+ struct YahooDividendEvent {
102
+ amount: Option<f64>,
103
+ date: Option<i64>,
104
+ }
105
+
106
+ #[derive(Debug, Deserialize)]
107
+ #[serde(rename_all = "camelCase")]
108
+ struct YahooSplitEvent {
109
+ date: Option<i64>,
110
+ numerator: Option<f64>,
111
+ denominator: Option<f64>,
112
+ }
113
+
114
+ #[derive(Debug, Deserialize)]
115
+ struct YahooCapitalGainEvent {
116
+ amount: Option<f64>,
117
+ date: Option<i64>,
118
+ }
119
+
120
+ #[derive(Debug, Deserialize)]
121
+ struct YahooV7Envelope {
122
+ #[serde(rename = "quoteResponse")]
123
+ quote_response: YahooV7QuoteResponse,
124
+ }
125
+
126
+ #[derive(Debug, Deserialize)]
127
+ struct YahooV7QuoteResponse {
128
+ result: Option<Vec<YahooV7QuoteNode>>,
129
+ error: Option<serde_json::Value>,
130
+ }
131
+
132
+ #[derive(Debug, Deserialize)]
133
+ #[serde(rename_all = "camelCase")]
134
+ struct YahooV7QuoteNode {
135
+ symbol: Option<String>,
136
+ currency: Option<String>,
137
+ full_exchange_name: Option<String>,
138
+ exchange: Option<String>,
139
+ regular_market_price: Option<f64>,
140
+ regular_market_time: Option<i64>,
141
+ regular_market_previous_close: Option<f64>,
142
+ regular_market_open: Option<f64>,
143
+ regular_market_day_high: Option<f64>,
144
+ regular_market_day_low: Option<f64>,
145
+ regular_market_volume: Option<u64>,
146
+ pre_market_price: Option<f64>,
147
+ pre_market_time: Option<i64>,
148
+ pre_market_change_percent: Option<f64>,
149
+ post_market_price: Option<f64>,
150
+ post_market_time: Option<i64>,
151
+ post_market_change_percent: Option<f64>,
152
+ overnight_market_price: Option<f64>,
153
+ overnight_market_time: Option<i64>,
154
+ overnight_market_change_percent: Option<f64>,
155
+ }
156
+
157
+ pub async fn fetch_quote(client: &Client, symbol: &str) -> Result<Quote> {
158
+ let provider_symbol = symbol.to_uppercase();
159
+ let url = format!(
160
+ "https://query1.finance.yahoo.com/v8/finance/chart/{provider_symbol}?range=1d&interval=1m"
161
+ );
162
+ let response: YahooResponse = client
163
+ .get(url)
164
+ .send()
165
+ .await
166
+ .context("Yahoo request failed")?
167
+ .error_for_status()
168
+ .context("Yahoo returned HTTP error")?
169
+ .json()
170
+ .await
171
+ .context("Yahoo JSON parse failed")?;
172
+
173
+ let result = yahoo_result(response)?;
174
+ let meta = result.meta;
175
+ let price = meta
176
+ .regular_market_price
177
+ .or_else(|| {
178
+ result
179
+ .indicators
180
+ .as_ref()
181
+ .and_then(|indicators| indicators.quote.as_ref())
182
+ .and_then(|blocks| blocks.first())
183
+ .and_then(|block| block.close.as_ref())
184
+ .and_then(|closes| closes.iter().rev().flatten().next().copied())
185
+ })
186
+ .ok_or_else(|| anyhow!("Yahoo response missing usable price"))?;
187
+ let previous_close = meta
188
+ .regular_market_previous_close
189
+ .or(meta.previous_close)
190
+ .or(meta.chart_previous_close);
191
+
192
+ Ok(Quote {
193
+ symbol: symbol.to_string(),
194
+ price,
195
+ currency: meta.currency,
196
+ provider: "yahoo".to_string(),
197
+ session: Some("regular".to_string()),
198
+ fetched_at_utc: utc_now(),
199
+ market_time: meta.regular_market_time.and_then(timestamp_sec_to_utc),
200
+ previous_close,
201
+ open: None,
202
+ high: None,
203
+ low: None,
204
+ volume: None,
205
+ exchange: meta.exchange_name,
206
+ provider_symbol: Some(provider_symbol),
207
+ change_pct: change_pct(price, previous_close),
208
+ })
209
+ }
210
+
211
+ pub async fn fetch_extended_quote(client: &Client, symbol: &str) -> Result<Quote> {
212
+ let provider_symbol = symbol.to_uppercase();
213
+ let url = format!(
214
+ "https://query1.finance.yahoo.com/v8/finance/chart/{provider_symbol}?range=2d&interval=1m&includePrePost=true"
215
+ );
216
+ let response: YahooResponse = client
217
+ .get(url)
218
+ .send()
219
+ .await
220
+ .context("Yahoo extended request failed")?
221
+ .error_for_status()
222
+ .context("Yahoo extended returned HTTP error")?
223
+ .json()
224
+ .await
225
+ .context("Yahoo extended JSON parse failed")?;
226
+
227
+ let result = yahoo_result(response)?;
228
+ let meta = result.meta;
229
+ let timestamps = result.timestamp;
230
+ let quote_block = result
231
+ .indicators
232
+ .as_ref()
233
+ .and_then(|indicators| indicators.quote.as_ref())
234
+ .and_then(|blocks| blocks.first())
235
+ .ok_or_else(|| anyhow!("Yahoo extended response missing quote block"))?;
236
+ let (index, price) =
237
+ last_close_index(quote_block).ok_or_else(|| anyhow!("Yahoo extended missing close"))?;
238
+ let market_timestamp = timestamps
239
+ .as_ref()
240
+ .and_then(|timestamps| timestamps.get(index))
241
+ .copied();
242
+ let previous_close = meta
243
+ .regular_market_previous_close
244
+ .or(meta.previous_close)
245
+ .or(meta.chart_previous_close);
246
+ let session = market_timestamp
247
+ .map(|timestamp| classify_session(&meta, timestamp))
248
+ .unwrap_or_else(|| "extended".to_string());
249
+ let market_time = market_timestamp
250
+ .and_then(timestamp_sec_to_utc)
251
+ .or_else(|| meta.regular_market_time.and_then(timestamp_sec_to_utc));
252
+
253
+ Ok(Quote {
254
+ symbol: symbol.to_uppercase(),
255
+ price,
256
+ currency: meta.currency,
257
+ provider: "yahoo-extended".to_string(),
258
+ session: Some(session),
259
+ fetched_at_utc: utc_now(),
260
+ market_time,
261
+ previous_close,
262
+ open: option_at_f64(quote_block.open.as_ref(), index),
263
+ high: option_at_f64(quote_block.high.as_ref(), index),
264
+ low: option_at_f64(quote_block.low.as_ref(), index),
265
+ volume: option_at_u64(quote_block.volume.as_ref(), index),
266
+ exchange: meta.exchange_name,
267
+ provider_symbol: Some(provider_symbol),
268
+ change_pct: change_pct(price, previous_close),
269
+ })
270
+ }
271
+
272
+ pub async fn fetch_session_points(
273
+ client: &Client,
274
+ symbol: &str,
275
+ timezone: &str,
276
+ ) -> Result<Vec<PricePoint>> {
277
+ let provider_symbol = symbol.to_uppercase();
278
+ let response = fetch_yahoo_v7_quote(client, &provider_symbol)
279
+ .await
280
+ .with_context(|| format!("Yahoo session request failed for {provider_symbol}"))?;
281
+ let node = yahoo_v7_result(response)?;
282
+ let symbol = node
283
+ .symbol
284
+ .clone()
285
+ .unwrap_or_else(|| provider_symbol.clone());
286
+ let currency = node.currency.clone();
287
+ let exchange = node.full_exchange_name.clone().or(node.exchange.clone());
288
+ let previous_close = node.regular_market_previous_close;
289
+ let mut points = Vec::new();
290
+
291
+ push_session_point(
292
+ &mut points,
293
+ SessionPointInput {
294
+ label: "Regular",
295
+ symbol: &symbol,
296
+ price: node.regular_market_price,
297
+ currency: currency.clone(),
298
+ session: SESSION_REGULAR,
299
+ market_time: node.regular_market_time,
300
+ change_pct_value: change_pct(
301
+ node.regular_market_price.unwrap_or_default(),
302
+ previous_close,
303
+ ),
304
+ previous_close,
305
+ open: node.regular_market_open,
306
+ high: node.regular_market_day_high,
307
+ low: node.regular_market_day_low,
308
+ volume: node.regular_market_volume,
309
+ exchange: exchange.clone(),
310
+ timezone,
311
+ note: "Yahoo regular market",
312
+ },
313
+ );
314
+ push_session_point(
315
+ &mut points,
316
+ SessionPointInput {
317
+ label: "Premarket",
318
+ symbol: &symbol,
319
+ price: node.pre_market_price,
320
+ currency: currency.clone(),
321
+ session: SESSION_PRE,
322
+ market_time: node.pre_market_time,
323
+ change_pct_value: node.pre_market_change_percent,
324
+ previous_close,
325
+ open: node.regular_market_open,
326
+ high: node.regular_market_day_high,
327
+ low: node.regular_market_day_low,
328
+ volume: node.regular_market_volume,
329
+ exchange: exchange.clone(),
330
+ timezone,
331
+ note: "Yahoo pre-market",
332
+ },
333
+ );
334
+ push_session_point(
335
+ &mut points,
336
+ SessionPointInput {
337
+ label: "Postmarket",
338
+ symbol: &symbol,
339
+ price: node.post_market_price,
340
+ currency: currency.clone(),
341
+ session: SESSION_POST,
342
+ market_time: node.post_market_time,
343
+ change_pct_value: node.post_market_change_percent,
344
+ previous_close,
345
+ open: node.regular_market_open,
346
+ high: node.regular_market_day_high,
347
+ low: node.regular_market_day_low,
348
+ volume: node.regular_market_volume,
349
+ exchange: exchange.clone(),
350
+ timezone,
351
+ note: "Yahoo post-market",
352
+ },
353
+ );
354
+ push_session_point(
355
+ &mut points,
356
+ SessionPointInput {
357
+ label: "Overnight",
358
+ symbol: &symbol,
359
+ price: node.overnight_market_price,
360
+ currency,
361
+ session: SESSION_OVERNIGHT,
362
+ market_time: node.overnight_market_time,
363
+ change_pct_value: node.overnight_market_change_percent,
364
+ previous_close,
365
+ open: node.regular_market_open,
366
+ high: node.regular_market_day_high,
367
+ low: node.regular_market_day_low,
368
+ volume: node.regular_market_volume,
369
+ exchange,
370
+ timezone,
371
+ note: "Yahoo BOATS overnight",
372
+ },
373
+ );
374
+
375
+ Ok(points)
376
+ }
377
+
378
+ pub async fn fetch_history(client: &Client, request: &HistoryRequest) -> Result<HistoryBatch> {
379
+ fetch_history_inner(client, request, false, "yahoo").await
380
+ }
381
+
382
+ pub async fn fetch_quote_summary(client: &Client, symbol: &str, modules: &[&str]) -> Result<Value> {
383
+ let provider_symbol = symbol.to_uppercase();
384
+ let mut url = Url::parse(&format!("{YAHOO_QUOTE_SUMMARY_BASE_URL}/{provider_symbol}"))
385
+ .context("invalid Yahoo quoteSummary URL")?;
386
+ url.query_pairs_mut()
387
+ .append_pair("modules", &modules.join(","))
388
+ .append_pair("formatted", "false")
389
+ .append_pair("lang", "en-US")
390
+ .append_pair("region", "US")
391
+ .append_pair("corsDomain", "finance.yahoo.com");
392
+ fetch_json_with_crumb_retry(client, url.as_str(), "Yahoo quoteSummary").await
393
+ }
394
+
395
+ pub async fn fetch_options(client: &Client, symbol: &str, expiry: Option<i64>) -> Result<Value> {
396
+ let provider_symbol = symbol.to_uppercase();
397
+ let mut url = Url::parse(&format!("{YAHOO_OPTIONS_BASE_URL}/{provider_symbol}"))
398
+ .context("invalid Yahoo options URL")?;
399
+ if let Some(expiry) = expiry {
400
+ url.query_pairs_mut()
401
+ .append_pair("date", &expiry.to_string());
402
+ }
403
+ fetch_json_with_crumb_retry(client, url.as_str(), "Yahoo options").await
404
+ }
405
+
406
+ pub async fn fetch_search(
407
+ client: &Client,
408
+ query: &str,
409
+ quotes_count: usize,
410
+ news_count: usize,
411
+ ) -> Result<Value> {
412
+ let mut url = Url::parse(YAHOO_SEARCH_URL).context("invalid Yahoo search URL")?;
413
+ url.query_pairs_mut()
414
+ .append_pair("q", query)
415
+ .append_pair("quotesCount", &quotes_count.clamp(0, 50).to_string())
416
+ .append_pair("newsCount", &news_count.clamp(0, 50).to_string())
417
+ .append_pair("enableFuzzyQuery", "false")
418
+ .append_pair("quotesQueryId", "tss_match_phrase_query")
419
+ .append_pair("newsQueryId", "news_cie_vespa")
420
+ .append_pair("lang", "en-US")
421
+ .append_pair("region", "US");
422
+ fetch_json_with_crumb_retry(client, url.as_str(), "Yahoo search").await
423
+ }
424
+
425
+ pub async fn fetch_screen(client: &Client, screener: &str, count: usize) -> Result<Value> {
426
+ let mut url = Url::parse(YAHOO_SCREENER_URL).context("invalid Yahoo screener URL")?;
427
+ url.query_pairs_mut()
428
+ .append_pair("scrIds", screener)
429
+ .append_pair("count", &count.clamp(1, 250).to_string())
430
+ .append_pair("formatted", "false")
431
+ .append_pair("lang", "en-US")
432
+ .append_pair("region", "US");
433
+ fetch_json_with_crumb_retry(client, url.as_str(), "Yahoo screener").await
434
+ }
435
+
436
+ pub async fn fetch_extended_history(
437
+ client: &Client,
438
+ request: &HistoryRequest,
439
+ ) -> Result<HistoryBatch> {
440
+ fetch_history_inner(client, request, true, "yahoo-extended").await
441
+ }
442
+
443
+ async fn fetch_history_inner(
444
+ client: &Client,
445
+ request: &HistoryRequest,
446
+ include_prepost: bool,
447
+ provider: &str,
448
+ ) -> Result<HistoryBatch> {
449
+ let provider_symbol = request.symbol.to_uppercase();
450
+ let include_prepost = if include_prepost {
451
+ "&includePrePost=true"
452
+ } else {
453
+ ""
454
+ };
455
+ let events = if request.actions {
456
+ "&events=div%2Csplits%2CcapitalGains"
457
+ } else {
458
+ ""
459
+ };
460
+ let url = format!(
461
+ "https://query1.finance.yahoo.com/v8/finance/chart/{provider_symbol}?range={range}&interval={interval}{include_prepost}{events}&includeAdjustedClose=true",
462
+ range = request.range,
463
+ interval = request.interval,
464
+ );
465
+ let response: YahooResponse = client
466
+ .get(url)
467
+ .send()
468
+ .await
469
+ .context("Yahoo history request failed")?
470
+ .error_for_status()
471
+ .context("Yahoo history returned HTTP error")?
472
+ .json()
473
+ .await
474
+ .context("Yahoo history JSON parse failed")?;
475
+ let result = yahoo_result(response)?;
476
+ let timestamps = result
477
+ .timestamp
478
+ .ok_or_else(|| anyhow!("Yahoo history missing timestamps"))?;
479
+ let events = result.events;
480
+ let quote_block = result
481
+ .indicators
482
+ .as_ref()
483
+ .and_then(|indicators| indicators.quote.as_ref())
484
+ .and_then(|blocks| blocks.first())
485
+ .ok_or_else(|| anyhow!("Yahoo history missing quote block"))?;
486
+ let adjclose_block = result
487
+ .indicators
488
+ .as_ref()
489
+ .and_then(|indicators| indicators.adjclose.as_ref())
490
+ .and_then(|blocks| blocks.first());
491
+ let action_index = request
492
+ .actions
493
+ .then(|| ActionIndex::from_events(events.as_ref()));
494
+
495
+ let mut bars: Vec<OhlcBar> = timestamps
496
+ .iter()
497
+ .enumerate()
498
+ .filter_map(|(index, timestamp)| {
499
+ let close = option_at_f64(quote_block.close.as_ref(), index)?;
500
+ let open_time = timestamp_sec_to_utc(*timestamp)?;
501
+ let action = action_index
502
+ .as_ref()
503
+ .and_then(|index| index.values(*timestamp));
504
+ Some(OhlcBar {
505
+ symbol: request.symbol.to_uppercase(),
506
+ provider: provider.to_string(),
507
+ open_time,
508
+ close_time: None,
509
+ open: option_at_f64(quote_block.open.as_ref(), index),
510
+ high: option_at_f64(quote_block.high.as_ref(), index),
511
+ low: option_at_f64(quote_block.low.as_ref(), index),
512
+ close,
513
+ adj_close: adjclose_block
514
+ .and_then(|block| option_at_f64(block.adjclose.as_ref(), index)),
515
+ volume: option_at_u64(quote_block.volume.as_ref(), index).map(|value| value as f64),
516
+ quote_volume: None,
517
+ trades: None,
518
+ dividend: action.as_ref().and_then(|action| action.dividend),
519
+ stock_split: action.as_ref().and_then(|action| action.stock_split),
520
+ capital_gain: action.as_ref().and_then(|action| action.capital_gain),
521
+ repaired: false,
522
+ })
523
+ })
524
+ .rev()
525
+ .take(request.limit)
526
+ .collect::<Vec<_>>()
527
+ .into_iter()
528
+ .rev()
529
+ .collect();
530
+ let repair_applied =
531
+ apply_history_adjustment_and_repair(&mut bars, request.adjustment, request.repair);
532
+
533
+ Ok(HistoryBatch {
534
+ symbol: request.symbol.to_uppercase(),
535
+ provider: provider.to_string(),
536
+ interval: request.interval.clone(),
537
+ adjustment: request.adjustment.label().to_string(),
538
+ actions_included: request.actions,
539
+ repair_requested: request.repair,
540
+ repair_applied,
541
+ bars,
542
+ })
543
+ }
544
+
545
+ fn yahoo_result(response: YahooResponse) -> Result<YahooResult> {
546
+ if let Some(error) = response.chart.error {
547
+ return Err(anyhow!("Yahoo error: {error}"));
548
+ }
549
+ response
550
+ .chart
551
+ .result
552
+ .and_then(|mut results| {
553
+ if results.is_empty() {
554
+ None
555
+ } else {
556
+ Some(results.remove(0))
557
+ }
558
+ })
559
+ .ok_or_else(|| anyhow!("Yahoo response missing result"))
560
+ }
561
+
562
+ #[derive(Debug, Default, Clone)]
563
+ struct ActionValues {
564
+ dividend: Option<f64>,
565
+ stock_split: Option<f64>,
566
+ capital_gain: Option<f64>,
567
+ }
568
+
569
+ #[derive(Debug, Default)]
570
+ struct ActionIndex {
571
+ by_timestamp: std::collections::BTreeMap<i64, ActionValues>,
572
+ by_date: std::collections::BTreeMap<String, ActionValues>,
573
+ }
574
+
575
+ impl ActionIndex {
576
+ fn from_events(events: Option<&YahooEvents>) -> Self {
577
+ let mut index = Self::default();
578
+ let Some(events) = events else {
579
+ return index;
580
+ };
581
+
582
+ if let Some(dividends) = events.dividends.as_ref() {
583
+ for (key, event) in dividends {
584
+ let timestamp = event_timestamp(key, event.date);
585
+ index.update(timestamp, |values| values.dividend = event.amount);
586
+ }
587
+ }
588
+ if let Some(splits) = events.splits.as_ref() {
589
+ for (key, event) in splits {
590
+ let ratio = match (event.numerator, event.denominator) {
591
+ (Some(numerator), Some(denominator)) if denominator != 0.0 => {
592
+ Some(numerator / denominator)
593
+ }
594
+ _ => None,
595
+ };
596
+ let timestamp = event_timestamp(key, event.date);
597
+ index.update(timestamp, |values| values.stock_split = ratio);
598
+ }
599
+ }
600
+ if let Some(capital_gains) = events.capital_gains.as_ref() {
601
+ for (key, event) in capital_gains {
602
+ let timestamp = event_timestamp(key, event.date);
603
+ index.update(timestamp, |values| values.capital_gain = event.amount);
604
+ }
605
+ }
606
+
607
+ index
608
+ }
609
+
610
+ fn values(&self, timestamp: i64) -> Option<ActionValues> {
611
+ self.by_timestamp
612
+ .get(&timestamp)
613
+ .cloned()
614
+ .or_else(|| date_key(timestamp).and_then(|date| self.by_date.get(&date).cloned()))
615
+ .filter(|values| {
616
+ values.dividend.is_some()
617
+ || values.stock_split.is_some()
618
+ || values.capital_gain.is_some()
619
+ })
620
+ }
621
+
622
+ fn update<F>(&mut self, timestamp: Option<i64>, mut update: F)
623
+ where
624
+ F: FnMut(&mut ActionValues),
625
+ {
626
+ let Some(timestamp) = timestamp else {
627
+ return;
628
+ };
629
+ update(self.by_timestamp.entry(timestamp).or_default());
630
+ if let Some(date) = date_key(timestamp) {
631
+ update(self.by_date.entry(date).or_default());
632
+ }
633
+ }
634
+ }
635
+
636
+ fn event_timestamp(key: &str, date: Option<i64>) -> Option<i64> {
637
+ date.or_else(|| key.parse::<i64>().ok())
638
+ }
639
+
640
+ fn date_key(timestamp: i64) -> Option<String> {
641
+ timestamp_sec_to_utc(timestamp).and_then(|value| value.get(..10).map(str::to_string))
642
+ }
643
+
644
+ fn yahoo_v7_result(response: YahooV7Envelope) -> Result<YahooV7QuoteNode> {
645
+ if let Some(error) = response.quote_response.error {
646
+ return Err(anyhow!("Yahoo v7 error: {error}"));
647
+ }
648
+ response
649
+ .quote_response
650
+ .result
651
+ .and_then(|mut results| {
652
+ if results.is_empty() {
653
+ None
654
+ } else {
655
+ Some(results.remove(0))
656
+ }
657
+ })
658
+ .ok_or_else(|| anyhow!("Yahoo v7 response missing result"))
659
+ }
660
+
661
+ async fn fetch_yahoo_v7_quote(client: &Client, provider_symbol: &str) -> Result<YahooV7Envelope> {
662
+ let mut url = Url::parse(YAHOO_QUOTE_V7_URL).context("invalid Yahoo v7 URL")?;
663
+ {
664
+ let mut query = url.query_pairs_mut();
665
+ query.append_pair("symbols", provider_symbol);
666
+ query.append_pair("formatted", "false");
667
+ query.append_pair("lang", "en-US");
668
+ query.append_pair("region", "US");
669
+ query.append_pair("overnightPrice", "true");
670
+ }
671
+ fetch_json_with_crumb_retry(client, url.as_str(), "Yahoo v7").await
672
+ }
673
+
674
+ async fn fetch_yahoo_crumb(client: &Client) -> Result<String> {
675
+ let cookie_response = client
676
+ .get(YAHOO_COOKIE_URL)
677
+ .send()
678
+ .await
679
+ .context("Yahoo cookie request failed")?;
680
+ drop(cookie_response);
681
+
682
+ let response = client
683
+ .get(YAHOO_CRUMB_URL)
684
+ .send()
685
+ .await
686
+ .context("Yahoo crumb request failed")?;
687
+ let status = response.status();
688
+ let crumb = response
689
+ .text()
690
+ .await
691
+ .context("Yahoo crumb response text parse failed")?;
692
+ if !status.is_success() {
693
+ return Err(anyhow!("Yahoo crumb returned HTTP {status}: {crumb}"));
694
+ }
695
+ if crumb.is_empty() || crumb.contains('{') || crumb.contains('<') {
696
+ return Err(anyhow!("Yahoo crumb response was invalid: {crumb}"));
697
+ }
698
+ Ok(crumb)
699
+ }
700
+
701
+ async fn fetch_json_with_crumb_retry<T>(client: &Client, url: &str, label: &str) -> Result<T>
702
+ where
703
+ T: DeserializeOwned,
704
+ {
705
+ let (status, body) = request_json_text(client, url, label).await?;
706
+ if status.is_success() {
707
+ return serde_json::from_str(&body).with_context(|| format!("{label} JSON parse failed"));
708
+ }
709
+ if !matches!(status.as_u16(), 401 | 403 | 429) {
710
+ return Err(anyhow!("{label} returned HTTP {status}: {body}"));
711
+ }
712
+
713
+ let crumb = fetch_yahoo_crumb(client).await?;
714
+ let mut url = Url::parse(url).with_context(|| format!("invalid {label} URL"))?;
715
+ url.query_pairs_mut().append_pair("crumb", &crumb);
716
+ let (status, body) = request_json_text(client, url.as_str(), label).await?;
717
+ if !status.is_success() {
718
+ return Err(anyhow!(
719
+ "{label} returned HTTP {status} after crumb retry: {body}"
720
+ ));
721
+ }
722
+ serde_json::from_str(&body)
723
+ .with_context(|| format!("{label} JSON parse failed after crumb retry"))
724
+ }
725
+
726
+ async fn request_json_text(
727
+ client: &Client,
728
+ url: &str,
729
+ label: &str,
730
+ ) -> Result<(wreq::StatusCode, String)> {
731
+ let response = client
732
+ .get(url)
733
+ .header(ACCEPT, "application/json")
734
+ .send()
735
+ .await
736
+ .with_context(|| format!("{label} request failed"))?;
737
+ let status = response.status();
738
+ let body = response
739
+ .text()
740
+ .await
741
+ .with_context(|| format!("{label} response text parse failed"))?;
742
+ Ok((status, body))
743
+ }
744
+
745
+ fn option_at_f64(values: Option<&Vec<Option<f64>>>, index: usize) -> Option<f64> {
746
+ values
747
+ .and_then(|values| values.get(index))
748
+ .and_then(|value| *value)
749
+ }
750
+
751
+ fn option_at_u64(values: Option<&Vec<Option<u64>>>, index: usize) -> Option<u64> {
752
+ values
753
+ .and_then(|values| values.get(index))
754
+ .and_then(|value| *value)
755
+ }
756
+
757
+ fn last_close_index(block: &YahooQuoteBlock) -> Option<(usize, f64)> {
758
+ block
759
+ .close
760
+ .as_ref()?
761
+ .iter()
762
+ .enumerate()
763
+ .rev()
764
+ .find_map(|(index, close)| close.map(|close| (index, close)))
765
+ }
766
+
767
+ fn classify_session(meta: &YahooMeta, timestamp: i64) -> String {
768
+ let Some(periods) = meta.current_trading_period.as_ref() else {
769
+ return "extended".to_string();
770
+ };
771
+ for (name, period) in [
772
+ (SESSION_PRE, periods.pre.as_ref()),
773
+ (SESSION_REGULAR, periods.regular.as_ref()),
774
+ (SESSION_POST, periods.post.as_ref()),
775
+ ] {
776
+ if period_contains(period, timestamp) {
777
+ return name.to_string();
778
+ }
779
+ }
780
+ SESSION_EXTENDED.to_string()
781
+ }
782
+
783
+ fn period_contains(period: Option<&YahooTradingPeriod>, timestamp: i64) -> bool {
784
+ let Some(period) = period else {
785
+ return false;
786
+ };
787
+ match (period.start, period.end) {
788
+ (Some(start), Some(end)) => timestamp >= start && timestamp < end,
789
+ _ => false,
790
+ }
791
+ }
792
+
793
+ struct SessionPointInput<'a> {
794
+ label: &'static str,
795
+ symbol: &'a str,
796
+ price: Option<f64>,
797
+ currency: Option<String>,
798
+ session: &'static str,
799
+ market_time: Option<i64>,
800
+ change_pct_value: Option<f64>,
801
+ previous_close: Option<f64>,
802
+ open: Option<f64>,
803
+ high: Option<f64>,
804
+ low: Option<f64>,
805
+ volume: Option<u64>,
806
+ exchange: Option<String>,
807
+ timezone: &'a str,
808
+ note: &'static str,
809
+ }
810
+
811
+ fn push_session_point(points: &mut Vec<PricePoint>, input: SessionPointInput<'_>) {
812
+ let Some(price) = input.price else {
813
+ return;
814
+ };
815
+ let market_time_utc = input.market_time.and_then(timestamp_sec_to_utc);
816
+ points.push(PricePoint {
817
+ label: input.label.to_string(),
818
+ symbol: input.symbol.to_string(),
819
+ price: Some(price),
820
+ currency: input.currency,
821
+ provider: "yahoo-boats".to_string(),
822
+ session: Some(input.session.to_string()),
823
+ market_time_local: utc_to_local(market_time_utc.as_deref(), input.timezone),
824
+ market_time_utc,
825
+ change_pct: input
826
+ .change_pct_value
827
+ .or_else(|| change_pct(price, input.previous_close)),
828
+ previous_close: input.previous_close,
829
+ open: input.open,
830
+ high: input.high,
831
+ low: input.low,
832
+ volume: input.volume,
833
+ exchange: input.exchange,
834
+ note: Some(input.note.to_string()),
835
+ });
836
+ }