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,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", "es_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(×tamp)
|
|
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
|
+
}
|