1use crate::indicators::OHLCV;
3use anyhow::{anyhow, Result};
4use reqwest::blocking::Client;
5use serde_json::Value;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9use tracing::{debug, warn};
10
11pub const TIMEFRAMES: [(&str, &str, &str); 5] = [
13 ("4h", "1h", "1y"),
14 ("1d", "1d", "2y"),
15 ("1w", "1wk", "5y"),
16 ("1mo", "1mo", "10y"),
17 ("3mo", "3mo", "max"),
18];
19
20pub const TF_WEIGHTS: [f64; 5] = [0.03, 0.20, 0.27, 0.30, 0.20];
21
22fn yahoo_sym(symbol: &str) -> &str {
24 match symbol {
25 "BRKB" => "BRK-B",
26 "BRKA" => "BRK-A",
27 "BFAM" => "BFAM",
28 _ => symbol,
29 }
30}
31
32fn yahoo_url(symbol: &str, interval: &str, range: &str) -> String {
33 format!(
34 "https://query1.finance.yahoo.com/v8/finance/chart/{}?interval={}&range={}&includeAdjustedClose=true",
35 yahoo_sym(symbol), interval, range
36 )
37}
38
39fn safe_component(value: &str) -> String {
40 value
41 .chars()
42 .map(|character| {
43 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
44 character
45 } else {
46 '_'
47 }
48 })
49 .collect()
50}
51
52fn cache_dir() -> Option<PathBuf> {
53 std::env::var_os("SD_OHLCV_CACHE_DIR").map(PathBuf::from)
54}
55
56fn cache_path(symbol: &str, interval: &str, range: &str) -> Option<PathBuf> {
57 Some(cache_dir()?.join(format!(
58 "{}__{}__{}.csv",
59 safe_component(yahoo_sym(symbol)),
60 safe_component(interval),
61 safe_component(range)
62 )))
63}
64
65fn offline_requested() -> bool {
66 std::env::var("SD_OFFLINE")
67 .is_ok_and(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
68}
69
70pub fn load_ohlcv_csv(path: impl AsRef<Path>) -> Result<Vec<OHLCV>> {
74 let path = path.as_ref();
75 let mut reader =
76 csv::Reader::from_path(path).map_err(|error| anyhow!("{}: {error}", path.display()))?;
77 let headers = reader.headers()?.clone();
78 let find = |name: &str| {
79 headers
80 .iter()
81 .position(|header| header.trim().eq_ignore_ascii_case(name))
82 .ok_or_else(|| anyhow!("{}: missing CSV column {name:?}", path.display()))
83 };
84 let open = find("open")?;
85 let high = find("high")?;
86 let low = find("low")?;
87 let close = find("close")?;
88 let volume = find("volume")?;
89 let mut bars = Vec::new();
90 for (row_number, row) in reader.records().enumerate() {
91 let row = row?;
92 let parse = |index: usize, name: &str| -> Result<f64> {
93 row.get(index)
94 .ok_or_else(|| anyhow!("row {} missing {name}", row_number + 2))?
95 .trim()
96 .parse::<f64>()
97 .map_err(|error| anyhow!("row {} invalid {name}: {error}", row_number + 2))
98 };
99 let bar = OHLCV {
100 open: parse(open, "open")?,
101 high: parse(high, "high")?,
102 low: parse(low, "low")?,
103 close: parse(close, "close")?,
104 volume: parse(volume, "volume")?,
105 };
106 if [bar.open, bar.high, bar.low, bar.close, bar.volume]
107 .into_iter()
108 .all(f64::is_finite)
109 {
110 bars.push(bar);
111 }
112 }
113 Ok(bars)
114}
115
116fn write_ohlcv_csv(path: &Path, bars: &[OHLCV]) -> Result<()> {
117 if let Some(parent) = path.parent() {
118 fs::create_dir_all(parent)?;
119 }
120 let mut writer = csv::Writer::from_path(path)?;
121 writer.write_record(["open", "high", "low", "close", "volume"])?;
122 for bar in bars {
123 writer.write_record([
124 bar.open.to_string(),
125 bar.high.to_string(),
126 bar.low.to_string(),
127 bar.close.to_string(),
128 bar.volume.to_string(),
129 ])?;
130 }
131 writer.flush()?;
132 Ok(())
133}
134
135fn cached_daily_price(symbol: &str) -> Option<f64> {
136 let directory = cache_dir()?;
137 let prefix = format!("{}__1d__", safe_component(yahoo_sym(symbol)));
138 let mut candidates = fs::read_dir(directory)
139 .ok()?
140 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
141 .filter(|path| {
142 path.file_name()
143 .and_then(|name| name.to_str())
144 .is_some_and(|name| name.starts_with(&prefix) && name.ends_with(".csv"))
145 })
146 .collect::<Vec<_>>();
147 candidates.sort();
148 candidates
149 .into_iter()
150 .filter_map(|path| load_ohlcv_csv(path).ok())
151 .filter_map(|bars| bars.last().map(|bar| bar.close))
152 .next_back()
153}
154
155fn parse_chart(body: &Value) -> Result<Vec<OHLCV>> {
156 let result = body
157 .get("chart")
158 .ok_or_else(|| anyhow!("no chart key"))?
159 .get("result")
160 .ok_or_else(|| anyhow!("no result key"))?
161 .get(0)
162 .ok_or_else(|| anyhow!("empty result"))?;
163
164 let timestamps = result
165 .get("timestamp")
166 .ok_or_else(|| anyhow!("no timestamps"))?
167 .as_array()
168 .ok_or_else(|| anyhow!("timestamps not array"))?;
169
170 let ind = result
171 .get("indicators")
172 .ok_or_else(|| anyhow!("no indicators"))?;
173 let quote = ind
174 .get("quote")
175 .ok_or_else(|| anyhow!("no quote"))?
176 .get(0)
177 .ok_or_else(|| anyhow!("empty quote"))?;
178
179 let opens = quote
180 .get("open")
181 .and_then(|v| v.as_array())
182 .ok_or_else(|| anyhow!("no open"))?;
183 let highs = quote
184 .get("high")
185 .and_then(|v| v.as_array())
186 .ok_or_else(|| anyhow!("no high"))?;
187 let lows = quote
188 .get("low")
189 .and_then(|v| v.as_array())
190 .ok_or_else(|| anyhow!("no low"))?;
191 let closes = quote
192 .get("close")
193 .and_then(|v| v.as_array())
194 .ok_or_else(|| anyhow!("no close"))?;
195 let volumes = quote
196 .get("volume")
197 .and_then(|v| v.as_array())
198 .ok_or_else(|| anyhow!("no volume"))?;
199
200 let n = timestamps.len();
201 let mut bars = Vec::with_capacity(n);
202 for i in 0..n {
203 let o = opens[i].as_f64();
204 let h = highs[i].as_f64();
205 let l = lows[i].as_f64();
206 let c = closes[i].as_f64();
207 let v = volumes[i].as_f64().unwrap_or(0.0);
208 if let (Some(o), Some(h), Some(l), Some(c)) = (o, h, l, c) {
209 bars.push(OHLCV {
210 open: o,
211 high: h,
212 low: l,
213 close: c,
214 volume: v,
215 });
216 }
217 }
218 Ok(bars)
219}
220
221fn aggregate_4h(bars: Vec<OHLCV>) -> Vec<OHLCV> {
223 bars.chunks(4)
224 .map(|chunk| {
225 let o = chunk[0].open;
226 let h = chunk
227 .iter()
228 .map(|b| b.high)
229 .fold(f64::NEG_INFINITY, f64::max);
230 let l = chunk.iter().map(|b| b.low).fold(f64::INFINITY, f64::min);
231 let c = chunk.last().unwrap().close;
232 let v = chunk.iter().map(|b| b.volume).sum();
233 OHLCV {
234 open: o,
235 high: h,
236 low: l,
237 close: c,
238 volume: v,
239 }
240 })
241 .collect()
242}
243
244pub fn fetch_current_price(client: &Client, symbol: &str) -> Result<f64> {
246 if let Some(price) = cached_daily_price(symbol) {
247 debug!("{symbol} current price from offline OHLCV cache: {price:.4}");
248 return Ok(price);
249 }
250 if offline_requested() {
251 return Err(anyhow!(
252 "offline cache miss for {symbol}; populate SD_OHLCV_CACHE_DIR before disconnecting"
253 ));
254 }
255 let url = yahoo_url(symbol, "1d", "5d");
256 let mut last_err = anyhow!("no attempts");
257
258 for attempt in 0..3u32 {
259 if attempt > 0 {
260 std::thread::sleep(Duration::from_millis(500 * 2u64.pow(attempt)));
261 }
262 match client
263 .get(&url)
264 .header("User-Agent", "Mozilla/5.0")
265 .timeout(Duration::from_secs(20))
266 .send()
267 {
268 Ok(resp) => {
269 if resp.status().as_u16() == 429 {
270 warn!("{symbol} price rate-limited, backing off");
271 std::thread::sleep(Duration::from_secs(5));
272 last_err = anyhow!("rate limited");
273 continue;
274 }
275 match resp.json::<Value>() {
276 Ok(body) => {
277 let closes = body
278 .get("chart")
279 .and_then(|c| c.get("result"))
280 .and_then(|r| r.get(0))
281 .and_then(|r| r.get("indicators"))
282 .and_then(|i| i.get("quote"))
283 .and_then(|q| q.get(0))
284 .and_then(|q| q.get("close"))
285 .and_then(|c| c.as_array());
286 match closes {
287 Some(arr) => {
288 if let Some(price) = arr
289 .iter()
290 .rev()
291 .find_map(|v| v.as_f64().filter(|&p| p > 0.0))
292 {
293 debug!("{symbol} current price: {price:.4}");
294 return Ok(price);
295 }
296 last_err = anyhow!("no valid close in response");
297 }
298 None => {
299 last_err = anyhow!("no close array in response");
300 }
301 }
302 }
303 Err(e) => {
304 last_err = anyhow!("json parse: {}", e);
305 }
306 }
307 }
308 Err(e) => {
309 last_err = anyhow!("http: {}", e);
310 }
311 }
312 }
313 Err(last_err)
314}
315
316pub fn fetch_ohlcv(
317 client: &Client,
318 symbol: &str,
319 interval: &str,
320 range: &str,
321 is_4h: bool,
322) -> Result<Vec<OHLCV>> {
323 let cached = cache_path(symbol, interval, range);
324 if let Some(path) = cached.as_ref().filter(|path| path.exists()) {
325 let mut bars = load_ohlcv_csv(path)?;
326 if is_4h {
327 bars = aggregate_4h(bars);
328 }
329 if bars.len() < 30 {
330 return Err(anyhow!(
331 "{} contains insufficient bars after aggregation: {}",
332 path.display(),
333 bars.len()
334 ));
335 }
336 debug!(
337 "{symbol} [{interval}] loaded {} bars from {}",
338 bars.len(),
339 path.display()
340 );
341 return Ok(bars);
342 }
343 if offline_requested() {
344 return Err(anyhow!(
345 "offline cache miss for {symbol} interval={interval} range={range}; expected {}",
346 cached
347 .as_deref()
348 .map(|path| path.display().to_string())
349 .unwrap_or_else(|| "SD_OHLCV_CACHE_DIR is not set".to_owned())
350 ));
351 }
352 let url = yahoo_url(symbol, interval, range);
353 let mut last_err = anyhow!("no attempts");
354
355 for attempt in 0..3u32 {
356 if attempt > 0 {
357 std::thread::sleep(Duration::from_millis(500 * 2u64.pow(attempt)));
358 }
359 match client
360 .get(&url)
361 .header("User-Agent", "Mozilla/5.0")
362 .timeout(Duration::from_secs(20))
363 .send()
364 {
365 Ok(resp) => {
366 if resp.status().as_u16() == 429 {
367 warn!("{symbol} rate-limited, backing off");
368 std::thread::sleep(Duration::from_secs(5));
369 last_err = anyhow!("rate limited");
370 continue;
371 }
372 match resp.json::<Value>() {
373 Ok(body) => match parse_chart(&body) {
374 Ok(mut bars) => {
375 if bars.len() < 30 {
376 return Err(anyhow!("insufficient bars: {}", bars.len()));
377 }
378 if let Some(path) = &cached {
379 if let Err(error) = write_ohlcv_csv(path, &bars) {
380 warn!(
381 "{symbol} [{interval}] could not write cache {}: {error}",
382 path.display()
383 );
384 }
385 }
386 if is_4h {
387 bars = aggregate_4h(bars);
388 }
389 if bars.len() < 30 {
390 return Err(anyhow!("insufficient 4h bars: {}", bars.len()));
391 }
392 debug!("{symbol} [{interval}] fetched {} bars", bars.len());
393 return Ok(bars);
394 }
395 Err(e) => {
396 last_err = e;
397 }
398 },
399 Err(e) => {
400 last_err = anyhow!("json parse: {}", e);
401 }
402 }
403 }
404 Err(e) => {
405 last_err = anyhow!("http: {}", e);
406 }
407 }
408 }
409 Err(last_err)
410}