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,904 @@
|
|
|
1
|
+
use std::collections::VecDeque;
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::io::{Read, Write};
|
|
4
|
+
use std::path::{Path, PathBuf};
|
|
5
|
+
|
|
6
|
+
use anyhow::{Context, Result, anyhow};
|
|
7
|
+
use chrono::{Datelike, NaiveDate};
|
|
8
|
+
use scraper::{Html, Selector};
|
|
9
|
+
use serde::Deserialize;
|
|
10
|
+
use wreq::Client;
|
|
11
|
+
use zip::ZipArchive;
|
|
12
|
+
|
|
13
|
+
use crate::cli::{StooqAsset, StooqFrequency, StooqMarket};
|
|
14
|
+
use crate::http::{clean_text, parse_optional_f64, parse_optional_u64, utc_now};
|
|
15
|
+
use crate::model::{HistoryBatch, OhlcBar, Quote, StooqSyncReport};
|
|
16
|
+
|
|
17
|
+
#[path = "stooq/catalog.rs"]
|
|
18
|
+
mod catalog;
|
|
19
|
+
|
|
20
|
+
pub use catalog::catalog;
|
|
21
|
+
use catalog::{cached_zip_path, catalog_package};
|
|
22
|
+
|
|
23
|
+
#[derive(Debug, Deserialize)]
|
|
24
|
+
struct StooqQuoteRow {
|
|
25
|
+
#[serde(rename = "Date")]
|
|
26
|
+
date: Option<String>,
|
|
27
|
+
#[serde(rename = "Time")]
|
|
28
|
+
time: Option<String>,
|
|
29
|
+
#[serde(rename = "Open")]
|
|
30
|
+
open: Option<String>,
|
|
31
|
+
#[serde(rename = "High")]
|
|
32
|
+
high: Option<String>,
|
|
33
|
+
#[serde(rename = "Low")]
|
|
34
|
+
low: Option<String>,
|
|
35
|
+
#[serde(rename = "Close")]
|
|
36
|
+
close: Option<String>,
|
|
37
|
+
#[serde(rename = "Volume")]
|
|
38
|
+
volume: Option<String>,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#[derive(Debug, Deserialize)]
|
|
42
|
+
struct StooqHistoryRow {
|
|
43
|
+
#[serde(rename = "Date")]
|
|
44
|
+
date: String,
|
|
45
|
+
#[serde(rename = "Open")]
|
|
46
|
+
open: Option<String>,
|
|
47
|
+
#[serde(rename = "High")]
|
|
48
|
+
high: Option<String>,
|
|
49
|
+
#[serde(rename = "Low")]
|
|
50
|
+
low: Option<String>,
|
|
51
|
+
#[serde(rename = "Close")]
|
|
52
|
+
close: Option<String>,
|
|
53
|
+
#[serde(rename = "Volume")]
|
|
54
|
+
volume: Option<String>,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pub async fn fetch_quote(client: &Client, symbol: &str) -> Result<Quote> {
|
|
58
|
+
let provider_symbol = stooq_symbol(symbol);
|
|
59
|
+
let url = format!("https://stooq.com/q/l/?s={provider_symbol}&f=sd2t2ohlcv&h&e=csv");
|
|
60
|
+
let text = client
|
|
61
|
+
.get(url)
|
|
62
|
+
.header("Accept-Encoding", "identity")
|
|
63
|
+
.send()
|
|
64
|
+
.await
|
|
65
|
+
.context("Stooq request failed")?
|
|
66
|
+
.error_for_status()
|
|
67
|
+
.context("Stooq returned HTTP error")?
|
|
68
|
+
.text()
|
|
69
|
+
.await
|
|
70
|
+
.context("Stooq response text parse failed")?;
|
|
71
|
+
let mut reader = csv::Reader::from_reader(text.as_bytes());
|
|
72
|
+
let row: StooqQuoteRow = reader
|
|
73
|
+
.deserialize()
|
|
74
|
+
.next()
|
|
75
|
+
.ok_or_else(|| anyhow!("Stooq response missing rows"))?
|
|
76
|
+
.context("Stooq CSV parse failed")?;
|
|
77
|
+
let price = parse_optional_f64(row.close.as_deref())
|
|
78
|
+
.ok_or_else(|| anyhow!("Stooq response missing usable close"))?;
|
|
79
|
+
let market_time = match (
|
|
80
|
+
clean_text(row.date.as_deref()),
|
|
81
|
+
clean_text(row.time.as_deref()),
|
|
82
|
+
) {
|
|
83
|
+
(Some(date), Some(time)) => Some(format!("{date} {time}")),
|
|
84
|
+
(Some(date), None) => Some(date.to_string()),
|
|
85
|
+
_ => None,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
Ok(Quote {
|
|
89
|
+
symbol: symbol.to_uppercase(),
|
|
90
|
+
price,
|
|
91
|
+
currency: provider_symbol.ends_with(".us").then(|| "USD".to_string()),
|
|
92
|
+
provider: "stooq".to_string(),
|
|
93
|
+
session: Some("regular".to_string()),
|
|
94
|
+
fetched_at_utc: utc_now(),
|
|
95
|
+
market_time,
|
|
96
|
+
previous_close: None,
|
|
97
|
+
open: parse_optional_f64(row.open.as_deref()),
|
|
98
|
+
high: parse_optional_f64(row.high.as_deref()),
|
|
99
|
+
low: parse_optional_f64(row.low.as_deref()),
|
|
100
|
+
volume: parse_optional_u64(row.volume.as_deref()),
|
|
101
|
+
exchange: None,
|
|
102
|
+
provider_symbol: Some(provider_symbol),
|
|
103
|
+
change_pct: None,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
pub async fn fetch_history(
|
|
108
|
+
client: &Client,
|
|
109
|
+
symbol: &str,
|
|
110
|
+
interval: &str,
|
|
111
|
+
limit: usize,
|
|
112
|
+
stooq_market: StooqMarket,
|
|
113
|
+
stooq_asset: StooqAsset,
|
|
114
|
+
) -> Result<HistoryBatch> {
|
|
115
|
+
if matches!(interval, "5m" | "5minute" | "1h" | "hour" | "60m") {
|
|
116
|
+
return fetch_bulk_history(symbol, interval, limit, stooq_market, stooq_asset);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let provider_symbol = stooq_symbol(symbol);
|
|
120
|
+
let interval_code = stooq_interval(interval)?;
|
|
121
|
+
let api_key = std::env::var("STOOQ_API_KEY")
|
|
122
|
+
.ok()
|
|
123
|
+
.filter(|value| !value.trim().is_empty());
|
|
124
|
+
let bars = match api_key.as_deref() {
|
|
125
|
+
Some(api_key) => match fetch_live_csv_history(
|
|
126
|
+
client,
|
|
127
|
+
symbol,
|
|
128
|
+
&provider_symbol,
|
|
129
|
+
interval_code,
|
|
130
|
+
limit,
|
|
131
|
+
Some(api_key),
|
|
132
|
+
)
|
|
133
|
+
.await
|
|
134
|
+
{
|
|
135
|
+
Ok(bars) => bars,
|
|
136
|
+
Err(error) if is_stooq_csv_auth_error(&error) => {
|
|
137
|
+
fetch_live_html_history(client, symbol, &provider_symbol, interval_code, limit)
|
|
138
|
+
.await?
|
|
139
|
+
}
|
|
140
|
+
Err(error) => return Err(error),
|
|
141
|
+
},
|
|
142
|
+
None => {
|
|
143
|
+
fetch_live_html_history(client, symbol, &provider_symbol, interval_code, limit).await?
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
Ok(live_history_batch(symbol, interval, bars))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pub struct StooqSyncRequest {
|
|
151
|
+
pub frequency: StooqFrequency,
|
|
152
|
+
pub market: StooqMarket,
|
|
153
|
+
pub asset: StooqAsset,
|
|
154
|
+
pub url: Option<String>,
|
|
155
|
+
pub zip_path: Option<PathBuf>,
|
|
156
|
+
pub force: bool,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
pub async fn sync_bulk(client: &Client, request: StooqSyncRequest) -> Result<StooqSyncReport> {
|
|
160
|
+
let package = catalog_package(request.frequency, request.market, request.asset)
|
|
161
|
+
.ok_or_else(|| anyhow!("Stooq catalog does not list this frequency/market/asset combo"))?;
|
|
162
|
+
let cache_key = package.cache_key();
|
|
163
|
+
let target = cached_zip_path(&cache_key)?;
|
|
164
|
+
if target.exists() && !request.force {
|
|
165
|
+
return Err(anyhow!(
|
|
166
|
+
"Stooq cache already exists at {}; pass --force to overwrite",
|
|
167
|
+
target.display()
|
|
168
|
+
));
|
|
169
|
+
}
|
|
170
|
+
if let Some(parent) = target.parent() {
|
|
171
|
+
fs::create_dir_all(parent).with_context(|| {
|
|
172
|
+
format!(
|
|
173
|
+
"failed to create Stooq cache directory {}",
|
|
174
|
+
parent.display()
|
|
175
|
+
)
|
|
176
|
+
})?;
|
|
177
|
+
}
|
|
178
|
+
let temp = target.with_extension("zip.tmp");
|
|
179
|
+
if temp.exists() {
|
|
180
|
+
fs::remove_file(&temp)
|
|
181
|
+
.with_context(|| format!("failed to remove stale temp ZIP {}", temp.display()))?;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let (bytes, source) = match (request.url.as_deref(), request.zip_path.as_deref()) {
|
|
185
|
+
(Some(url), None) => {
|
|
186
|
+
let bytes = download_zip_to_file(client, url, &temp).await?;
|
|
187
|
+
(bytes, url.to_string())
|
|
188
|
+
}
|
|
189
|
+
(None, Some(path)) => (
|
|
190
|
+
fs::copy(path, &temp).with_context(|| {
|
|
191
|
+
format!(
|
|
192
|
+
"failed to copy Stooq ZIP {} to {}",
|
|
193
|
+
path.display(),
|
|
194
|
+
temp.display()
|
|
195
|
+
)
|
|
196
|
+
})?,
|
|
197
|
+
path.display().to_string(),
|
|
198
|
+
),
|
|
199
|
+
(Some(_), Some(_)) => {
|
|
200
|
+
return Err(anyhow!("pass either --url or --zip-path, not both"));
|
|
201
|
+
}
|
|
202
|
+
(None, None) => {
|
|
203
|
+
return Err(anyhow!(
|
|
204
|
+
"Stooq bulk downloads require a captcha-authorized URL or local ZIP; run `agent-finance stooq catalog` for package listings"
|
|
205
|
+
));
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
validate_zip_file(&temp)?;
|
|
209
|
+
if target.exists() {
|
|
210
|
+
fs::remove_file(&target).with_context(|| {
|
|
211
|
+
format!("failed to remove old Stooq cache ZIP {}", target.display())
|
|
212
|
+
})?;
|
|
213
|
+
}
|
|
214
|
+
fs::rename(&temp, &target).with_context(|| {
|
|
215
|
+
format!(
|
|
216
|
+
"failed to move Stooq cache ZIP {} to {}",
|
|
217
|
+
temp.display(),
|
|
218
|
+
target.display()
|
|
219
|
+
)
|
|
220
|
+
})?;
|
|
221
|
+
|
|
222
|
+
Ok(StooqSyncReport {
|
|
223
|
+
provider: "stooq".to_string(),
|
|
224
|
+
frequency: request.frequency.label().to_string(),
|
|
225
|
+
market: request.market.label().to_string(),
|
|
226
|
+
asset: request.asset.label().to_string(),
|
|
227
|
+
cache_key,
|
|
228
|
+
zip_path: target.display().to_string(),
|
|
229
|
+
bytes,
|
|
230
|
+
imported_at_utc: utc_now(),
|
|
231
|
+
source,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async fn fetch_live_csv_history(
|
|
236
|
+
client: &Client,
|
|
237
|
+
symbol: &str,
|
|
238
|
+
provider_symbol: &str,
|
|
239
|
+
interval_code: &str,
|
|
240
|
+
limit: usize,
|
|
241
|
+
api_key: Option<&str>,
|
|
242
|
+
) -> Result<Vec<OhlcBar>> {
|
|
243
|
+
let url = stooq_history_url(provider_symbol, interval_code, api_key);
|
|
244
|
+
let text = client
|
|
245
|
+
.get(url)
|
|
246
|
+
.header("Accept-Encoding", "identity")
|
|
247
|
+
.send()
|
|
248
|
+
.await
|
|
249
|
+
.context("Stooq history request failed")?
|
|
250
|
+
.error_for_status()
|
|
251
|
+
.context("Stooq history returned HTTP error")?
|
|
252
|
+
.text()
|
|
253
|
+
.await
|
|
254
|
+
.context("Stooq history response text parse failed")?;
|
|
255
|
+
if is_stooq_csv_auth_challenge(&text) {
|
|
256
|
+
return Err(anyhow!(
|
|
257
|
+
"Stooq CSV history requires a captcha-issued API key; set STOOQ_API_KEY or use the no-key HTML table fallback"
|
|
258
|
+
));
|
|
259
|
+
}
|
|
260
|
+
parse_csv_history_bars(symbol, &text, limit)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
fn parse_csv_history_bars(symbol: &str, text: &str, limit: usize) -> Result<Vec<OhlcBar>> {
|
|
264
|
+
let mut reader = csv::Reader::from_reader(text.as_bytes());
|
|
265
|
+
let mut rows = Vec::new();
|
|
266
|
+
for row in reader.deserialize::<StooqHistoryRow>() {
|
|
267
|
+
let row = row.context("Stooq history CSV parse failed")?;
|
|
268
|
+
let Some(close) = parse_optional_f64(row.close.as_deref()) else {
|
|
269
|
+
continue;
|
|
270
|
+
};
|
|
271
|
+
rows.push(OhlcBar {
|
|
272
|
+
symbol: symbol.to_uppercase(),
|
|
273
|
+
provider: "stooq".to_string(),
|
|
274
|
+
open_time: row.date,
|
|
275
|
+
close_time: None,
|
|
276
|
+
open: parse_optional_f64(row.open.as_deref()),
|
|
277
|
+
high: parse_optional_f64(row.high.as_deref()),
|
|
278
|
+
low: parse_optional_f64(row.low.as_deref()),
|
|
279
|
+
close,
|
|
280
|
+
adj_close: None,
|
|
281
|
+
volume: parse_optional_f64(row.volume.as_deref()),
|
|
282
|
+
quote_volume: None,
|
|
283
|
+
trades: None,
|
|
284
|
+
dividend: None,
|
|
285
|
+
stock_split: None,
|
|
286
|
+
capital_gain: None,
|
|
287
|
+
repaired: false,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
let bars = rows
|
|
291
|
+
.into_iter()
|
|
292
|
+
.rev()
|
|
293
|
+
.take(limit.max(1))
|
|
294
|
+
.collect::<Vec<_>>()
|
|
295
|
+
.into_iter()
|
|
296
|
+
.rev()
|
|
297
|
+
.collect();
|
|
298
|
+
Ok(bars)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async fn fetch_live_html_history(
|
|
302
|
+
client: &Client,
|
|
303
|
+
symbol: &str,
|
|
304
|
+
provider_symbol: &str,
|
|
305
|
+
interval_code: &str,
|
|
306
|
+
limit: usize,
|
|
307
|
+
) -> Result<Vec<OhlcBar>> {
|
|
308
|
+
let limit = limit.max(1);
|
|
309
|
+
let daily_limit = stooq_daily_limit_for_html_fallback(interval_code, limit);
|
|
310
|
+
let daily_bars = fetch_live_html_daily_history(client, symbol, provider_symbol, daily_limit)
|
|
311
|
+
.await
|
|
312
|
+
.context("Stooq no-key HTML history fallback failed")?;
|
|
313
|
+
match interval_code {
|
|
314
|
+
"d" => Ok(take_latest_chronological(daily_bars, limit)),
|
|
315
|
+
"w" => aggregate_daily_bars(daily_bars, limit, AggregationPeriod::Week),
|
|
316
|
+
"m" => aggregate_daily_bars(daily_bars, limit, AggregationPeriod::Month),
|
|
317
|
+
_ => Err(anyhow!("unsupported Stooq HTML fallback interval")),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async fn fetch_live_html_daily_history(
|
|
322
|
+
client: &Client,
|
|
323
|
+
symbol: &str,
|
|
324
|
+
provider_symbol: &str,
|
|
325
|
+
limit: usize,
|
|
326
|
+
) -> Result<Vec<OhlcBar>> {
|
|
327
|
+
let mut newest_first = Vec::new();
|
|
328
|
+
for page in 1..=stooq_html_page_cap(limit) {
|
|
329
|
+
let url = stooq_history_page_url(provider_symbol, page);
|
|
330
|
+
let text = client
|
|
331
|
+
.get(url)
|
|
332
|
+
.header("Accept-Encoding", "identity")
|
|
333
|
+
.send()
|
|
334
|
+
.await
|
|
335
|
+
.context("Stooq history HTML request failed")?
|
|
336
|
+
.error_for_status()
|
|
337
|
+
.context("Stooq history HTML returned HTTP error")?
|
|
338
|
+
.text()
|
|
339
|
+
.await
|
|
340
|
+
.context("Stooq history HTML response text parse failed")?;
|
|
341
|
+
let mut page_bars = parse_html_history_page(symbol, &text)?;
|
|
342
|
+
if page_bars.is_empty() {
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
newest_first.append(&mut page_bars);
|
|
346
|
+
if newest_first.len() >= limit || !html_has_next_page(&text, page) {
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if newest_first.is_empty() {
|
|
351
|
+
return Err(anyhow!(
|
|
352
|
+
"Stooq history HTML page did not contain usable OHLC rows"
|
|
353
|
+
));
|
|
354
|
+
}
|
|
355
|
+
let mut bars = newest_first.into_iter().take(limit).collect::<Vec<_>>();
|
|
356
|
+
bars.reverse();
|
|
357
|
+
Ok(bars)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
fn parse_html_history_page(symbol: &str, text: &str) -> Result<Vec<OhlcBar>> {
|
|
361
|
+
if is_stooq_html_rate_limited(text) {
|
|
362
|
+
return Err(anyhow!(
|
|
363
|
+
"Stooq no-key HTML history hit the daily site hits limit; set STOOQ_API_KEY for CSV history or import bulk history with `agent-finance stooq sync`"
|
|
364
|
+
));
|
|
365
|
+
}
|
|
366
|
+
let document = Html::parse_document(text);
|
|
367
|
+
let row_selector = Selector::parse("table#fth1 tbody tr").expect("valid Stooq row selector");
|
|
368
|
+
let cell_selector = Selector::parse("td").expect("valid Stooq cell selector");
|
|
369
|
+
let bars = document
|
|
370
|
+
.select(&row_selector)
|
|
371
|
+
.filter_map(|row| {
|
|
372
|
+
let cells = row
|
|
373
|
+
.select(&cell_selector)
|
|
374
|
+
.map(|cell| normalize_html_text(cell.text()))
|
|
375
|
+
.collect::<Vec<_>>();
|
|
376
|
+
html_cells_to_bar(symbol, &cells).transpose()
|
|
377
|
+
})
|
|
378
|
+
.collect::<Result<Vec<_>>>()?;
|
|
379
|
+
Ok(bars)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
fn html_cells_to_bar(symbol: &str, cells: &[String]) -> Result<Option<OhlcBar>> {
|
|
383
|
+
if cells.len() < 6 || cells[1].eq_ignore_ascii_case("date") {
|
|
384
|
+
return Ok(None);
|
|
385
|
+
}
|
|
386
|
+
let Some(close) = parse_stooq_number(cells.get(5).map(String::as_str)) else {
|
|
387
|
+
return Ok(None);
|
|
388
|
+
};
|
|
389
|
+
Ok(Some(OhlcBar {
|
|
390
|
+
symbol: symbol.to_uppercase(),
|
|
391
|
+
provider: "stooq".to_string(),
|
|
392
|
+
open_time: parse_stooq_html_date(&cells[1])?,
|
|
393
|
+
close_time: None,
|
|
394
|
+
open: parse_stooq_number(cells.get(2).map(String::as_str)),
|
|
395
|
+
high: parse_stooq_number(cells.get(3).map(String::as_str)),
|
|
396
|
+
low: parse_stooq_number(cells.get(4).map(String::as_str)),
|
|
397
|
+
close,
|
|
398
|
+
adj_close: None,
|
|
399
|
+
volume: parse_stooq_number(cells.last().map(String::as_str)),
|
|
400
|
+
quote_volume: None,
|
|
401
|
+
trades: None,
|
|
402
|
+
dividend: None,
|
|
403
|
+
stock_split: None,
|
|
404
|
+
capital_gain: None,
|
|
405
|
+
repaired: false,
|
|
406
|
+
}))
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
fn parse_stooq_html_date(value: &str) -> Result<String> {
|
|
410
|
+
let value = normalize_space(value);
|
|
411
|
+
for format in ["%e %b %Y", "%d %b %Y", "%Y-%m-%d"] {
|
|
412
|
+
if let Ok(date) = NaiveDate::parse_from_str(&value, format) {
|
|
413
|
+
return Ok(date.format("%Y-%m-%d").to_string());
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
Err(anyhow!(
|
|
417
|
+
"Stooq history HTML row has unsupported date: {value}"
|
|
418
|
+
))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
fn parse_stooq_number(value: Option<&str>) -> Option<f64> {
|
|
422
|
+
let value = clean_text(value)?;
|
|
423
|
+
value.replace(',', "").parse::<f64>().ok()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
fn normalize_html_text<'a>(parts: impl Iterator<Item = &'a str>) -> String {
|
|
427
|
+
normalize_space(&parts.collect::<Vec<_>>().join(" "))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
fn normalize_space(value: &str) -> String {
|
|
431
|
+
value.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
fn html_has_next_page(text: &str, page: usize) -> bool {
|
|
435
|
+
let document = Html::parse_document(text);
|
|
436
|
+
let selector = Selector::parse("a[href]").expect("valid link selector");
|
|
437
|
+
let next = format!("l={}", page + 1);
|
|
438
|
+
document.select(&selector).any(|element| {
|
|
439
|
+
element
|
|
440
|
+
.value()
|
|
441
|
+
.attr("href")
|
|
442
|
+
.is_some_and(|href| href.contains("q/d/?") && href.contains(&next))
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
fn stooq_daily_limit_for_html_fallback(interval_code: &str, limit: usize) -> usize {
|
|
447
|
+
match interval_code {
|
|
448
|
+
"w" => limit.max(1) * 7 + 7,
|
|
449
|
+
"m" => limit.max(1) * 31 + 31,
|
|
450
|
+
_ => limit.max(1),
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
fn stooq_html_page_cap(limit: usize) -> usize {
|
|
455
|
+
(limit.max(1).div_ceil(40) + 1).min(120)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
fn live_history_batch(symbol: &str, interval: &str, bars: Vec<OhlcBar>) -> HistoryBatch {
|
|
459
|
+
HistoryBatch {
|
|
460
|
+
symbol: symbol.to_uppercase(),
|
|
461
|
+
provider: "stooq".to_string(),
|
|
462
|
+
interval: interval.to_string(),
|
|
463
|
+
adjustment: "raw".to_string(),
|
|
464
|
+
actions_included: false,
|
|
465
|
+
repair_requested: false,
|
|
466
|
+
repair_applied: false,
|
|
467
|
+
bars,
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
fn is_stooq_csv_auth_error(error: &anyhow::Error) -> bool {
|
|
472
|
+
is_stooq_csv_auth_challenge(&error.to_string())
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
fn is_stooq_csv_auth_challenge(text: &str) -> bool {
|
|
476
|
+
text.contains("Get your apikey") || text.contains("get_apikey")
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
fn is_stooq_html_rate_limited(text: &str) -> bool {
|
|
480
|
+
text.contains("Exceeded the daily site hits limit") || text.contains("The data has been hidden")
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#[derive(Clone, Copy)]
|
|
484
|
+
enum AggregationPeriod {
|
|
485
|
+
Week,
|
|
486
|
+
Month,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
fn aggregate_daily_bars(
|
|
490
|
+
daily_bars: Vec<OhlcBar>,
|
|
491
|
+
limit: usize,
|
|
492
|
+
period: AggregationPeriod,
|
|
493
|
+
) -> Result<Vec<OhlcBar>> {
|
|
494
|
+
let mut aggregates = Vec::new();
|
|
495
|
+
let mut current_key = None::<String>;
|
|
496
|
+
let mut current_bar = None::<OhlcBar>;
|
|
497
|
+
|
|
498
|
+
for bar in daily_bars {
|
|
499
|
+
let key = aggregation_key(&bar.open_time, period)?;
|
|
500
|
+
if current_key.as_deref() != Some(key.as_str()) {
|
|
501
|
+
if let Some(bar) = current_bar.take() {
|
|
502
|
+
aggregates.push(bar);
|
|
503
|
+
}
|
|
504
|
+
current_key = Some(key);
|
|
505
|
+
current_bar = Some(bar);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if let Some(current) = current_bar.as_mut() {
|
|
510
|
+
current.close_time = Some(bar.open_time);
|
|
511
|
+
current.high = max_optional(current.high, bar.high);
|
|
512
|
+
current.low = min_optional(current.low, bar.low);
|
|
513
|
+
current.close = bar.close;
|
|
514
|
+
current.volume = sum_optional(current.volume, bar.volume);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if let Some(bar) = current_bar {
|
|
518
|
+
aggregates.push(bar);
|
|
519
|
+
}
|
|
520
|
+
Ok(take_latest_chronological(aggregates, limit.max(1)))
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
fn aggregation_key(date: &str, period: AggregationPeriod) -> Result<String> {
|
|
524
|
+
let date = NaiveDate::parse_from_str(date, "%Y-%m-%d")
|
|
525
|
+
.with_context(|| format!("Stooq aggregate fallback cannot parse date: {date}"))?;
|
|
526
|
+
Ok(match period {
|
|
527
|
+
AggregationPeriod::Week => {
|
|
528
|
+
let week = date.iso_week();
|
|
529
|
+
format!("{}-{:02}", week.year(), week.week())
|
|
530
|
+
}
|
|
531
|
+
AggregationPeriod::Month => format!("{}-{:02}", date.year(), date.month()),
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
fn take_latest_chronological<T>(items: Vec<T>, limit: usize) -> Vec<T> {
|
|
536
|
+
let len = items.len();
|
|
537
|
+
items.into_iter().skip(len.saturating_sub(limit)).collect()
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
fn max_optional(left: Option<f64>, right: Option<f64>) -> Option<f64> {
|
|
541
|
+
match (left, right) {
|
|
542
|
+
(Some(left), Some(right)) => Some(left.max(right)),
|
|
543
|
+
(Some(value), None) | (None, Some(value)) => Some(value),
|
|
544
|
+
(None, None) => None,
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
fn min_optional(left: Option<f64>, right: Option<f64>) -> Option<f64> {
|
|
549
|
+
match (left, right) {
|
|
550
|
+
(Some(left), Some(right)) => Some(left.min(right)),
|
|
551
|
+
(Some(value), None) | (None, Some(value)) => Some(value),
|
|
552
|
+
(None, None) => None,
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
fn sum_optional(left: Option<f64>, right: Option<f64>) -> Option<f64> {
|
|
557
|
+
match (left, right) {
|
|
558
|
+
(Some(left), Some(right)) => Some(left + right),
|
|
559
|
+
(Some(value), None) | (None, Some(value)) => Some(value),
|
|
560
|
+
(None, None) => None,
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async fn download_zip_to_file(client: &Client, url: &str, target: &Path) -> Result<u64> {
|
|
565
|
+
let response = client
|
|
566
|
+
.get(url)
|
|
567
|
+
.send()
|
|
568
|
+
.await
|
|
569
|
+
.context("Stooq bulk download request failed")?
|
|
570
|
+
.error_for_status()
|
|
571
|
+
.context("Stooq bulk download returned HTTP error")?;
|
|
572
|
+
let mut file = fs::File::create(target)
|
|
573
|
+
.with_context(|| format!("failed to create temp ZIP {}", target.display()))?;
|
|
574
|
+
let bytes = response
|
|
575
|
+
.bytes()
|
|
576
|
+
.await
|
|
577
|
+
.context("Stooq bulk download body read failed")?;
|
|
578
|
+
file.write_all(&bytes)
|
|
579
|
+
.with_context(|| format!("failed to write temp ZIP {}", target.display()))?;
|
|
580
|
+
Ok(bytes.len() as u64)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
fn stooq_symbol(symbol: &str) -> String {
|
|
584
|
+
let mut normalized = symbol.trim().to_lowercase().replace('-', ".");
|
|
585
|
+
if !normalized.contains('.') {
|
|
586
|
+
normalized.push_str(".us");
|
|
587
|
+
}
|
|
588
|
+
normalized
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
fn stooq_interval(interval: &str) -> Result<&'static str> {
|
|
592
|
+
match interval {
|
|
593
|
+
"1d" | "d" => Ok("d"),
|
|
594
|
+
"1w" | "w" => Ok("w"),
|
|
595
|
+
"1mo" | "1M" | "m" => Ok("m"),
|
|
596
|
+
_ => Err(anyhow!(
|
|
597
|
+
"Stooq history supports daily/weekly/monthly intervals only"
|
|
598
|
+
)),
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
fn stooq_history_url(provider_symbol: &str, interval_code: &str, api_key: Option<&str>) -> String {
|
|
603
|
+
let mut url = format!("https://stooq.com/q/d/l/?s={provider_symbol}&i={interval_code}");
|
|
604
|
+
if let Some(api_key) = api_key.map(str::trim).filter(|value| !value.is_empty()) {
|
|
605
|
+
url.push_str("&apikey=");
|
|
606
|
+
url.push_str(api_key);
|
|
607
|
+
}
|
|
608
|
+
url
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
fn stooq_history_page_url(provider_symbol: &str, page: usize) -> String {
|
|
612
|
+
format!("https://stooq.com/q/d/?s={provider_symbol}&i=d&l={page}")
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
fn fetch_bulk_history(
|
|
616
|
+
symbol: &str,
|
|
617
|
+
interval: &str,
|
|
618
|
+
limit: usize,
|
|
619
|
+
market: StooqMarket,
|
|
620
|
+
asset: StooqAsset,
|
|
621
|
+
) -> Result<HistoryBatch> {
|
|
622
|
+
let frequency = match interval {
|
|
623
|
+
"5m" | "5minute" => StooqFrequency::FiveMin,
|
|
624
|
+
"1h" | "hour" | "60m" => StooqFrequency::Hourly,
|
|
625
|
+
_ => return Err(anyhow!("Stooq bulk supports 5m and 1h intervals")),
|
|
626
|
+
};
|
|
627
|
+
let package = catalog_package(frequency, market, asset)
|
|
628
|
+
.ok_or_else(|| anyhow!("Stooq catalog does not list this frequency/market/asset combo"))?;
|
|
629
|
+
let cache_key = package.cache_key();
|
|
630
|
+
let zip_path = cached_zip_path(&cache_key)?;
|
|
631
|
+
if !zip_path.exists() {
|
|
632
|
+
return Err(anyhow!(
|
|
633
|
+
"no Stooq {} bulk ZIP is cached at {}; import it with `agent-finance stooq sync --frequency {} --market {} --asset {} --zip-path <file>`",
|
|
634
|
+
package.label,
|
|
635
|
+
zip_path.display(),
|
|
636
|
+
frequency.label(),
|
|
637
|
+
market.label(),
|
|
638
|
+
asset.label()
|
|
639
|
+
));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
let normalized = stooq_symbol(symbol);
|
|
643
|
+
if let Some(history) = read_symbol_from_zip(&zip_path, symbol, &normalized, interval, limit)? {
|
|
644
|
+
return Ok(history);
|
|
645
|
+
}
|
|
646
|
+
Err(anyhow!(
|
|
647
|
+
"symbol {} was not found in cached Stooq {} ZIP {}",
|
|
648
|
+
normalized,
|
|
649
|
+
package.label,
|
|
650
|
+
zip_path.display()
|
|
651
|
+
))
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
fn validate_zip_file(path: &Path) -> Result<()> {
|
|
655
|
+
let file = fs::File::open(path)
|
|
656
|
+
.with_context(|| format!("failed to open Stooq ZIP {}", path.display()))?;
|
|
657
|
+
ZipArchive::new(file).context("Stooq bulk payload is not a readable ZIP")?;
|
|
658
|
+
Ok(())
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
fn read_symbol_from_zip(
|
|
662
|
+
path: &Path,
|
|
663
|
+
symbol: &str,
|
|
664
|
+
provider_symbol: &str,
|
|
665
|
+
interval: &str,
|
|
666
|
+
limit: usize,
|
|
667
|
+
) -> Result<Option<HistoryBatch>> {
|
|
668
|
+
let file = fs::File::open(path)
|
|
669
|
+
.with_context(|| format!("failed to open Stooq cache ZIP {}", path.display()))?;
|
|
670
|
+
let mut archive = ZipArchive::new(file)
|
|
671
|
+
.with_context(|| format!("failed to read Stooq cache ZIP {}", path.display()))?;
|
|
672
|
+
let symbol_file = provider_symbol.to_ascii_lowercase();
|
|
673
|
+
let plain_file = symbol_file.trim_end_matches(".us").to_string();
|
|
674
|
+
|
|
675
|
+
for index in 0..archive.len() {
|
|
676
|
+
let mut file = archive
|
|
677
|
+
.by_index(index)
|
|
678
|
+
.with_context(|| format!("failed to read Stooq ZIP entry {index}"))?;
|
|
679
|
+
if !file.is_file() {
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
let entry_name = file.name().to_ascii_lowercase();
|
|
683
|
+
let Some(file_name) = entry_name.rsplit('/').next() else {
|
|
684
|
+
continue;
|
|
685
|
+
};
|
|
686
|
+
if !matches_symbol_file(file_name, &symbol_file, &plain_file) {
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
let bars = parse_bulk_bars_from_reader(symbol, &mut file, limit)?;
|
|
690
|
+
return Ok(Some(HistoryBatch {
|
|
691
|
+
symbol: symbol.to_uppercase(),
|
|
692
|
+
provider: "stooq-bulk".to_string(),
|
|
693
|
+
interval: interval.to_string(),
|
|
694
|
+
adjustment: "raw".to_string(),
|
|
695
|
+
actions_included: false,
|
|
696
|
+
repair_requested: false,
|
|
697
|
+
repair_applied: false,
|
|
698
|
+
bars,
|
|
699
|
+
}));
|
|
700
|
+
}
|
|
701
|
+
Ok(None)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
fn matches_symbol_file(file_name: &str, symbol_file: &str, plain_file: &str) -> bool {
|
|
705
|
+
let file_name = file_name.trim_end_matches(".txt").trim_end_matches(".csv");
|
|
706
|
+
file_name == symbol_file || file_name == plain_file
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
fn parse_bulk_bars_from_reader<R: Read>(
|
|
710
|
+
symbol: &str,
|
|
711
|
+
reader: R,
|
|
712
|
+
limit: usize,
|
|
713
|
+
) -> Result<Vec<OhlcBar>> {
|
|
714
|
+
let limit = limit.max(1);
|
|
715
|
+
let mut reader = csv::ReaderBuilder::new()
|
|
716
|
+
.has_headers(true)
|
|
717
|
+
.from_reader(reader);
|
|
718
|
+
let headers = reader
|
|
719
|
+
.headers()
|
|
720
|
+
.context("Stooq bulk CSV missing headers")?
|
|
721
|
+
.clone();
|
|
722
|
+
let mut bars = VecDeque::with_capacity(limit);
|
|
723
|
+
for record in reader.records() {
|
|
724
|
+
let record = record.context("Stooq bulk CSV record parse failed")?;
|
|
725
|
+
if let Some(bar) = record_to_bar(symbol, &headers, &record) {
|
|
726
|
+
if bars.len() == limit {
|
|
727
|
+
bars.pop_front();
|
|
728
|
+
}
|
|
729
|
+
bars.push_back(bar);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if bars.is_empty() {
|
|
733
|
+
return Err(anyhow!("Stooq bulk CSV did not contain usable OHLC rows"));
|
|
734
|
+
}
|
|
735
|
+
Ok(bars.into_iter().collect())
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
fn record_to_bar(
|
|
739
|
+
symbol: &str,
|
|
740
|
+
headers: &csv::StringRecord,
|
|
741
|
+
record: &csv::StringRecord,
|
|
742
|
+
) -> Option<OhlcBar> {
|
|
743
|
+
let date = field(headers, record, &["Date", "DATE", "<DATE>"])?;
|
|
744
|
+
let time = field(headers, record, &["Time", "TIME", "<TIME>"]);
|
|
745
|
+
let open_time = match time {
|
|
746
|
+
Some(time) if !time.trim().is_empty() => format!("{date} {time}"),
|
|
747
|
+
_ => date.to_string(),
|
|
748
|
+
};
|
|
749
|
+
let close = parse_optional_f64(field(headers, record, &["Close", "CLOSE", "<CLOSE>"]))?;
|
|
750
|
+
Some(OhlcBar {
|
|
751
|
+
symbol: symbol.to_uppercase(),
|
|
752
|
+
provider: "stooq-bulk".to_string(),
|
|
753
|
+
open_time,
|
|
754
|
+
close_time: None,
|
|
755
|
+
open: parse_optional_f64(field(headers, record, &["Open", "OPEN", "<OPEN>"])),
|
|
756
|
+
high: parse_optional_f64(field(headers, record, &["High", "HIGH", "<HIGH>"])),
|
|
757
|
+
low: parse_optional_f64(field(headers, record, &["Low", "LOW", "<LOW>"])),
|
|
758
|
+
close,
|
|
759
|
+
adj_close: None,
|
|
760
|
+
volume: parse_optional_f64(field(headers, record, &["Volume", "VOL", "<VOL>"])),
|
|
761
|
+
quote_volume: None,
|
|
762
|
+
trades: None,
|
|
763
|
+
dividend: None,
|
|
764
|
+
stock_split: None,
|
|
765
|
+
capital_gain: None,
|
|
766
|
+
repaired: false,
|
|
767
|
+
})
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
fn field<'a>(
|
|
771
|
+
headers: &csv::StringRecord,
|
|
772
|
+
record: &'a csv::StringRecord,
|
|
773
|
+
names: &[&str],
|
|
774
|
+
) -> Option<&'a str> {
|
|
775
|
+
names.iter().find_map(|name| {
|
|
776
|
+
headers
|
|
777
|
+
.iter()
|
|
778
|
+
.position(|header| header.eq_ignore_ascii_case(name))
|
|
779
|
+
.and_then(|index| record.get(index))
|
|
780
|
+
})
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
#[cfg(test)]
|
|
784
|
+
mod tests {
|
|
785
|
+
use super::*;
|
|
786
|
+
|
|
787
|
+
const HTML_HISTORY_PAGE: &str = r#"
|
|
788
|
+
<html>
|
|
789
|
+
<body>
|
|
790
|
+
<a href="q/d/?s=aapl.us&i=d&l=2">></a>
|
|
791
|
+
<table class="fth1" id="fth1">
|
|
792
|
+
<thead>
|
|
793
|
+
<tr><td>No.</td><td>Date</td><td>Open</td><td>High</td><td>Low</td><td>Close</td><td colspan="2">Change</td><td>Volume</td></tr>
|
|
794
|
+
</thead>
|
|
795
|
+
<tbody>
|
|
796
|
+
<tr><td>10515</td><td>3 Jun 2026</td><td>314.175</td><td>316.94</td><td>308.85</td><td>310.26</td><td>-1.57%</td><td>-4.940</td><td>50,836,705</td></tr>
|
|
797
|
+
<tr><td>10514</td><td>2 Jun 2026</td><td>307.46</td><td>315.45</td><td>306.685</td><td>315.2</td><td>+2.90%</td><td>+8.890</td><td>44,534,716</td></tr>
|
|
798
|
+
<tr><td>10513</td><td>1 Jun 2026</td><td>309.625</td><td>310.94</td><td>305.02</td><td>306.31</td><td>-1.84%</td><td>-5.750</td><td>48,849,933</td></tr>
|
|
799
|
+
<tr><td>10512</td><td>29 May 2026</td><td>311.775</td><td>315</td><td>309.53</td><td>312.06</td><td>-0.14%</td><td>-0.450</td><td>70,026,752</td></tr>
|
|
800
|
+
</tbody>
|
|
801
|
+
</table>
|
|
802
|
+
</body>
|
|
803
|
+
</html>
|
|
804
|
+
"#;
|
|
805
|
+
|
|
806
|
+
#[test]
|
|
807
|
+
fn parses_no_key_stooq_html_history_table() {
|
|
808
|
+
let bars = parse_html_history_page("AAPL", HTML_HISTORY_PAGE).expect("html history");
|
|
809
|
+
|
|
810
|
+
assert_eq!(bars.len(), 4);
|
|
811
|
+
assert_eq!(bars[0].open_time, "2026-06-03");
|
|
812
|
+
assert_eq!(bars[0].open, Some(314.175));
|
|
813
|
+
assert_eq!(bars[0].high, Some(316.94));
|
|
814
|
+
assert_eq!(bars[0].low, Some(308.85));
|
|
815
|
+
assert_eq!(bars[0].close, 310.26);
|
|
816
|
+
assert_eq!(bars[0].volume, Some(50_836_705.0));
|
|
817
|
+
assert!(html_has_next_page(HTML_HISTORY_PAGE, 1));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
#[test]
|
|
821
|
+
fn reports_stooq_html_daily_site_limit() {
|
|
822
|
+
let html = "<b>Exceeded the daily site hits limit<br>The data has been hidden</b>";
|
|
823
|
+
|
|
824
|
+
let error = parse_html_history_page("AAPL", html).expect_err("rate-limited page");
|
|
825
|
+
|
|
826
|
+
assert!(error.to_string().contains("daily site hits limit"));
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
#[test]
|
|
830
|
+
fn aggregates_no_key_daily_rows_when_stooq_html_ignores_interval() {
|
|
831
|
+
let newest_first =
|
|
832
|
+
parse_html_history_page("AAPL", HTML_HISTORY_PAGE).expect("html history");
|
|
833
|
+
let mut chronological = newest_first;
|
|
834
|
+
chronological.reverse();
|
|
835
|
+
|
|
836
|
+
let monthly =
|
|
837
|
+
aggregate_daily_bars(chronological, 1, AggregationPeriod::Month).expect("monthly bars");
|
|
838
|
+
|
|
839
|
+
assert_eq!(monthly.len(), 1);
|
|
840
|
+
assert_eq!(monthly[0].open_time, "2026-06-01");
|
|
841
|
+
assert_eq!(monthly[0].close_time.as_deref(), Some("2026-06-03"));
|
|
842
|
+
assert_eq!(monthly[0].open, Some(309.625));
|
|
843
|
+
assert_eq!(monthly[0].high, Some(316.94));
|
|
844
|
+
assert_eq!(monthly[0].low, Some(305.02));
|
|
845
|
+
assert_eq!(monthly[0].close, 310.26);
|
|
846
|
+
assert_eq!(monthly[0].volume, Some(144_221_354.0));
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
#[test]
|
|
850
|
+
fn builds_stooq_urls_for_csv_key_and_html_table_paths() {
|
|
851
|
+
assert_eq!(
|
|
852
|
+
stooq_history_url("aapl.us", "d", None),
|
|
853
|
+
"https://stooq.com/q/d/l/?s=aapl.us&i=d"
|
|
854
|
+
);
|
|
855
|
+
assert_eq!(
|
|
856
|
+
stooq_history_url("aapl.us", "w", Some(" token ")),
|
|
857
|
+
"https://stooq.com/q/d/l/?s=aapl.us&i=w&apikey=token"
|
|
858
|
+
);
|
|
859
|
+
assert_eq!(
|
|
860
|
+
stooq_history_page_url("aapl.us", 3),
|
|
861
|
+
"https://stooq.com/q/d/?s=aapl.us&i=d&l=3"
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
#[test]
|
|
866
|
+
fn parses_stooq_bulk_angle_headers_and_keeps_latest_limit() {
|
|
867
|
+
let text = "\
|
|
868
|
+
<TICKER>,<PER>,<DATE>,<TIME>,<OPEN>,<HIGH>,<LOW>,<CLOSE>,<VOL>,<OPENINT>
|
|
869
|
+
AAPL.US,5,20260603,154500,312.21,312.36,311.91,311.95,5866,0
|
|
870
|
+
AAPL.US,5,20260603,155000,311.75,312.00,311.75,311.84,1482,0
|
|
871
|
+
";
|
|
872
|
+
let bars = parse_bulk_bars_from_reader("AAPL", text.as_bytes(), 1).expect("bulk bars");
|
|
873
|
+
|
|
874
|
+
assert_eq!(bars.len(), 1);
|
|
875
|
+
assert_eq!(bars[0].symbol, "AAPL");
|
|
876
|
+
assert_eq!(bars[0].provider, "stooq-bulk");
|
|
877
|
+
assert_eq!(bars[0].open_time, "20260603 155000");
|
|
878
|
+
assert_eq!(bars[0].open, Some(311.75));
|
|
879
|
+
assert_eq!(bars[0].high, Some(312.0));
|
|
880
|
+
assert_eq!(bars[0].low, Some(311.75));
|
|
881
|
+
assert_eq!(bars[0].close, 311.84);
|
|
882
|
+
assert_eq!(bars[0].volume, Some(1482.0));
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
#[test]
|
|
886
|
+
fn stooq_bulk_cache_key_uses_full_package_scope() {
|
|
887
|
+
let stocks = catalog_package(StooqFrequency::FiveMin, StooqMarket::Us, StooqAsset::Stocks)
|
|
888
|
+
.expect("us stocks 5m package");
|
|
889
|
+
let etfs = catalog_package(StooqFrequency::FiveMin, StooqMarket::Us, StooqAsset::Etfs)
|
|
890
|
+
.expect("us etfs 5m package");
|
|
891
|
+
|
|
892
|
+
assert_eq!(stocks.cache_key(), "5m_us_stocks");
|
|
893
|
+
assert_eq!(etfs.cache_key(), "5m_us_etfs");
|
|
894
|
+
assert_ne!(stocks.cache_key(), etfs.cache_key());
|
|
895
|
+
assert!(
|
|
896
|
+
catalog_package(
|
|
897
|
+
StooqFrequency::FiveMin,
|
|
898
|
+
StooqMarket::Macro,
|
|
899
|
+
StooqAsset::Macro
|
|
900
|
+
)
|
|
901
|
+
.is_none()
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
}
|