flowsurface-data 0.9.0

Data aggregation, indexing, and configuration utilities for Flowsurface
Documentation
use std::{collections::VecDeque, time::Duration};

use exchange::{
    SizeUnit, TickerInfo, Trade, UnixMs,
    unit::{Qty, qty::volume_size_unit},
};
use serde::{Deserialize, Serialize};

use crate::util::ok_or_default;

const TRADE_RETENTION_MS: u64 = 120_000;

#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
pub struct Config {
    #[serde(deserialize_with = "ok_or_default", default)]
    pub ticker_display: TickerDisplay,
    #[serde(deserialize_with = "ok_or_default", default)]
    pub time_format: TimeFormat,
    pub trade_size_filter: f32,
    #[serde(default = "default_buffer_filter")]
    pub trade_retention: Duration,
    #[serde(deserialize_with = "ok_or_default", default)]
    pub stacked_bar: Option<StackedBar>,
}

impl Default for Config {
    fn default() -> Self {
        Config {
            ticker_display: TickerDisplay::default(),
            time_format: TimeFormat::default(),
            trade_size_filter: 0.0,
            trade_retention: Duration::from_millis(TRADE_RETENTION_MS),
            stacked_bar: StackedBar::Compact(StackedBarRatio::default()).into(),
        }
    }
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TickerDisplay {
    #[default]
    Icon,
    IconAndTicker,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TimeFormat {
    #[default]
    MinutesSecondsMillis,
    HoursMinutesSecondsMillis,
    HoursMinutesSeconds,
}

impl TimeFormat {
    pub const ALL: [Self; 3] = [
        Self::MinutesSecondsMillis,
        Self::HoursMinutesSecondsMillis,
        Self::HoursMinutesSeconds,
    ];

    pub fn pattern(self) -> &'static str {
        match self {
            Self::MinutesSecondsMillis => "%M:%S.%3f",
            Self::HoursMinutesSecondsMillis => "%H:%M:%S.%3f",
            Self::HoursMinutesSeconds => "%H:%M:%S",
        }
    }
}

impl std::fmt::Display for TimeFormat {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Self::MinutesSecondsMillis => "mm:ss.ms",
            Self::HoursMinutesSecondsMillis => "hh:mm:ss.ms",
            Self::HoursMinutesSeconds => "hh:mm:ss",
        })
    }
}

fn default_buffer_filter() -> Duration {
    Duration::from_millis(TRADE_RETENTION_MS)
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Copy)]
pub enum StackedBar {
    Compact(StackedBarRatio),
    Full(StackedBarRatio),
}

impl StackedBar {
    pub fn ratio(self) -> StackedBarRatio {
        match self {
            StackedBar::Compact(r) | StackedBar::Full(r) => r,
        }
    }

    pub fn with_ratio(self, r: StackedBarRatio) -> Self {
        match self {
            StackedBar::Compact(_) => StackedBar::Compact(r),
            StackedBar::Full(_) => StackedBar::Full(r),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default, Copy)]
pub enum StackedBarRatio {
    Count,
    #[default]
    Volume,
    AverageSize,
}

impl std::fmt::Display for StackedBarRatio {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            StackedBarRatio::Count => write!(f, "Count"),
            StackedBarRatio::AverageSize => write!(f, "Average trade size"),
            StackedBarRatio::Volume => write!(f, "Volume"),
        }
    }
}

impl StackedBarRatio {
    pub const ALL: [StackedBarRatio; 3] = [
        StackedBarRatio::Count,
        StackedBarRatio::Volume,
        StackedBarRatio::AverageSize,
    ];
}

#[derive(Clone, Copy)]
pub struct TapeTrade {
    trade: Trade,
    ticker_info: TickerInfo,
    notional: Qty,
}

impl std::ops::Deref for TapeTrade {
    type Target = Trade;

    fn deref(&self) -> &Trade {
        &self.trade
    }
}

impl TapeTrade {
    pub fn new(ticker_info: TickerInfo, trade: Trade, size_unit: SizeUnit) -> Self {
        let notional = Qty::from_f64(ticker_info.market_type().qty_in_quote_value(
            trade.qty,
            trade.price,
            size_unit == SizeUnit::Quote,
        ));
        Self {
            trade,
            ticker_info,
            notional,
        }
    }

    pub fn ticker_info(&self) -> TickerInfo {
        self.ticker_info
    }

    pub fn quantity_text(&self, multiple_tickers: bool) -> String {
        format!(
            "{}{}",
            self.quantity_prefix(multiple_tickers, volume_size_unit()),
            crate::util::abbr_large_numbers(self.quantity(multiple_tickers).to_f64())
        )
    }

