| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | use std::collections::BTreeMap;
|
| |
|
| |
|
| |
|
| |
|
| | #[derive(Clone, Debug)]
|
| | pub struct RawTick {
|
| | pub price: f64,
|
| | pub time: i64,
|
| | pub index: usize,
|
| | }
|
| |
|
| |
|
| | #[derive(Clone, Debug, Default)]
|
| | pub struct GmpBin {
|
| | pub count: u32,
|
| | }
|
| |
|
| |
|
| | #[derive(Clone, Debug, Default)]
|
| | pub struct FootprintBin {
|
| | pub count: u32,
|
| | pub up: u32,
|
| | pub down: u32,
|
| | }
|
| |
|
| | impl FootprintBin {
|
| | #[inline]
|
| | pub fn delta(&self) -> i32 {
|
| | self.up as i32 - self.down as i32
|
| | }
|
| | }
|
| |
|
| |
|
| | #[derive(Clone, Debug)]
|
| | pub struct GmpProfile {
|
| | pub bins: BTreeMap<i64, GmpBin>,
|
| | pub beta: f64,
|
| | pub min_bin: i64,
|
| | pub max_bin: i64,
|
| | }
|
| |
|
| |
|
| | #[derive(Clone, Debug)]
|
| | pub struct FootprintProfile {
|
| | pub bins: BTreeMap<i64, FootprintBin>,
|
| | pub beta: f64,
|
| | pub min_bin: i64,
|
| | pub max_bin: i64,
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | #[inline]
|
| | pub fn bin_index(price: f64, beta: f64) -> i64 {
|
| | (price / beta).floor() as i64
|
| | }
|
| |
|
| |
|
| | #[inline]
|
| | pub fn bin_price_range(b: i64, beta: f64) -> (f64, f64) {
|
| | (b as f64 * beta, (b + 1) as f64 * beta)
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | pub fn build_gmp(ticks: &[RawTick], beta: f64) -> GmpProfile {
|
| | let mut bins: BTreeMap<i64, GmpBin> = BTreeMap::new();
|
| |
|
| | if ticks.is_empty() {
|
| | return GmpProfile { bins, beta, min_bin: 0, max_bin: 0 };
|
| | }
|
| |
|
| |
|
| | for t in ticks {
|
| | let b = bin_index(t.price, beta);
|
| | bins.entry(b).or_default().count += 1;
|
| | }
|
| |
|
| |
|
| | for pair in ticks.windows(2) {
|
| | let b_from = bin_index(pair[0].price, beta);
|
| | let b_to = bin_index(pair[1].price, beta);
|
| |
|
| | if (b_to - b_from).abs() <= 1 {
|
| | continue;
|
| | }
|
| |
|
| | let dir: i64 = if b_to > b_from { 1 } else { -1 };
|
| | let mut b = b_from + dir;
|
| | while b != b_to {
|
| | bins.entry(b).or_default().count += 1;
|
| | b += dir;
|
| | }
|
| | }
|
| |
|
| | let min_bin = *bins.keys().next().unwrap_or(&0);
|
| | let max_bin = *bins.keys().next_back().unwrap_or(&0);
|
| |
|
| | GmpProfile { bins, beta, min_bin, max_bin }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | pub fn build_footprint(ticks: &[RawTick], beta: f64) -> FootprintProfile {
|
| | let mut bins: BTreeMap<i64, FootprintBin> = BTreeMap::new();
|
| |
|
| | if ticks.is_empty() {
|
| | return FootprintProfile { bins, beta, min_bin: 0, max_bin: 0 };
|
| | }
|
| |
|
| |
|
| | for t in ticks {
|
| | let b = bin_index(t.price, beta);
|
| | bins.entry(b).or_default().count += 1;
|
| | }
|
| | for pair in ticks.windows(2) {
|
| | let b_from = bin_index(pair[0].price, beta);
|
| | let b_to = bin_index(pair[1].price, beta);
|
| | if (b_to - b_from).abs() <= 1 { continue; }
|
| | let dir: i64 = if b_to > b_from { 1 } else { -1 };
|
| | let mut b = b_from + dir;
|
| | while b != b_to {
|
| | bins.entry(b).or_default().count += 1;
|
| | b += dir;
|
| | }
|
| | }
|
| |
|
| |
|
| | for pair in ticks.windows(2) {
|
| | let src_price = pair[0].price;
|
| | let dst_price = pair[1].price;
|
| | let b_from = bin_index(src_price, beta);
|
| | let b_to = bin_index(dst_price, beta);
|
| |
|
| | if b_from == b_to {
|
| |
|
| | let entry = bins.entry(b_from).or_default();
|
| | if dst_price > src_price {
|
| | entry.up += 1;
|
| | } else if dst_price < src_price {
|
| | entry.down += 1;
|
| | }
|
| | continue;
|
| | }
|
| |
|
| | let is_up = b_to > b_from;
|
| | let dir: i64 = if is_up { 1 } else { -1 };
|
| |
|
| |
|
| |
|
| | let mut b = b_from + dir;
|
| | loop {
|
| | let entry = bins.entry(b).or_default();
|
| | if is_up { entry.up += 1; } else { entry.down += 1; }
|
| | if b == b_to { break; }
|
| | b += dir;
|
| | }
|
| | }
|
| |
|
| | let min_bin = *bins.keys().next().unwrap_or(&0);
|
| | let max_bin = *bins.keys().next_back().unwrap_or(&0);
|
| |
|
| | FootprintProfile { bins, beta, min_bin, max_bin }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| | #[derive(Clone, Debug)]
|
| | pub struct IntervalProfile {
|
| | pub gmp: GmpProfile,
|
| | pub footprint: FootprintProfile,
|
| |
|
| | pub label: String,
|
| |
|
| | pub mid_price: f64,
|
| |
|
| | pub start_idx: usize,
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| | pub fn aggregate_by_time(
|
| | ticks: &[RawTick],
|
| | interval_secs: u64,
|
| | beta: f64,
|
| | ) -> Vec<IntervalProfile> {
|
| | if ticks.is_empty() || interval_secs == 0 {
|
| | return Vec::new();
|
| | }
|
| |
|
| | let mut profiles = Vec::new();
|
| | let mut start = 0usize;
|
| | let base_time = ticks[0].time;
|
| |
|
| | while start < ticks.len() {
|
| | let bucket = ((ticks[start].time - base_time) as u64) / interval_secs;
|
| | let bucket_end_time = base_time + ((bucket + 1) * interval_secs) as i64;
|
| |
|
| |
|
| | let mut end = start;
|
| | while end < ticks.len() && ticks[end].time < bucket_end_time {
|
| | end += 1;
|
| | }
|
| |
|
| | let slice = &ticks[start..end];
|
| | if !slice.is_empty() {
|
| | let gmp = build_gmp(slice, beta);
|
| | let footprint = build_footprint(slice, beta);
|
| |
|
| |
|
| | let t0 = slice.first().unwrap().time;
|
| | let t1 = slice.last().unwrap().time;
|
| | let label = format_time_range(t0, t1);
|
| |
|
| |
|
| | let sum: f64 = slice.iter().map(|t| t.price).sum();
|
| | let mid_price = sum / slice.len() as f64;
|
| |
|
| | profiles.push(IntervalProfile {
|
| | gmp,
|
| | footprint,
|
| | label,
|
| | mid_price,
|
| | start_idx: start,
|
| | });
|
| | }
|
| |
|
| | start = end;
|
| | }
|
| |
|
| | profiles
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| | pub fn aggregate_by_trades(
|
| | ticks: &[RawTick],
|
| | trade_interval: usize,
|
| | beta: f64,
|
| | ) -> Vec<IntervalProfile> {
|
| | if ticks.is_empty() || trade_interval == 0 {
|
| | return Vec::new();
|
| | }
|
| |
|
| | let mut profiles = Vec::new();
|
| |
|
| | for (chunk_idx, chunk) in ticks.chunks(trade_interval).enumerate() {
|
| | let gmp = build_gmp(chunk, beta);
|
| | let footprint = build_footprint(chunk, beta);
|
| |
|
| | let first_idx = chunk_idx * trade_interval;
|
| | let last_idx = first_idx + chunk.len() - 1;
|
| | let label = format!("T{}-T{}", first_idx + 1, last_idx + 1);
|
| |
|
| | let sum: f64 = chunk.iter().map(|t| t.price).sum();
|
| | let mid_price = sum / chunk.len() as f64;
|
| |
|
| | profiles.push(IntervalProfile {
|
| | gmp,
|
| | footprint,
|
| | label,
|
| | mid_price,
|
| | start_idx: first_idx,
|
| | });
|
| | }
|
| |
|
| | profiles
|
| | }
|
| |
|
| |
|
| |
|
| | fn format_time_range(t0: i64, t1: i64) -> String {
|
| | let fmt = |t: i64| -> String {
|
| | let h = (t / 3600) % 24;
|
| | let m = (t / 60) % 60;
|
| | let s = t % 60;
|
| | format!("{:02}:{:02}:{:02}", h, m, s)
|
| | };
|
| | if t0 == t1 {
|
| | fmt(t0)
|
| | } else {
|
| | format!("{}-{}", fmt(t0), fmt(t1))
|
| | }
|
| | }
|
| |
|
| |
|
| |
|
| | #[cfg(test)]
|
| | mod tests {
|
| | use super::*;
|
| |
|
| |
|
| | fn reference_datapoints() -> Vec<RawTick> {
|
| | vec![
|
| | RawTick { price: 3000.914, time: 1, index: 0 },
|
| | RawTick { price: 3003.837, time: 2, index: 1 },
|
| | RawTick { price: 3002.432, time: 3, index: 2 },
|
| | RawTick { price: 3009.892, time: 4, index: 3 },
|
| | RawTick { price: 3007.698, time: 5, index: 4 },
|
| | RawTick { price: 3009.176, time: 6, index: 5 },
|
| | RawTick { price: 3003.381, time: 7, index: 6 },
|
| | RawTick { price: 3004.283, time: 8, index: 7 },
|
| | RawTick { price: 3003.512, time: 9, index: 8 },
|
| | RawTick { price: 3003.012, time: 10, index: 9 },
|
| | ]
|
| | }
|
| |
|
| | #[test]
|
| | fn test_bin_index() {
|
| |
|
| | assert_eq!(bin_index(3000.914, 1.0), 3000);
|
| | assert_eq!(bin_index(3003.837, 1.0), 3003);
|
| | assert_eq!(bin_index(3009.892, 1.0), 3009);
|
| | }
|
| |
|
| | #[test]
|
| | fn test_gmp_parity_with_python() {
|
| |
|
| |
|
| |
|
| | let ticks = reference_datapoints();
|
| | let profile = build_gmp(&ticks, 1.0);
|
| |
|
| |
|
| | for b in 3000..=3009 {
|
| | assert!(
|
| | profile.bins.contains_key(&b),
|
| | "GMP missing bin {}",
|
| | b
|
| | );
|
| | }
|
| |
|
| |
|
| | let total: u32 = profile.bins.values().map(|b| b.count).sum();
|
| | assert_eq!(total, 25, "GMP total stacks mismatch");
|
| | }
|
| |
|
| | #[test]
|
| | fn test_footprint_parity_with_python() {
|
| |
|
| |
|
| | let ticks = reference_datapoints();
|
| | let fp = build_footprint(&ticks, 1.0);
|
| |
|
| |
|
| | for b in 3000..=3009 {
|
| | assert!(
|
| | fp.bins.contains_key(&b),
|
| | "Footprint missing bin {}",
|
| | b
|
| | );
|
| | }
|
| |
|
| |
|
| | let total: u32 = fp.bins.values().map(|b| b.count).sum();
|
| | assert_eq!(total, 25, "Footprint total stacks mismatch");
|
| |
|
| |
|
| | let total_up: u32 = fp.bins.values().map(|b| b.up).sum();
|
| | let total_down: u32 = fp.bins.values().map(|b| b.down).sum();
|
| | assert!(total_up > 0, "Expected some up counts");
|
| | assert!(total_down > 0, "Expected some down counts");
|
| | }
|
| |
|
| | #[test]
|
| | fn test_aggregate_by_trades() {
|
| | let ticks = reference_datapoints();
|
| |
|
| | let intervals = aggregate_by_trades(&ticks, 5, 1.0);
|
| | assert_eq!(intervals.len(), 2);
|
| | assert_eq!(intervals[0].label, "T1-T5");
|
| | assert_eq!(intervals[1].label, "T6-T10");
|
| | }
|
| |
|
| | #[test]
|
| | fn test_aggregate_by_time() {
|
| | let ticks = reference_datapoints();
|
| |
|
| | let intervals = aggregate_by_time(&ticks, 5, 1.0);
|
| | assert_eq!(intervals.len(), 2);
|
| | }
|
| | }
|
| |
|