use super::*;
use chrono::{Duration, TimeZone, Utc};
use rust_decimal_macros::dec;
fn candle(
open: Decimal,
high: Decimal,
low: Decimal,
close: Decimal,
volume: Decimal,
ts: chrono::DateTime<Utc>,
) -> Candle {
Candle::new(open, high, low, close, volume, ts).unwrap()
}
fn base_ts() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()
}
#[test]
fn identity_ratio_is_ones() {
let ts = base_ts();
let a = vec![
candle(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts),
candle(
dec!(105),
dec!(115),
dec!(95),
dec!(110),
dec!(1100),
ts + Duration::days(1),
),
];
let ratio = synthesize_ratio_candles(&a, &a).unwrap();
assert_eq!(ratio.len(), 2);
for c in &ratio {
assert_eq!(c.open(), dec!(1));
assert_eq!(c.close(), dec!(1));
assert!(c.high() >= Decimal::ONE);
assert!(c.low() <= Decimal::ONE);
}
}
#[test]
fn simple_two_bar_ratio() {
let ts = base_ts();
let a = vec![candle(
dec!(100),
dec!(110),
dec!(90),
dec!(105),
dec!(1000),
ts,
)];
let b = vec![candle(
dec!(50),
dec!(55),
dec!(45),
dec!(52),
dec!(500),
ts,
)];
let ratio = synthesize_ratio_candles(&a, &b).unwrap();
assert_eq!(ratio.len(), 1);
let c = &ratio[0];
assert_eq!(c.open(), dec!(2));
assert_eq!(c.high(), dec!(110) / dec!(45));
assert_eq!(c.low(), dec!(90) / dec!(55));
assert_eq!(c.close(), dec!(105) / dec!(52));
assert_eq!(c.volume(), dec!(500));
assert_eq!(c.timestamp(), ts);
}
#[test]
fn inverse_ratio_multiplication_yields_one() {
let ts = base_ts();
let a = vec![
candle(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts),
candle(
dec!(105),
dec!(115),
dec!(95),
dec!(108),
dec!(1000),
ts + Duration::days(1),
),
];
let b = vec![
candle(dec!(50), dec!(55), dec!(45), dec!(52), dec!(500), ts),
candle(
dec!(52),
dec!(58),
dec!(48),
dec!(55),
dec!(500),
ts + Duration::days(1),
),
];
let ab = synthesize_ratio_candles(&a, &b).unwrap();
let ba = synthesize_ratio_candles(&b, &a).unwrap();
let epsilon = dec!(0.00000000000000000001);
for (x, y) in ab.iter().zip(ba.iter()) {
let product = x.close() * y.close();
assert!(
(product - Decimal::ONE).abs() < epsilon,
"close product {} deviates from 1 by more than {}",
product,
epsilon
);
let open_product = x.open() * y.open();
assert!(
(open_product - Decimal::ONE).abs() < epsilon,
"open product {} deviates from 1 by more than {}",
open_product,
epsilon
);
}
}
#[test]
fn ratio_high_is_greater_than_close() {
let ts = base_ts();
let a = vec![candle(
dec!(100),
dec!(120),
dec!(80),
dec!(115),
dec!(1000),
ts,
)];
let b = vec![candle(
dec!(50),
dec!(60),
dec!(40),
dec!(48),
dec!(500),
ts,
)];
let ratio = synthesize_ratio_candles(&a, &b).unwrap();
let c = &ratio[0];
let max_oc = c.open().max(c.close());
let min_oc = c.open().min(c.close());
assert!(
c.high() >= max_oc,
"high {} < max(o,c) {}",
c.high(),
max_oc
);
assert!(c.low() <= min_oc, "low {} > min(o,c) {}", c.low(), min_oc);
}
#[test]
fn volume_is_min_of_both_legs() {
let ts = base_ts();
let a = vec![candle(
dec!(100),
dec!(110),
dec!(90),
dec!(105),
dec!(10000),
ts,
)];
let b = vec![candle(
dec!(50),
dec!(55),
dec!(45),
dec!(52),
dec!(300),
ts,
)];
let ratio = synthesize_ratio_candles(&a, &b).unwrap();
assert_eq!(ratio[0].volume(), dec!(300));
let a2 = vec![candle(
dec!(100),
dec!(110),
dec!(90),
dec!(105),
dec!(100),
ts,
)];
let ratio2 = synthesize_ratio_candles(&a2, &b).unwrap();
assert_eq!(ratio2[0].volume(), dec!(100));
}
#[test]
fn empty_input_returns_error() {
let empty: Vec<Candle> = vec![];
let one = vec![candle(
dec!(100),
dec!(110),
dec!(90),
dec!(105),
dec!(1000),
base_ts(),
)];
assert_eq!(
synthesize_ratio_candles(&empty, &one).unwrap_err(),
RatioCandleError::EmptySeries
);
assert_eq!(
synthesize_ratio_candles(&one, &empty).unwrap_err(),
RatioCandleError::EmptySeries
);
assert_eq!(
synthesize_ratio_candles(&empty, &empty).unwrap_err(),
RatioCandleError::EmptySeries
);
}
#[test]
fn length_mismatch_returns_error() {
let ts = base_ts();
let a = vec![
candle(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts),
candle(
dec!(105),
dec!(115),
dec!(95),
dec!(110),
dec!(1100),
ts + Duration::days(1),
),
];
let b = vec![candle(
dec!(50),
dec!(55),
dec!(45),
dec!(52),
dec!(500),
ts,
)];
let err = synthesize_ratio_candles(&a, &b).unwrap_err();
assert!(matches!(
err,
RatioCandleError::LengthMismatch { len_a: 2, len_b: 1 }
));
}
#[test]
fn mismatched_timestamps_at_index() {
let ts = base_ts();
let a = vec![
candle(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts),
candle(
dec!(105),
dec!(115),
dec!(95),
dec!(110),
dec!(1100),
ts + Duration::days(1),
),
];
let b = vec![
candle(dec!(50), dec!(55), dec!(45), dec!(52), dec!(500), ts),
candle(
dec!(52),
dec!(58),
dec!(48),
dec!(55),
dec!(500),
ts + Duration::days(2),
),
];
let err = synthesize_ratio_candles(&a, &b).unwrap_err();
assert_eq!(err, RatioCandleError::MismatchedTimestamps { index: 1 });
}
#[test]
fn division_by_zero_returns_error() {
let ts = base_ts();
let a = vec![candle(
dec!(100),
dec!(110),
dec!(90),
dec!(105),
dec!(1000),
ts,
)];
let b = vec![candle(dec!(50), dec!(55), dec!(45), dec!(0), dec!(500), ts)];
let err = synthesize_ratio_candles(&a, &b).unwrap_err();
assert_eq!(err, RatioCandleError::DivisionByZero { index: 0 });
let b2 = vec![candle(dec!(50), dec!(55), dec!(0), dec!(52), dec!(500), ts)];
let err = synthesize_ratio_candles(&a, &b2).unwrap_err();
assert_eq!(err, RatioCandleError::DivisionByZero { index: 0 });
}
#[test]
fn ratio_close_series_matches_candle_close() {
let ts = base_ts();
let a = vec![
candle(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts),
candle(
dec!(110),
dec!(120),
dec!(100),
dec!(115),
dec!(1000),
ts + Duration::days(1),
),
];
let b = vec![
candle(dec!(50), dec!(55), dec!(45), dec!(52), dec!(500), ts),
candle(
dec!(52),
dec!(58),
dec!(48),
dec!(55),
dec!(500),
ts + Duration::days(1),
),
];
let full = synthesize_ratio_candles(&a, &b).unwrap();
let closes = ratio_close_series(&a, &b).unwrap();
assert_eq!(full.len(), closes.len());
for (c, (ts, val)) in full.iter().zip(closes.iter()) {
assert_eq!(c.timestamp(), *ts);
assert_eq!(c.close(), *val);
}
}
#[test]
fn ratio_close_series_uses_only_close_for_validation() {
let ts = base_ts();
let a = vec![candle(
dec!(100),
dec!(110),
dec!(90),
dec!(105),
dec!(1000),
ts,
)];
let b = vec![candle(dec!(50), dec!(55), dec!(0), dec!(52), dec!(500), ts)];
let result = ratio_close_series(&a, &b);
assert!(result.is_ok());
let closes = result.unwrap();
assert_eq!(closes[0].1, dec!(105) / dec!(52));
}
#[test]
fn many_bars_alignment_preserved() {
let ts = base_ts();
let n = 100;
let a: Vec<Candle> = (0..n)
.map(|i| {
let d = Decimal::from(i);
candle(
dec!(100) + d,
dec!(110) + d,
dec!(90) + d,
dec!(105) + d,
dec!(1000),
ts + Duration::days(i as i64),
)
})
.collect();
let b: Vec<Candle> = (0..n)
.map(|i| {
let d = Decimal::from(i);
candle(
dec!(50) + d,
dec!(55) + d,
dec!(45) + d,
dec!(52) + d,
dec!(500),
ts + Duration::days(i as i64),
)
})
.collect();
let ratio = synthesize_ratio_candles(&a, &b).unwrap();
assert_eq!(ratio.len(), n);
for (i, c) in ratio.iter().enumerate() {
assert_eq!(c.timestamp(), ts + Duration::days(i as i64));
}
}