    pub fn quantity(&self, multiple_tickers: bool) -> Qty {
        if multiple_tickers {
            self.notional
        } else {
            self.qty
        }
    }

    pub fn passes_filter(&self, threshold: f32) -> bool {
        self.notional.to_f32_lossy() >= threshold
    }

    pub fn quantity_prefix(&self, multiple_tickers: bool, size_unit: SizeUnit) -> &'static str {
        if multiple_tickers
            || size_unit == SizeUnit::Quote
            || self.ticker_info.market_type() == exchange::adapter::MarketKind::InversePerps
        {
            "$"
        } else {
            ""
        }
    }

    pub fn merge_into(target: &mut VecDeque<Self>, mut incoming: Vec<Self>) {
        if incoming.is_empty() {
            return;
        }
        incoming.sort_by_key(|entry| entry.time);
        if target
            .back()
            .is_none_or(|last| last.time <= incoming[0].time)
        {
            target.extend(incoming);
            return;
        }
        let merge_start = target.partition_point(|entry| entry.time <= incoming[0].time);
        let mut previous = target.split_off(merge_start);
        for entry in incoming {
            while previous.front().is_some_and(|old| old.time <= entry.time) {
                target.push_back(previous.pop_front().unwrap());
            }
            target.push_back(entry);
        }
        target.append(&mut previous);
    }
}

pub fn prune_trades(trades: &mut VecDeque<TapeTrade>, retention: Duration, now: UnixMs) -> bool {
    let retention_ms = retention.as_millis() as u64;
    let prune_slack_ms = retention_ms / 10;
    let retention_cutoff = now.saturating_sub(retention_ms);
    let slack_cutoff = now.saturating_sub(retention_ms.saturating_add(prune_slack_ms));

    if trades
        .front()
        .is_none_or(|oldest| oldest.time >= slack_cutoff)
    {
        return false;
    }

    let previous_len = trades.len();
    while trades
        .front()
        .is_some_and(|trade| trade.time < retention_cutoff)
    {
        trades.pop_front();
    }
    trades.len() != previous_len
}

#[derive(Debug, Clone, Copy)]
pub enum HistAggValues {
    Count { buy: u64, sell: u64 },
    Qty { buy: Qty, sell: Qty },
}

#[derive(Default)]
pub struct HistAgg {
    buy_count: u64,
    sell_count: u64,
    buy_sum: Qty,
    sell_sum: Qty,
}

impl HistAgg {
    fn average_qty(sum: Qty, count: u64) -> Qty {
        if count == 0 {
            return Qty::ZERO;
        }

        let c = count.min(i64::MAX as u64) as i64;
        let half = c / 2;
        let rounded = if sum.units >= 0 {
            sum.units.saturating_add(half).div_euclid(c)
        } else {
            sum.units.saturating_sub(half).div_euclid(c)
        };

        Qty::from_units(rounded)
    }

    pub fn add(&mut self, is_sell: bool, qty: Qty) {
        if is_sell {
            self.sell_count += 1;
            self.sell_sum = Qty::from_units(self.sell_sum.units.saturating_add(qty.units));
        } else {
            self.buy_count += 1;
            self.buy_sum = Qty::from_units(self.buy_sum.units.saturating_add(qty.units));
        }
    }

    pub fn values_for(&self, ratio_kind: StackedBarRatio) -> Option<HistAggValues> {
        match ratio_kind {
            StackedBarRatio::Count => {
                let buy = self.buy_count;
                let sell = self.sell_count;
                let total = buy.saturating_add(sell);

                if total == 0 {
                    return None;
                }

                Some(HistAggValues::Count { buy, sell })
            }
            StackedBarRatio::Volume => {
                let buy = self.buy_sum.to_f32_lossy();
                let sell = self.sell_sum.to_f32_lossy();
                let total = buy + sell;

                if total <= 0.0 {
                    return None;
                }

                Some(HistAggValues::Qty {
                    buy: self.buy_sum,
                    sell: self.sell_sum,
                })
            }
            StackedBarRatio::AverageSize => {
                let buy_avg = Self::average_qty(self.buy_sum, self.buy_count);
                let sell_avg = Self::average_qty(self.sell_sum, self.sell_count);

                let denom = buy_avg.units.saturating_add(sell_avg.units);
                if denom <= 0 {
                    return None;
                }

                Some(HistAggValues::Qty {
                    buy: buy_avg,
                    sell: sell_avg,
                })
            }
        }
    }
}