Skip to main content

finance_data/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub mod surface;
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashSet;
7use video_analysis_core::{DetectError, Result};
8
9#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10#[serde(rename_all = "camelCase")]
11pub struct Instrument {
12    pub id: String,
13    pub symbol: String,
14    pub name: Option<String>,
15    pub exchange: Option<String>,
16    pub currency: Option<String>,
17    pub asset_class: AssetClass,
18}
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum AssetClass {
23    Equity,
24    Etf,
25    Fund,
26    Index,
27    Future,
28    Option,
29    Crypto,
30    Forex,
31    Bond,
32    #[default]
33    Other,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct OhlcvBar {
39    pub timestamp_ms: i64,
40    pub open: f64,
41    pub high: f64,
42    pub low: f64,
43    pub close: f64,
44    pub volume: Option<f64>,
45    pub adjusted_close: Option<f64>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub struct Quote {
51    pub timestamp_ms: i64,
52    pub bid: Option<f64>,
53    pub ask: Option<f64>,
54    pub last: Option<f64>,
55    pub size: Option<f64>,
56}
57
58#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
59#[serde(rename_all = "camelCase")]
60pub struct CorporateAction {
61    pub timestamp_ms: i64,
62    pub kind: CorporateActionKind,
63}
64
65#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
66#[serde(
67    tag = "kind",
68    rename_all = "kebab-case",
69    rename_all_fields = "camelCase"
70)]
71pub enum CorporateActionKind {
72    Split { ratio: f64 },
73    Dividend { amount: f64 },
74}
75
76#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct FinanceSeries {
79    pub instrument: Instrument,
80    pub bars: Vec<OhlcvBar>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
84#[serde(rename_all = "camelCase")]
85pub struct FinanceBounds {
86    pub start_ms: i64,
87    pub end_ms: i64,
88    pub min_price: f64,
89    pub max_price: f64,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
93#[serde(rename_all = "camelCase")]
94pub struct RiskSummaryOptions {
95    #[serde(default)]
96    pub adjusted: bool,
97    #[serde(default = "default_periods_per_year")]
98    pub periods_per_year: f64,
99    #[serde(default = "default_confidence")]
100    pub confidence: f64,
101    #[serde(default)]
102    pub risk_free_return_per_period: f64,
103}
104
105impl Default for RiskSummaryOptions {
106    fn default() -> Self {
107        Self {
108            adjusted: false,
109            periods_per_year: default_periods_per_year(),
110            confidence: default_confidence(),
111            risk_free_return_per_period: 0.0,
112        }
113    }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub struct RiskSummary {
119    pub mean_return: f64,
120    pub std_dev: f64,
121    pub annualized_return: f64,
122    pub annualized_volatility: f64,
123    pub sharpe_ratio: Option<f64>,
124    pub sortino_ratio: Option<f64>,
125    pub value_at_risk: f64,
126    pub conditional_value_at_risk: f64,
127    pub max_drawdown: DrawdownSummary,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
131#[serde(rename_all = "camelCase")]
132pub struct DrawdownSummary {
133    pub depth: f64,
134    pub peak_index: usize,
135    pub trough_index: usize,
136    pub recovery_index: Option<usize>,
137}
138
139#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
140#[serde(rename_all = "camelCase")]
141pub struct LoadBarsRequest {
142    pub instrument: Instrument,
143    pub start_ms: Option<i64>,
144    pub end_ms: Option<i64>,
145}
146
147pub trait FinanceDataProvider {
148    fn load_bars(&self, request: LoadBarsRequest) -> Result<FinanceSeries>;
149}
150
151#[derive(Debug, Clone, PartialEq)]
152pub struct FinanceSeriesIndex {
153    series: FinanceSeries,
154}
155
156impl FinanceSeriesIndex {
157    pub fn new(mut series: FinanceSeries) -> Result<Self> {
158        validate_instrument(&series.instrument)?;
159        series.bars.sort_by_key(|bar| bar.timestamp_ms);
160        validate_bars(&series.bars)?;
161        Ok(Self { series })
162    }
163
164    pub fn series(&self) -> &FinanceSeries {
165        &self.series
166    }
167
168    pub fn bounds(&self) -> Option<FinanceBounds> {
169        let first = self.series.bars.first()?;
170        let last = self.series.bars.last()?;
171        let mut min_price = f64::INFINITY;
172        let mut max_price = f64::NEG_INFINITY;
173
174        for bar in &self.series.bars {
175            min_price = min_price.min(bar.low);
176            max_price = max_price.max(bar.high);
177        }
178
179        Some(FinanceBounds {
180            start_ms: first.timestamp_ms,
181            end_ms: last.timestamp_ms,
182            min_price,
183            max_price,
184        })
185    }
186
187    pub fn bars_in_range(&self, start_ms: i64, end_ms: i64) -> Vec<OhlcvBar> {
188        if start_ms > end_ms {
189            return Vec::new();
190        }
191
192        self.series
193            .bars
194            .iter()
195            .copied()
196            .filter(|bar| bar.timestamp_ms >= start_ms && bar.timestamp_ms <= end_ms)
197            .collect()
198    }
199
200    pub fn downsample_ohlcv(
201        &self,
202        start_ms: i64,
203        end_ms: i64,
204        target_count: usize,
205    ) -> Result<Vec<OhlcvBar>> {
206        if target_count == 0 {
207            return Err(invalid_argument("target count must be greater than zero"));
208        }
209        let bars = self.bars_in_range(start_ms, end_ms);
210        if bars.len() <= target_count {
211            return Ok(bars);
212        }
213
214        let bucket_count = target_count.min(bars.len());
215        let mut downsampled = Vec::with_capacity(bucket_count);
216
217        for bucket_index in 0..bucket_count {
218            let start = bucket_index * bars.len() / bucket_count;
219            let end = ((bucket_index + 1) * bars.len() / bucket_count).max(start + 1);
220            downsampled.push(aggregate_ohlcv_bucket(&bars[start..end])?);
221        }
222
223        Ok(downsampled)
224    }
225
226    pub fn close_prices(&self) -> Vec<f64> {
227        self.series.bars.iter().map(|bar| bar.close).collect()
228    }
229
230    pub fn adjusted_close_prices(&self) -> Vec<f64> {
231        self.series
232            .bars
233            .iter()
234            .map(|bar| bar.adjusted_close.unwrap_or(bar.close))
235            .collect()
236    }
237
238    pub fn simple_returns(&self, adjusted: bool) -> Result<Vec<f64>> {
239        let prices = self.prices(adjusted);
240        finance_statistics::simple_returns(&prices)
241    }
242
243    pub fn log_returns(&self, adjusted: bool) -> Result<Vec<f64>> {
244        let prices = self.prices(adjusted);
245        finance_statistics::log_returns(&prices)
246    }
247
248    pub fn risk_summary(&self, options: RiskSummaryOptions) -> Result<RiskSummary> {
249        let returns = self.simple_returns(options.adjusted)?;
250        let historical =
251            finance_statistics::historical_value_at_risk(&returns, options.confidence)?;
252        let drawdown = finance_statistics::max_drawdown(&returns)?;
253
254        Ok(RiskSummary {
255            mean_return: finance_statistics::mean_return(&returns)?,
256            std_dev: finance_statistics::std_dev(
257                &returns,
258                finance_statistics::VarianceMode::Sample,
259            )?,
260            annualized_return: finance_statistics::annualized_return(
261                &returns,
262                options.periods_per_year,
263            )?,
264            annualized_volatility: finance_statistics::annualized_volatility(
265                &returns,
266                options.periods_per_year,
267            )?,
268            sharpe_ratio: finance_statistics::sharpe_ratio(
269                &returns,
270                options.risk_free_return_per_period,
271                options.periods_per_year,
272            )
273            .ok(),
274            sortino_ratio: finance_statistics::sortino_ratio(
275                &returns,
276                options.risk_free_return_per_period,
277                options.periods_per_year,
278            )
279            .ok(),
280            value_at_risk: historical.value_at_risk,
281            conditional_value_at_risk: historical.conditional_value_at_risk,
282            max_drawdown: DrawdownSummary {
283                depth: drawdown.depth,
284                peak_index: drawdown.peak_index,
285                trough_index: drawdown.trough_index,
286                recovery_index: drawdown.recovery_index,
287            },
288        })
289    }
290
291    fn prices(&self, adjusted: bool) -> Vec<f64> {
292        if adjusted {
293            self.adjusted_close_prices()
294        } else {
295            self.close_prices()
296        }
297    }
298}
299
300pub fn parse_ohlcv_json(input: &str) -> Result<FinanceSeries> {
301    serde_json::from_str(input).map_err(|error| invalid_argument(format!("invalid JSON: {error}")))
302}
303
304fn aggregate_ohlcv_bucket(bars: &[OhlcvBar]) -> Result<OhlcvBar> {
305    let first = bars
306        .first()
307        .ok_or_else(|| invalid_argument("cannot aggregate an empty OHLCV bucket"))?;
308    let last = bars
309        .last()
310        .ok_or_else(|| invalid_argument("cannot aggregate an empty OHLCV bucket"))?;
311    let high = bars
312        .iter()
313        .map(|bar| bar.high)
314        .fold(f64::NEG_INFINITY, f64::max);
315    let low = bars.iter().map(|bar| bar.low).fold(f64::INFINITY, f64::min);
316    let volume = bars
317        .iter()
318        .any(|bar| bar.volume.is_some())
319        .then(|| bars.iter().filter_map(|bar| bar.volume).sum());
320    let adjusted_close = last
321        .adjusted_close
322        .or_else(|| bars.iter().rev().find_map(|bar| bar.adjusted_close));
323
324    Ok(OhlcvBar {
325        timestamp_ms: first.timestamp_ms,
326        open: first.open,
327        high,
328        low,
329        close: last.close,
330        volume,
331        adjusted_close,
332    })
333}
334
335fn validate_instrument(instrument: &Instrument) -> Result<()> {
336    if instrument.symbol.trim().is_empty() {
337        return Err(invalid_argument("instrument symbol must not be empty"));
338    }
339    Ok(())
340}
341
342fn validate_bars(bars: &[OhlcvBar]) -> Result<()> {
343    let mut seen = HashSet::with_capacity(bars.len());
344    let mut last_timestamp = None;
345
346    for bar in bars {
347        validate_bar(bar)?;
348
349        if !seen.insert(bar.timestamp_ms) {
350            return Err(invalid_argument("bar timestamps must be unique"));
351        }
352        if let Some(previous) = last_timestamp {
353            if bar.timestamp_ms < previous {
354                return Err(invalid_argument("bars must be sorted ascending"));
355            }
356        }
357        last_timestamp = Some(bar.timestamp_ms);
358    }
359
360    Ok(())
361}
362
363fn validate_bar(bar: &OhlcvBar) -> Result<()> {
364    validate_positive_price(bar.open, "open")?;
365    validate_positive_price(bar.high, "high")?;
366    validate_positive_price(bar.low, "low")?;
367    validate_positive_price(bar.close, "close")?;
368
369    if bar.high < bar.open.max(bar.close).max(bar.low) {
370        return Err(invalid_argument(
371            "high must be greater than or equal to open, close, and low",
372        ));
373    }
374    if bar.low > bar.open.min(bar.close).min(bar.high) {
375        return Err(invalid_argument(
376            "low must be less than or equal to open, close, and high",
377        ));
378    }
379    if let Some(volume) = bar.volume {
380        if !volume.is_finite() || volume < 0.0 {
381            return Err(invalid_argument("volume must be finite and non-negative"));
382        }
383    }
384    if let Some(adjusted_close) = bar.adjusted_close {
385        validate_positive_price(adjusted_close, "adjusted close")?;
386    }
387
388    Ok(())
389}
390
391fn validate_positive_price(value: f64, name: &str) -> Result<()> {
392    if !value.is_finite() || value <= 0.0 {
393        return Err(invalid_argument(format!(
394            "{name} must be finite and positive"
395        )));
396    }
397    Ok(())
398}
399
400fn invalid_argument(message: impl Into<String>) -> DetectError {
401    DetectError::InvalidArgument(message.into())
402}
403
404fn default_periods_per_year() -> f64 {
405    252.0
406}
407
408fn default_confidence() -> f64 {
409    0.95
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn instrument() -> Instrument {
417        Instrument {
418            id: "aapl".to_string(),
419            symbol: "AAPL".to_string(),
420            name: Some("Apple Inc.".to_string()),
421            exchange: Some("NASDAQ".to_string()),
422            currency: Some("USD".to_string()),
423            asset_class: AssetClass::Equity,
424        }
425    }
426
427    fn bar(timestamp_ms: i64, open: f64, high: f64, low: f64, close: f64) -> OhlcvBar {
428        OhlcvBar {
429            timestamp_ms,
430            open,
431            high,
432            low,
433            close,
434            volume: Some(10.0),
435            adjusted_close: Some(close - 1.0),
436        }
437    }
438
439    fn index() -> FinanceSeriesIndex {
440        FinanceSeriesIndex::new(FinanceSeries {
441            instrument: instrument(),
442            bars: vec![
443                bar(1, 100.0, 110.0, 99.0, 108.0),
444                bar(2, 108.0, 112.0, 105.0, 106.0),
445                bar(3, 106.0, 109.0, 101.0, 102.0),
446                bar(4, 102.0, 120.0, 100.0, 118.0),
447            ],
448        })
449        .unwrap()
450    }
451
452    #[test]
453    fn accepts_and_sorts_valid_bars() {
454        let index = FinanceSeriesIndex::new(FinanceSeries {
455            instrument: instrument(),
456            bars: vec![bar(2, 10.0, 12.0, 9.0, 11.0), bar(1, 9.0, 10.0, 8.0, 10.0)],
457        })
458        .unwrap();
459        assert_eq!(index.series().bars[0].timestamp_ms, 1);
460    }
461
462    #[test]
463    fn rejects_invalid_bars() {
464        assert!(FinanceSeriesIndex::new(FinanceSeries {
465            instrument: instrument(),
466            bars: vec![bar(1, 1.0, 2.0, 0.5, 1.5), bar(1, 1.0, 2.0, 0.5, 1.5)]
467        })
468        .is_err());
469
470        assert!(FinanceSeriesIndex::new(FinanceSeries {
471            instrument: instrument(),
472            bars: vec![bar(1, f64::NAN, 2.0, 0.5, 1.5)]
473        })
474        .is_err());
475
476        assert!(FinanceSeriesIndex::new(FinanceSeries {
477            instrument: instrument(),
478            bars: vec![bar(1, 10.0, 9.0, 8.0, 10.0)]
479        })
480        .is_err());
481
482        let mut negative_volume = bar(1, 10.0, 11.0, 9.0, 10.0);
483        negative_volume.volume = Some(-1.0);
484        assert!(FinanceSeriesIndex::new(FinanceSeries {
485            instrument: instrument(),
486            bars: vec![negative_volume]
487        })
488        .is_err());
489    }
490
491    #[test]
492    fn ranges_and_empty_queries_work() {
493        let index = index();
494        assert_eq!(index.bars_in_range(2, 3).len(), 2);
495        assert!(index.bars_in_range(5, 6).is_empty());
496        let bounds = index.bounds().unwrap();
497        assert_eq!(bounds.start_ms, 1);
498        assert_eq!(bounds.end_ms, 4);
499        assert_eq!(bounds.min_price, 99.0);
500        assert_eq!(bounds.max_price, 120.0);
501    }
502
503    #[test]
504    fn downsamples_ohlcv_without_averaging_prices() {
505        let bars = index().downsample_ohlcv(1, 4, 2).unwrap();
506        assert_eq!(bars.len(), 2);
507        assert_eq!(bars[0].timestamp_ms, 1);
508        assert_eq!(bars[0].open, 100.0);
509        assert_eq!(bars[0].high, 112.0);
510        assert_eq!(bars[0].low, 99.0);
511        assert_eq!(bars[0].close, 106.0);
512        assert_eq!(bars[0].volume, Some(20.0));
513        assert_eq!(bars[0].adjusted_close, Some(105.0));
514    }
515
516    #[test]
517    fn computes_returns_and_risk_with_adjusted_mode() {
518        let index = index();
519        assert_eq!(index.simple_returns(false).unwrap().len(), 3);
520        assert_eq!(index.log_returns(true).unwrap().len(), 3);
521        let risk = index.risk_summary(RiskSummaryOptions::default()).unwrap();
522        assert!(risk.annualized_volatility > 0.0);
523        assert!(risk.value_at_risk >= 0.0);
524    }
525
526    #[test]
527    fn parses_provider_neutral_json() {
528        let json = serde_json::to_string(&FinanceSeries {
529            instrument: instrument(),
530            bars: vec![bar(1, 10.0, 11.0, 9.0, 10.5)],
531        })
532        .unwrap();
533        let parsed = parse_ohlcv_json(&json).unwrap();
534        assert_eq!(parsed.bars.len(), 1);
535    }
536}