fast-ta 0.1.0

High-performance technical analysis indicators with batch, prepared, and streaming APIs
#[path = "fixtures/ht_dcperiod_reference.rs"]
mod reference;

use ta_core::{
    cycle::{
        HT_DCPERIODBatchRunner, HT_DCPERIODConfig, HT_DCPERIODStream, HT_DCPERIOD,
        HT_DCPERIOD_LOOKBACK,
    },
    Float, IndicatorConfig, OutputRange, PreparedBatchRunner, StreamingComputation, TalibError,
};

#[cfg(feature = "f32")]
const ABS_TOLERANCE: Float = 5.0e-3;
#[cfg(feature = "f32")]
const REL_TOLERANCE: Float = 5.0e-4;
#[cfg(not(feature = "f32"))]
const ABS_TOLERANCE: Float = 1.0e-9;
#[cfg(not(feature = "f32"))]
const REL_TOLERANCE: Float = 1.0e-12;

fn assert_close(actual: Float, expected: f64, context: &str) {
    let expected = expected as Float;
    let difference = (actual - expected).abs();
    let tolerance = ABS_TOLERANCE.max(REL_TOLERANCE * expected.abs());
    assert!(
        difference <= tolerance,
        "{context}: expected {expected}, got {actual}, difference {difference}, tolerance {tolerance}"
    );
}

fn assert_values_close(actual: &[Float], expected: &[f64], context: &str) {
    assert_eq!(actual.len(), expected.len(), "{context}");
    for (index, (&actual, &expected)) in actual.iter().zip(expected).enumerate() {
        assert_close(
            actual,
            expected,
            &format!("{context}, compact index {index}"),
        );
    }
}

fn as_float(values: &[f64]) -> Vec<Float> {
    values.iter().map(|&value| value as Float).collect()
}

#[test]
fn ht_dcperiod_matches_pinned_talib_vectors_in_every_execution_mode() {
    assert_eq!(reference::TALIB_VERSION, "0.6.4");
    assert_eq!(
        reference::TALIB_GIT_REVISION,
        "43f9d5042ecc4bd367941846494ad907bf20ea50"
    );
    assert_eq!(
        reference::TALIB_SOURCE_ARCHIVE_SHA256,
        "aa04066d17d69c73b1baaef0883414d3d56ab3775872d82916d1cdb376a3ae86"
    );
    assert_eq!(reference::OUTPUT_BEGIN, HT_DCPERIOD_LOOKBACK);
    assert_eq!(reference::SOURCE_LENGTH, 256);
    assert_eq!(reference::NOISE_SEED, 0x5EED_C0DE);

    let config = HT_DCPERIODConfig::new();
    assert_eq!(config.lookback(), HT_DCPERIOD_LOOKBACK);

    for case in reference::CASES {
        let input = as_float(case.input);
        let range = OutputRange::new(HT_DCPERIOD_LOOKBACK, case.expected.len());
        let context = format!("{} ({})", case.name, case.definition);

        let owned = config.compute(input.as_slice()).unwrap();
        assert_eq!(owned.source_len(), input.len(), "{context}, owned");
        assert_eq!(owned.range(), range, "{context}, owned");
        assert_values_close(owned.values(), case.expected, &format!("{context}, owned"));

        let mut caller_output = vec![0.0 as Float; case.expected.len()];
        assert_eq!(
            HT_DCPERIOD(input.as_slice(), caller_output.as_mut_slice()).unwrap(),
            range,
            "{context}, caller-owned"
        );
        assert_values_close(
            &caller_output,
            case.expected,
            &format!("{context}, caller-owned"),
        );

        let mut runner = config.prepare_batch(input.len()).unwrap();
        assert_eq!(runner.max_input_len(), input.len());
        let mut prepared_output = vec![0.0 as Float; case.expected.len()];
        for pass in ["first", "repeated"] {
            assert_eq!(
                runner
                    .compute_into(input.as_slice(), prepared_output.as_mut_slice())
                    .unwrap(),
                range,
                "{context}, prepared {pass}"
            );
            assert_values_close(
                &prepared_output,
                case.expected,
                &format!("{context}, prepared {pass}"),
            );
        }

        let mut stream = config.stream().unwrap();
        let streamed = input
            .iter()
            .copied()
            .filter_map(|tick| stream.next(tick).unwrap())
            .collect::<Vec<_>>();
        assert_values_close(&streamed, case.expected, &format!("{context}, streaming"));

        stream.reset();
        let replayed = input
            .iter()
            .copied()
            .filter_map(|tick| stream.next(tick).unwrap())
            .collect::<Vec<_>>();
        assert_eq!(streamed, replayed, "{context}, reset replay");
    }
}

