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}