Skip to main content

portfolio_analyzer/
fetcher.rs

1/// Yahoo Finance OHLCV data fetcher with retry logic
2use 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
11// Supported: 1h (used for 4h aggregation), 1d, 1wk, 1mo, 3mo
12pub 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
22// Some brokerages strip the dot/dash from Yahoo Finance symbols
23fn 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
70/// Read OHLCV from a local CSV. Required columns are open, high, low, close,
71/// and volume; names are matched case-insensitively and extra columns are
72/// ignored.
73pub 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
221/// Aggregate 1h bars into 4h bars (every 4 consecutive 1h bars → 1 4h bar)
222fn 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
244/// Fetch the most recent closing price (last valid close in a 5-day 1d window)
245pub 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}