#[test]
fn ht_dcperiod_reports_validation_and_capacity_failures_without_mutating_state() {
    let config = HT_DCPERIODConfig::new();
    let empty = config.compute(&[]).unwrap();
    assert_eq!(empty.range(), OutputRange::empty());
    assert!(empty.values().is_empty());

    assert_eq!(
        config
            .compute(&[1.0 as Float; HT_DCPERIOD_LOOKBACK])
            .unwrap_err(),
        TalibError::InsufficientData {
            required: HT_DCPERIOD_LOOKBACK + 1,
            actual: HT_DCPERIOD_LOOKBACK,
        }
    );

    let valid = [1.0 as Float; HT_DCPERIOD_LOOKBACK + 1];
    let mut missing_output = [];
    assert!(matches!(
        config
            .compute_into(&valid, &mut missing_output)
            .unwrap_err(),
        TalibError::InvalidInput { .. }
    ));

    let mut runner = config.prepare_batch(valid.len() - 1).unwrap();
    let mut output = [123.0 as Float; 1];
    assert_eq!(
        runner.compute_into(&valid, &mut output).unwrap_err(),
        TalibError::PreparedCapacityExceeded {
            max_input_len: valid.len() - 1,
            actual_input_len: valid.len(),
        }
    );
    assert_eq!(output, [123.0 as Float; 1]);

    let case = &reference::CASES[2];
    let input = as_float(case.input);
    let mut stream = config.stream().unwrap();
    assert!(matches!(
        stream.next(Float::NAN).unwrap_err(),
        TalibError::InvalidInput { .. }
    ));
    let streamed = input
        .iter()
        .copied()
        .filter_map(|tick| stream.next(tick).unwrap())
        .collect::<Vec<_>>();
    assert_values_close(
        &streamed,
        case.expected,
        "streaming after rejected non-finite tick",
    );

    let mut batch_output = vec![123.0 as Float; case.expected.len()];
    let mut non_finite = input;
    non_finite[40] = Float::INFINITY;
    assert!(matches!(
        config
            .compute_into(&non_finite, batch_output.as_mut_slice())
            .unwrap_err(),
        TalibError::InvalidInput { .. }
    ));
    assert!(batch_output.iter().all(|&value| value == 123.0 as Float));
}

#[test]
fn ht_dcperiod_converges_on_known_stationary_cycles() {
    let config = HT_DCPERIODConfig::new();
    for expected_period in [10.0 as Float, 20.0 as Float, 40.0 as Float] {
        let input = (0..512)
            .map(|index| {
                100.0 as Float
                    + 7.0 as Float
                        * (2.0 as Float * core::f64::consts::PI as Float * index as Float
                            / expected_period)
                            .sin()
            })
            .collect::<Vec<_>>();
        let output = config.compute(input.as_slice()).unwrap();
        let settled = &output.values()[output.values().len() - 64..];
        let estimated = settled.iter().sum::<Float>() / settled.len() as Float;
        assert!(
            (estimated - expected_period).abs() <= 1.5 as Float,
            "expected period {expected_period}, estimated {estimated}"
        );
        assert!(
            output
                .values()
                .iter()
                .all(|&period| (6.0 as Float..=50.0 as Float).contains(&period)),
            "period bounds for {expected_period}"
        );
    }
}

fn assert_execution_types<C, R, S>()
where
    C: IndicatorConfig<BatchRunner = R, Stream = S>,
    R: PreparedBatchRunner<C>,
    S: StreamingComputation<C>,
{
}

#[test]
fn ht_dcperiod_execution_types_are_publicly_wired() {
    assert_execution_types::<HT_DCPERIODConfig, HT_DCPERIODBatchRunner, HT_DCPERIODStream>();
}