bitcoin-heuristics 0.1.0

Pattern detection heuristics for Bitcoin on-chain analytics — consolidations, distributions, CoinJoin, fee spikes, dormant supply reactivation and more
Documentation
use crate::models::UtxoAgeVariance;

/// log2(1 + 1_500_000) — ceiling for ~29 years of blocks (~1.5M blocks).
const LOG2_MAX_AGE: f64 = 20.516_f64;

/// Encodes the age of a UTXO in blocks as an 8-bit tier via logarithmic scale.
///
/// Formula: `tier = clamp(floor(255 × log₂(1 + age_blocks) / log₂(1 + 1_500_000)), 0, 255)`
///
/// Uniform distribution of 256 bands between 0 blocks and ~1.5M blocks (~29 years),
/// with high resolution in recent ages and progressive compression in ancient eras.
pub fn encode_utxo_age_log(age_blocks: i64) -> u8 {
    if age_blocks <= 0 {
        return 0;
    }
    let log_val = (1.0 + age_blocks as f64).log2();
    ((255.0 * log_val / LOG2_MAX_AGE).round() as i32).clamp(0, 255) as u8
}

pub fn calculate_utxo_age_variance(ages_blocks: &[i64], blocks_per_day: u32) -> UtxoAgeVariance {
    if ages_blocks.is_empty() {
        return UtxoAgeVariance::Uniform;
    }

    let n = ages_blocks.len() as f64;
    let mean = ages_blocks.iter().sum::<i64>() as f64 / n;
    let variance = ages_blocks
        .iter()
        .map(|&age| {
            let diff = age as f64 - mean;
            diff * diff
        })
        .sum::<f64>()
        / n;

    let variance_days = variance.sqrt() / blocks_per_day as f64;

    if variance_days < 7.0 {
        UtxoAgeVariance::Uniform
    } else if variance_days < 90.0 {
        UtxoAgeVariance::Mixed
    } else if variance_days < 365.0 {
        UtxoAgeVariance::Dispersed
    } else {
        UtxoAgeVariance::HighlyDispersed
    }
}

/// Returns (age_max_tier, age_median_tier, variance) as (u8, u8, UtxoAgeVariance).
/// The 8-bit tiers use log-scale encoding (see encode_utxo_age_log).
pub fn analyze_utxo_ages(ages_blocks: &[i64], blocks_per_day: u32) -> (u8, u8, UtxoAgeVariance) {
    if ages_blocks.is_empty() {
        return (0u8, 0u8, UtxoAgeVariance::Uniform);
    }

    let mut sorted = ages_blocks.to_vec();
    sorted.sort_unstable();

    let max = sorted.last().copied().unwrap_or(0);
    let median = if sorted.len() % 2 == 0 {
        (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2
    } else {
        sorted[sorted.len() / 2]
    };

    let max_tier = encode_utxo_age_log(max);
    let median_tier = encode_utxo_age_log(median);
    let variance = calculate_utxo_age_variance(&sorted, blocks_per_day);

    (max_tier, median_tier, variance)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_encode_zero_blocks() {
        assert_eq!(encode_utxo_age_log(0), 0);
        assert_eq!(encode_utxo_age_log(-1), 0);
    }

    #[test]
    fn test_encode_key_thresholds() {
        let day = encode_utxo_age_log(144);
        assert!(
            day >= 85 && day <= 93,
            "1 day: expected tier ~89, obtained {day}"
        );

        let year = encode_utxo_age_log(52_560);
        assert!(
            year >= 191 && year <= 199,
            "1 year: expected tier ~195, obtained {year}"
        );

        let twelve_years = encode_utxo_age_log(630_720);
        assert!(
            twelve_years >= 235 && twelve_years <= 243,
            "12 years: expected tier ~239, obtained {twelve_years}"
        );
    }

    #[test]
    fn test_encode_monotonic() {
        let t1 = encode_utxo_age_log(144);
        let t2 = encode_utxo_age_log(1008);
        let t3 = encode_utxo_age_log(52_560);
        assert!(t1 < t2 && t2 < t3, "encoding must be strictly increasing");
    }

    #[test]
    fn test_encode_max_clamp() {
        let extreme = encode_utxo_age_log(2_000_000);
        assert_eq!(
            extreme, 255,
            "values above the ceiling should be clamped to 255"
        );
    }

    #[test]
    fn test_analyze_utxo_ages_empty() {
        let (max, med, var) = analyze_utxo_ages(&[], 144);
        assert_eq!(max, 0);
        assert_eq!(med, 0);
        assert_eq!(var, UtxoAgeVariance::Uniform);
    }

    #[test]
    fn test_analyze_utxo_ages_single() {
        let ages = vec![52_560i64];
        let (max, med, var) = analyze_utxo_ages(&ages, 144);
        assert_eq!(max, med, "single element: max == median");
        assert_eq!(var, UtxoAgeVariance::Uniform);
    }

    #[test]
    fn test_analyze_variance_uniform() {
        let ages = vec![100i64, 110, 120, 130];
        let (_, _, var) = analyze_utxo_ages(&ages, 144);
        assert_eq!(var, UtxoAgeVariance::Uniform);
    }

    #[test]
    fn test_analyze_variance_highly_dispersed() {
        let ages = vec![144i64, 262_800];
        let (_, _, var) = analyze_utxo_ages(&ages, 144);
        assert_eq!(var, UtxoAgeVariance::HighlyDispersed);
    }
}