1use crate::indicators::OHLCV;
21use anyhow::{anyhow, Context, Result};
22use chrono::{DateTime, NaiveDate};
23use reqwest::blocking::Client;
24use serde_json::Value;
25use std::fs;
26use std::path::Path;
27use std::time::Duration;
28
29#[derive(Debug, Clone)]
31pub struct DatedBar {
32 pub date: NaiveDate,
33 pub bar: OHLCV,
34}
35
36pub fn fetch_daily(client: &Client, symbol: &str, range: &str) -> Result<Vec<DatedBar>> {
43 let url = format!(
44 "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1d&range={range}&includeAdjustedClose=true"
45 );
46 let mut last_err = anyhow!("no attempts");
47 for attempt in 0..4u32 {
48 if attempt > 0 {
49 std::thread::sleep(Duration::from_millis(750 * 2u64.pow(attempt)));
50 }
51 let response = match client
52 .get(&url)
53 .header("User-Agent", "Mozilla/5.0")
54 .timeout(Duration::from_secs(30))
55 .send()
56 {
57 Ok(response) => response,
58 Err(error) => {
59 last_err = anyhow!("http: {error}");
60 continue;
61 }
62 };
63 if response.status().as_u16() == 429 {
64 last_err = anyhow!("rate limited");
65 std::thread::sleep(Duration::from_secs(5));
66 continue;
67 }
68 let body: Value = match response.json() {
69 Ok(body) => body,
70 Err(error) => {
71 last_err = anyhow!("json: {error}");
72 continue;
73 }
74 };
75 return parse(&body);
76 }
77 Err(last_err)
78}
79
80fn parse(body: &Value) -> Result<Vec<DatedBar>> {
81 let result = &body["chart"]["result"][0];
82 let timestamps = result["timestamp"]
83 .as_array()
84 .ok_or_else(|| anyhow!("no timestamps"))?;
85 let quote = &result["indicators"]["quote"][0];
86 let adj = result["indicators"]["adjclose"][0]["adjclose"].as_array();
87 let field = |name: &str| quote[name].as_array().cloned().unwrap_or_default();
88 let (open, high, low, close, volume) = (
89 field("open"),
90 field("high"),
91 field("low"),
92 field("close"),
93 field("volume"),
94 );
95 let mut bars = Vec::with_capacity(timestamps.len());
96 for (i, ts) in timestamps.iter().enumerate() {
97 let get = |v: &Vec<Value>| v.get(i).and_then(Value::as_f64);
98 let (Some(o), Some(h), Some(l), Some(c)) = (get(&open), get(&high), get(&low), get(&close))
99 else {
100 continue;
101 };
102 if c <= 0.0 || h < l {
103 continue;
104 }
105 let ratio = adj
106 .and_then(|a| a.get(i))
107 .and_then(Value::as_f64)
108 .map(|a| a / c)
109 .filter(|r| r.is_finite() && *r > 0.0)
110 .unwrap_or(1.0);
111 let Some(date) = ts
112 .as_i64()
113 .and_then(|s| DateTime::from_timestamp(s, 0))
114 .map(|d| d.date_naive())
115 else {
116 continue;
117 };
118 bars.push(DatedBar {
119 date,
120 bar: OHLCV {
121 open: o * ratio,
122 high: h * ratio,
123 low: l * ratio,
124 close: c * ratio,
125 volume: get(&volume).unwrap_or(0.0),
126 },
127 });
128 }
129 bars.dedup_by(|later, earlier| {
131 if later.date == earlier.date {
132 *earlier = later.clone();
133 true
134 } else {
135 false
136 }
137 });
138 Ok(bars)
139}
140
141pub fn save(path: &Path, bars: &[DatedBar]) -> Result<()> {
143 if let Some(parent) = path.parent() {
144 fs::create_dir_all(parent)?;
145 }
146 let mut out = String::from("date,open,high,low,close,volume\n");
147 for b in bars {
148 out.push_str(&format!(
149 "{},{:.6},{:.6},{:.6},{:.6},{}\n",
150 b.date, b.bar.open, b.bar.high, b.bar.low, b.bar.close, b.bar.volume
151 ));
152 }
153 fs::write(path, out).with_context(|| format!("writing {}", path.display()))
154}
155
156pub fn load(path: &Path) -> Result<Vec<DatedBar>> {
158 let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
159 let mut bars = Vec::new();
160 for line in text.lines().skip(1) {
161 let f: Vec<&str> = line.split(',').collect();
162 if f.len() < 6 {
163 continue;
164 }
165 let num = |i: usize| f[i].parse::<f64>().unwrap_or(f64::NAN);
166 let Ok(date) = NaiveDate::parse_from_str(f[0], "%Y-%m-%d") else {
167 continue;
168 };
169 let bar = OHLCV {
170 open: num(1),
171 high: num(2),
172 low: num(3),
173 close: num(4),
174 volume: num(5),
175 };
176 if [bar.open, bar.high, bar.low, bar.close]
177 .iter()
178 .all(|v| v.is_finite() && *v > 0.0)
179 {
180 bars.push(DatedBar { date, bar });
181 }
182 }
183 Ok(bars)
184}
185
186pub fn fetch_price_scale(client: &Client, symbol: &str, range: &str) -> Result<Vec<(NaiveDate, f64)>> {
195 let url = format!(
196 "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1d&range={range}&includeAdjustedClose=true&events=split"
197 );
198 let body: Value = client
199 .get(&url)
200 .header("User-Agent", "Mozilla/5.0")
201 .timeout(Duration::from_secs(30))
202 .send()?
203 .json()?;
204 let result = &body["chart"]["result"][0];
205 let ts = result["timestamp"].as_array().ok_or_else(|| anyhow!("no timestamps"))?;
206 let close = result["indicators"]["quote"][0]["close"].as_array().cloned().unwrap_or_default();
207 let adj = result["indicators"]["adjclose"][0]["adjclose"].as_array().cloned().unwrap_or_default();
208 let mut splits: Vec<(i64, f64)> = result["events"]["splits"]
209 .as_object()
210 .map(|m| {
211 m.values()
212 .filter_map(|s| Some((s["date"].as_i64()?, s["numerator"].as_f64()? / s["denominator"].as_f64()?)))
213 .filter(|(_, r)| r.is_finite() && *r > 0.0)
214 .collect()
215 })
216 .unwrap_or_default();
217 splits.sort_by_key(|s| s.0);
218 let mut out = Vec::new();
219 for (i, t) in ts.iter().enumerate() {
220 let (Some(t), Some(c), Some(a)) = (t.as_i64(), close.get(i).and_then(Value::as_f64), adj.get(i).and_then(Value::as_f64)) else { continue };
221 if c <= 0.0 || a <= 0.0 {
222 continue;
223 }
224 let later: f64 = splits.iter().filter(|(d, _)| *d > t).map(|(_, r)| r).product();
225 let Some(date) = DateTime::from_timestamp(t, 0).map(|d| d.date_naive()) else { continue };
226 out.push((date, c * later / a));
227 }
228 out.dedup_by(|b, a| {
229 if a.0 == b.0 {
230 *a = *b;
231 true
232 } else {
233 false
234 }
235 });
236 Ok(out)
237}
238
239pub fn save_scale(path: &Path, rows: &[(NaiveDate, f64)]) -> Result<()> {
241 if let Some(parent) = path.parent() {
242 fs::create_dir_all(parent)?;
243 }
244 let mut out = String::from("date,scale\n");
245 for (d, s) in rows {
246 out.push_str(&format!("{d},{s:.8}\n"));
247 }
248 fs::write(path, out).with_context(|| format!("writing {}", path.display()))
249}
250
251pub fn load_scale(path: &Path) -> Result<Vec<(NaiveDate, f64)>> {
252 let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
253 Ok(text
254 .lines()
255 .skip(1)
256 .filter_map(|l| {
257 let (d, s) = l.split_once(',')?;
258 Some((NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()?, s.parse().ok()?))
259 })
260 .collect())
261}