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 evidence_chain::{EvidenceCategory, EvidenceChain, EvidenceLink};

use crate::heuristics::{Heuristic, HeuristicStatus};
use crate::match_result::HeuristicMatch;
use crate::models::{TxFeatures, UtxoAgeVariance};

/// Detects "Sniper" type consolidation: 100+ fresh inputs (≤7 days) into ≤3 outputs.
///
/// Pattern: coordinated accumulation of recent UTXOs merged into a single destination.
/// Typically observed in sniper bots / tactical accumulators that consolidate
/// position after an intense acquisition period.
///
/// **Trigger scope: block** — the pattern is evaluated per tx but belongs to the block scope.
pub struct HftConsolidationHeuristic;

impl Heuristic for HftConsolidationHeuristic {
    fn id(&self) -> &'static str {
        "hft-consolidation-v1"
    }

    fn version(&self) -> &'static str {
        "1.0.0"
    }

    fn trigger_scope(&self) -> &'static str {
        "block"
    }

    fn status(&self) -> HeuristicStatus {
        HeuristicStatus::Experimental
    }

    fn evaluate(&self, f: &TxFeatures) -> Option<HeuristicMatch> {
        if f.is_coinbase {
            return None;
        }
        if f.input_count < 100 {
            return None;
        }
        if f.output_count > 3 {
            return None;
        }
        let btc_price = f.block_price_usd?;
        let total_btc = f.total_input_value as f64 / 100_000_000.0;
        let total_usd = total_btc * btc_price;
        if total_usd < 25_000.0 {
            return None;
        }
        if f.utxo_ages_blocks.is_empty() {
            return None;
        }
        let max_age = f.utxo_ages_blocks.values().copied().max().unwrap_or(0);
        if max_age > 1008 {
            return None;
        }
        if matches!(
            f.utxo_age_variance,
            UtxoAgeVariance::Dispersed | UtxoAgeVariance::HighlyDispersed
        ) {
            return None;
        }

        let summary = format!(
            "HFT consolidation: {} fresh inputs (max age {} blocks, ≤1008) \{} outputs (${:.0} USD) at block {}",
            f.input_count, max_age, f.output_count, total_usd, f.block_height
        );

        Some(HeuristicMatch::new(
            self.id(),
            self.version(),
            "hft_consolidation",
            "coordinated_fresh_consolidation",
            self.trigger_scope(),
            summary,
            serde_json::json!({
                "input_count": f.input_count,
                "output_count": f.output_count,
                "total_input_value": f.total_input_value,
                "max_utxo_age_blocks": max_age,
                "utxo_age_variance": format!("{:?}", f.utxo_age_variance),
                "fee_rate_sat_vb": f.fee_rate_sat_vb,
            }),
        ))
    }

    fn build_evidence(&self, f: &TxFeatures) -> Option<EvidenceChain> {
        if f.is_coinbase || f.input_count < 100 || f.utxo_ages_blocks.is_empty() {
            return None;
        }

        let btc_price = f.block_price_usd?;
        let total_btc = f.total_input_value as f64 / 100_000_000.0;
        let total_usd = total_btc * btc_price;
        if total_usd < 25_000.0 {
            return None;
        }

        let max_age = f.utxo_ages_blocks.values().copied().max().unwrap_or(0);
        if max_age > 1008 {
            return None;
        }

        let txid_hex: String = f.txid.iter().rev().map(|b| format!("{b:02x}")).collect();
        let mut chain = EvidenceChain::new(self.id(), self.version());

        chain.add_link(
            EvidenceLink::new(
                EvidenceCategory::Structural,
                "Tactical accumulation (over $25,000 USD)".to_string(),
                txid_hex.clone(),
            )
            .with_metric(total_usd, "USD")
            .with_threshold(25_000.0, total_usd >= 25_000.0),
        );

        chain.add_link(
            EvidenceLink::new(
                EvidenceCategory::Structural,
                format!(
                    "{} inputs consolidated into {} outputs (HFT pattern)",
                    f.input_count, f.output_count
                ),
                txid_hex.clone(),
            )
            .with_metric(f.input_count as f64, "inputs")
            .with_threshold(100.0, true),
        );

        chain.add_link(
            EvidenceLink::new(
                EvidenceCategory::Temporal,
                format!(
                    "All sampled inputs fresh: max age {} blocks (≤1008 = 7 days)",
                    max_age
                ),
                txid_hex,
            )
            .with_metric(max_age as f64, "blocks")
            .with_threshold(1008.0, true),
        );

        chain.finalize();
        Some(chain)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Utc;
    use std::collections::HashMap;

    fn make_base() -> TxFeatures {
        let mut ages = HashMap::new();
        for i in 0u32..100 {
            ages.insert((vec![i as u8; 32], 0u32), 500i64);
        }
        TxFeatures {
            txid: vec![0xaau8; 32],
            block_height: 840_000,
            block_timestamp: Utc::now(),
            input_count: 100,
            output_count: 1,
            is_coinbase: false,
            total_input_value: 50_000_000,
            utxo_ages_blocks: ages,
            utxo_age_variance: UtxoAgeVariance::Uniform,
            block_price_usd: Some(60_000.0),
            ..Default::default()
        }
    }

    #[test]
    fn test_sniper_detected() {
        let h = HftConsolidationHeuristic;
        let f = make_base();
        let result = h.evaluate(&f);
        assert!(result.is_some(), "HFT should fire with correct features");
        let r = result.unwrap();
        assert_eq!(r.event_type, "hft_consolidation");
        assert_eq!(r.trigger_scope, "block");
    }

    #[test]
    fn test_sniper_too_few_inputs() {
        let h = HftConsolidationHeuristic;
        let mut f = make_base();
        f.input_count = 50;
        assert!(h.evaluate(&f).is_none(), "50 inputs < 100 should not fire");
    }

    #[test]
    fn test_sniper_old_utxos() {
        let h = HftConsolidationHeuristic;
        let mut f = make_base();
        f.utxo_ages_blocks.insert((vec![0xffu8; 32], 0), 2000i64);
        assert!(
            h.evaluate(&f).is_none(),
            "max_age 2000 > 1008 should not fire"
        );
    }

    #[test]
    fn test_sniper_too_many_outputs() {
        let h = HftConsolidationHeuristic;
        let mut f = make_base();
        f.output_count = 5;
        assert!(
            h.evaluate(&f).is_none(),
            "output_count 5 > 3 should not fire"
        );
    }

    #[test]
    fn test_sniper_no_age_data() {
        let h = HftConsolidationHeuristic;
        let mut f = make_base();
        f.utxo_ages_blocks.clear();
        assert!(
            h.evaluate(&f).is_none(),
            "empty utxo_ages_blocks should not fire"
        );
    }

    #[test]
    fn test_sniper_dispersed_variance() {
        let h = HftConsolidationHeuristic;
        let mut f = make_base();
        f.utxo_age_variance = UtxoAgeVariance::HighlyDispersed;
        assert!(h.evaluate(&f).is_none(), "HighlyDispersed should not fire");
    }

    #[test]
    fn test_sniper_build_evidence() {
        let h = HftConsolidationHeuristic;
        let f = make_base();
        let chain = h.build_evidence(&f);
        assert!(chain.is_some(), "build_evidence should return Some");
        let chain = chain.unwrap();
        assert!(chain.links.len() >= 2, "should have ≥2 links");
        let has_structural = chain
            .links
            .iter()
            .any(|l| l.category == EvidenceCategory::Structural);
        let has_temporal = chain
            .links
            .iter()
            .any(|l| l.category == EvidenceCategory::Temporal);
        assert!(has_structural, "should have Structural link");
        assert!(has_temporal, "should have Temporal link");
    }

    #[test]
    fn test_sniper_summary_no_forbidden_words() {
        let h = HftConsolidationHeuristic;
        let f = make_base();
        let result = h.evaluate(&f).unwrap();
        let forbidden = [
            "suspicious",
            "malicious",
            "laundering",
            "illegal",
            "criminal",
            "fraud",
            "scam",
        ];
        for word in &forbidden {
            assert!(
                !result.summary.to_lowercase().contains(word),
                "summary contains forbidden word '{}'",
                word
            );
        }
    }

    #[test]
    fn test_sniper_coinbase_skipped() {
        let h = HftConsolidationHeuristic;
        let mut f = make_base();
        f.is_coinbase = true;
        assert!(h.evaluate(&f).is_none(), "coinbase should be ignored");
    }

    #[test]
    fn test_sniper_below_min_value() {
        let h = HftConsolidationHeuristic;
        let mut f = make_base();
        f.total_input_value = 10_000_000;
        assert!(
            h.evaluate(&f).is_none(),
            "USD value below minimum should not fire"
        );
    }

    #[test]
    fn test_sniper_no_price_skips() {
        let h = HftConsolidationHeuristic;
        let mut f = make_base();
        f.block_price_usd = None;
        assert!(h.evaluate(&f).is_none(), "no USD price should skip");
    }
}