use std::collections::BTreeMap;
use std::sync::Arc;
use digdigdig3::connector_manager::ExchangeHub;
use digdigdig3::core::types::{AccountType, ExchangeId, SymbolInput};
use digdigdig3::core::websocket::KlineInterval;
use crate::data::BarPoint;
use crate::error::{Result, StationError};
use crate::series::{Kind, SeriesKey};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FillPolicy {
ForwardFill,
ZeroFlow,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScalarBar {
pub bar_open_time: i64,
pub value: f64,
pub filled: bool,
}
#[derive(Debug, Clone)]
pub enum BarAlignedSeries {
Klines(Vec<BarPoint>),
Scalar(Vec<ScalarBar>),
}
impl BarAlignedSeries {
pub fn len(&self) -> usize {
match self {
BarAlignedSeries::Klines(v) => v.len(),
BarAlignedSeries::Scalar(v) => v.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl Kind {
pub fn fill_policy(&self) -> FillPolicy {
match self {
Kind::Liquidation | Kind::AggTrade | Kind::Trade
| Kind::TakerVolume | Kind::LiquidationBucket => FillPolicy::ZeroFlow,
_ => FillPolicy::ForwardFill,
}
}
}
fn interval_millis(iv: &str) -> Option<i64> {
let iv = iv.trim();
let (num, unit) = iv.split_at(iv.len().saturating_sub(1));
let n: i64 = num.parse().ok()?;
let per = match unit {
"m" => 60_000,
"h" => 60 * 60_000,
"d" | "D" => 24 * 60 * 60_000,
"w" | "W" => 7 * 24 * 60 * 60_000,
_ => return None,
};
Some(n * per)
}
fn resample(mut src: Vec<(i64, f64)>, start: i64, end: i64, step: i64, policy: FillPolicy) -> Vec<ScalarBar> {
src.sort_unstable_by_key(|(ts, _)| *ts);
let first_bar = start.div_euclid(step) * step;
let mut out = Vec::new();
match policy {
FillPolicy::ForwardFill => {
let mut idx = 0usize;
let mut last: Option<f64> = None;
let mut t = first_bar;
while t < end {
let bar_close = t + step;
let mut fresh = false;
while idx < src.len() && src[idx].0 < bar_close {
last = Some(src[idx].1);
if src[idx].0 >= t {
fresh = true;
}
idx += 1;
}
if let Some(v) = last {
out.push(ScalarBar { bar_open_time: t, value: v, filled: !fresh });
}
t += step;
}
}
FillPolicy::ZeroFlow => {
let mut idx = 0usize;
let mut t = first_bar;
while t < end {
let bar_close = t + step;
let mut sum = 0.0;
while idx < src.len() && src[idx].0 < bar_close {
if src[idx].0 >= t {
sum += src[idx].1;
}
idx += 1;
}
out.push(ScalarBar { bar_open_time: t, value: sum, filled: false });
t += step;
}
}
}
out
}
pub fn bar_align_points<T, F>(
points: &[T],
project: F,
start_ms: i64,
end_ms: i64,
interval: &KlineInterval,
policy: FillPolicy,
) -> Result<Vec<ScalarBar>>
where
T: crate::series::DataPoint,
F: Fn(&T) -> f64,
{
let step = interval_millis(interval.as_str())
.ok_or_else(|| StationError::Core(format!("interval {interval} has no fixed ms width for resample")))?;
let src: Vec<(i64, f64)> = points
.iter()
.filter(|p| {
let t = p.timestamp_ms();
t >= start_ms && t < end_ms
})
.map(|p| (p.timestamp_ms(), project(p)))
.collect();
Ok(resample(src, start_ms, end_ms, step, policy))
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn bar_align_from_disk<T, F>(
store: &crate::series::DiskStore<T>,
project: F,
start_ms: i64,
end_ms: i64,
interval: &KlineInterval,
policy: FillPolicy,
max_points: usize,
) -> Result<Vec<ScalarBar>>
where
T: crate::series::DataPoint,
F: Fn(&T) -> f64,
{
let pts = store
.read_tail(max_points)
.await
.map_err(|e| StationError::Core(format!("DiskStore read_tail failed: {e}")))?;
bar_align_points(&pts, project, start_ms, end_ms, interval, policy)
}
const KLINE_PAGE: u16 = 1000;
async fn paginate_klines<F, Fut>(start: i64, end: i64, mut fetch: F) -> Result<Vec<BarPoint>>
where
F: FnMut(i64) -> Fut,
Fut: std::future::Future<Output = Result<Vec<BarPoint>>>,
{
let mut all: Vec<BarPoint> = Vec::new();
let mut cursor = end;
for _ in 0..256 {
let page = match fetch(cursor).await {
Ok(p) => p,
Err(_) => fetch(cursor).await?,
};
if page.is_empty() {
break;
}
let oldest = page.iter().map(|b| b.open_time).min().unwrap_or(cursor);
all.extend(page);
if oldest <= start {
break;
}
let next = oldest - 1;
if next >= cursor {
break; }
cursor = next;
}
all.retain(|b| b.open_time >= start && b.open_time < end);
all.sort_unstable_by_key(|b| b.open_time);
all.dedup_by_key(|b| b.open_time);
Ok(all)
}
pub async fn load_bar_aligned(
hub: &Arc<ExchangeHub>,
exchange: ExchangeId,
account: AccountType,
symbol: &str,
kind: &Kind,
interval: &KlineInterval,
start_ms: i64,
end_ms: i64,
) -> Result<BarAlignedSeries> {
let rest = hub
.rest(exchange)
.ok_or_else(|| StationError::Core(format!("{exchange:?} not connected in hub")))?;
if let Some(klines) = match kind {
Kind::Kline(iv) => Some(("kline", iv.clone())),
Kind::MarkPriceKline(iv) => Some(("mark", iv.clone())),
Kind::IndexPriceKline(iv) => Some(("index", iv.clone())),
Kind::PremiumIndexKline(iv) => Some(("premium", iv.clone())),
_ => None,
} {
let (which, iv) = klines;
let rest = rest.clone();
let sym = symbol.to_string();
let bars = paginate_klines(start_ms, end_ms, |cursor| {
let rest = rest.clone();
let sym = sym.clone();
let ivs = iv.as_str().to_string();
async move {
let res = match which {
"kline" => rest
.get_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE), account, Some(cursor))
.await,
"mark" => rest
.get_mark_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor))
.await,
"index" => rest
.get_index_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor))
.await,
_ => rest
.get_premium_index_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor))
.await,
};
res.map(|ks| ks.iter().map(BarPoint::from_kline).collect())
.map_err(|e| StationError::Core(format!("{which} klines failed: {e}")))
}
})
.await?;
return Ok(BarAlignedSeries::Klines(bars));
}
let step = interval_millis(interval.as_str())
.ok_or_else(|| StationError::Core(format!("interval {interval} has no fixed ms width for resample")))?;
let src: Vec<(i64, f64)> = match kind {
Kind::FundingRate => rest
.get_funding_rate_history(SymbolInput::Raw(symbol), Some(start_ms), Some(end_ms), Some(1000), account)
.await
.map_err(|e| StationError::Core(format!("funding history failed: {e}")))?
.into_iter()
.map(|f| (f.timestamp, f.rate))
.collect(),
Kind::OpenInterest => rest
.get_open_interest_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
.await
.map_err(|e| StationError::Core(format!("open interest history failed: {e}")))?
.into_iter()
.map(|oi| (oi.timestamp, oi.open_interest))
.collect(),
Kind::Basis => rest
.get_basis_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
.await
.map_err(|e| StationError::Core(format!("basis history failed: {e}")))?
.into_iter()
.map(|b| (b.timestamp, b.basis))
.collect(),
Kind::LongShortRatio => rest
.get_long_short_ratio_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
.await
.map_err(|e| StationError::Core(format!("long/short ratio history failed: {e}")))?
.into_iter()
.map(|r| {
let v = r.ratio.unwrap_or_else(|| {
if r.short_ratio > 0.0 { r.long_ratio / r.short_ratio } else { r.long_ratio }
});
(r.timestamp, v)
})
.collect(),
Kind::MarkPrice | Kind::IndexPrice => {
let which = if matches!(kind, Kind::MarkPrice) { "mark" } else { "index" };
let rest2 = rest.clone();
let sym = symbol.to_string();
let ivs = interval.as_str().to_string();
let bars = paginate_klines(start_ms, end_ms, |cursor| {
let rest2 = rest2.clone();
let sym = sym.clone();
let ivs = ivs.clone();
async move {
let res = if which == "mark" {
rest2.get_mark_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor)).await
} else {
rest2.get_index_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor)).await
};
res.map(|ks| ks.iter().map(BarPoint::from_kline).collect())
.map_err(|e| StationError::Core(format!("{which} klines failed: {e}")))
}
})
.await?;
let scalars = bars
.into_iter()
.map(|b| ScalarBar { bar_open_time: b.open_time, value: b.close, filled: false })
.collect();
return Ok(BarAlignedSeries::Scalar(scalars));
}
Kind::Liquidation => rest
.get_liquidation_history(Some(SymbolInput::Raw(symbol)), Some(start_ms), Some(end_ms), Some(1000), account)
.await
.map_err(|e| StationError::Core(format!("liquidation history failed: {e}")))?
.into_iter()
.filter_map(|liq| {
let value = liq.value.unwrap_or_else(|| liq.price * liq.quantity);
if value > 0.0 { Some((liq.timestamp, value)) } else { None }
})
.collect(),
Kind::InsuranceFund => rest
.get_insurance_fund(Some(SymbolInput::Raw(symbol)), account)
.await
.map_err(|e| StationError::Core(format!("insurance fund failed: {e}")))?
.into_iter()
.map(|f| (f.timestamp, f.balance))
.collect(),
Kind::TakerVolume => rest
.get_taker_volume_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
.await
.map_err(|e| StationError::Core(format!("taker volume history failed: {e}")))?
.into_iter()
.map(|tv| (tv.timestamp, tv.buy_volume + tv.sell_volume))
.collect(),
Kind::LiquidationBucket => rest
.get_liquidation_bucket_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
.await
.map_err(|e| StationError::Core(format!("liquidation bucket history failed: {e}")))?
.into_iter()
.map(|lb| {
let total = lb.long_liq_usd.unwrap_or(0.0) + lb.short_liq_usd.unwrap_or(0.0);
(lb.timestamp, total)
})
.collect(),
Kind::AggTrade => {
return Err(StationError::StreamNotSupported(format!(
"AggTrade bar-aligned history needs the recording daemon (no usable REST history endpoint)"
)));
}
other => {
return Err(StationError::StreamNotSupported(format!(
"bar-aligned loader does not yet support {other:?}"
)));
}
};
Ok(BarAlignedSeries::Scalar(resample(src, start_ms, end_ms, step, kind.fill_policy())))
}
pub async fn load_for_key(
hub: &Arc<ExchangeHub>,
key: &SeriesKey,
interval: &KlineInterval,
start_ms: i64,
end_ms: i64,
) -> Result<BarAlignedSeries> {
let iv = match &key.kind {
Kind::Kline(iv) | Kind::MarkPriceKline(iv) | Kind::IndexPriceKline(iv) | Kind::PremiumIndexKline(iv) => iv.clone(),
_ => interval.clone(),
};
load_bar_aligned(hub, key.exchange, key.account_type, &key.symbol, &key.kind, &iv, start_ms, end_ms).await
}
#[derive(Debug, Clone)]
pub struct MultiScalarBar {
pub bar_open_time: i64,
pub lanes: BTreeMap<&'static str, f64>,
pub filled: bool,
}
#[derive(Debug, Clone)]
pub struct MultiScalarSeries {
pub lanes: Vec<&'static str>,
pub bars: Vec<MultiScalarBar>,
}
impl MultiScalarSeries {
pub fn len(&self) -> usize { self.bars.len() }
pub fn is_empty(&self) -> bool { self.bars.is_empty() }
}
fn resample_multi(
mut src: Vec<(i64, BTreeMap<&'static str, f64>)>,
lanes: &[&'static str],
start: i64,
end: i64,
step: i64,
policy: FillPolicy,
) -> Vec<MultiScalarBar> {
src.sort_unstable_by_key(|(ts, _)| *ts);
let first_bar = start.div_euclid(step) * step;
let mut out = Vec::new();
match policy {
FillPolicy::ForwardFill => {
let mut last: BTreeMap<&'static str, f64> = BTreeMap::new();
let mut idx = 0usize;
let mut t = first_bar;
while t < end {
let bar_close = t + step;
let mut fresh = false;
while idx < src.len() && src[idx].0 < bar_close {
for lane in lanes {
if let Some(&v) = src[idx].1.get(lane) {
last.insert(lane, v);
}
}
if src[idx].0 >= t { fresh = true; }
idx += 1;
}
if !last.is_empty() {
let mut bar_lanes: BTreeMap<&'static str, f64> = BTreeMap::new();
for lane in lanes {
bar_lanes.insert(lane, *last.get(lane).unwrap_or(&f64::NAN));
}
out.push(MultiScalarBar { bar_open_time: t, lanes: bar_lanes, filled: !fresh });
}
t += step;
}
}
FillPolicy::ZeroFlow => {
let mut idx = 0usize;
let mut t = first_bar;
while t < end {
let bar_close = t + step;
let mut sums: BTreeMap<&'static str, f64> = lanes.iter().map(|&l| (l, 0.0)).collect();
while idx < src.len() && src[idx].0 < bar_close {
if src[idx].0 >= t {
for lane in lanes {
if let Some(&v) = src[idx].1.get(lane) {
if !v.is_nan() {
*sums.entry(lane).or_insert(0.0) += v;
}
}
}
}
idx += 1;
}
out.push(MultiScalarBar { bar_open_time: t, lanes: sums, filled: false });
t += step;
}
}
}
out
}
pub async fn load_bar_aligned_multi(
hub: &Arc<ExchangeHub>,
exchange: ExchangeId,
account: AccountType,
symbol: &str,
kind: &Kind,
interval: &KlineInterval,
start_ms: i64,
end_ms: i64,
) -> Result<MultiScalarSeries> {
let rest = hub
.rest(exchange)
.ok_or_else(|| StationError::Core(format!("{exchange:?} not connected in hub")))?;
let step = interval_millis(interval.as_str())
.ok_or_else(|| StationError::Core(format!("interval {interval} has no fixed ms width")))?;
match kind {
Kind::FundingRate => {
const LANES: &[&str] = &[
"rate", "interest_8h", "index_price", "premium",
"realized_rate", "estimated_rate", "accrued_funding",
"funding_step", "funding_interval_hours",
];
let items = rest
.get_funding_rate_history(SymbolInput::Raw(symbol), Some(start_ms), Some(end_ms), Some(1000), account)
.await
.map_err(|e| StationError::Core(format!("funding history failed: {e}")))?;
let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|f| {
let mut m = BTreeMap::new();
m.insert("rate", f.rate);
m.insert("interest_8h", f.interest_8h.unwrap_or(f64::NAN));
m.insert("index_price", f.index_price.unwrap_or(f64::NAN));
m.insert("premium", f.premium.unwrap_or(f64::NAN));
m.insert("realized_rate", f.realized_rate.unwrap_or(f64::NAN));
m.insert("estimated_rate", f.estimated_rate.unwrap_or(f64::NAN));
m.insert("accrued_funding", f.accrued_funding.unwrap_or(f64::NAN));
m.insert("funding_step", f.funding_step.unwrap_or(0) as f64);
m.insert("funding_interval_hours", f.funding_interval_hours.unwrap_or(f64::NAN));
(f.timestamp, m)
}).collect();
let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
}
Kind::MarkPrice => {
const LANES: &[&str] = &[
"mark", "index", "estimated_settle", "funding_rate",
"interest_rate", "indicative_index", "deriv_price",
];
let items = rest
.get_premium_index(Some(SymbolInput::Raw(symbol)), account)
.await
.map_err(|e| StationError::Core(format!("mark price failed: {e}")))?;
let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|mp| {
let mut m = BTreeMap::new();
m.insert("mark", mp.mark_price);
m.insert("index", mp.index_price.unwrap_or(f64::NAN));
m.insert("estimated_settle", mp.estimated_settle_price.unwrap_or(f64::NAN));
m.insert("funding_rate", mp.funding_rate.unwrap_or(f64::NAN));
m.insert("interest_rate", mp.interest_rate.unwrap_or(f64::NAN));
m.insert("indicative_index", mp.indicative_settle_price.unwrap_or(f64::NAN));
m.insert("deriv_price", mp.deriv_price.unwrap_or(f64::NAN));
(mp.timestamp, m)
}).collect();
let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
}
Kind::OpenInterest => {
const LANES: &[&str] = &[
"open_interest", "open_interest_value", "oi_ccy", "oi_usd", "sum_oi",
];
let items = rest
.get_open_interest_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
.await
.map_err(|e| StationError::Core(format!("open interest history failed: {e}")))?;
let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|oi| {
let mut m = BTreeMap::new();
m.insert("open_interest", oi.open_interest);
m.insert("open_interest_value", oi.open_interest_value.unwrap_or(f64::NAN));
m.insert("oi_ccy", oi.open_interest_ccy.unwrap_or(f64::NAN));
m.insert("oi_usd", oi.open_interest_usd.unwrap_or(f64::NAN));
m.insert("sum_oi", oi.sum_open_interest.unwrap_or(f64::NAN));
(oi.timestamp, m)
}).collect();
let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
}
Kind::LongShortRatio => {
const LANES: &[&str] = &["ratio", "long_pct", "short_pct"];
let items = rest
.get_long_short_ratio_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
.await
.map_err(|e| StationError::Core(format!("long/short ratio history failed: {e}")))?;
let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|r| {
let combined = r.ratio.unwrap_or_else(|| {
if r.short_ratio > 0.0 { r.long_ratio / r.short_ratio } else { r.long_ratio }
});
let mut m = BTreeMap::new();
m.insert("ratio", combined);
m.insert("long_pct", r.long_ratio);
m.insert("short_pct", r.short_ratio);
(r.timestamp, m)
}).collect();
let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
}
Kind::Basis => {
const LANES: &[&str] = &["basis", "futures_price", "index_price"];
let items = rest
.get_basis_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
.await
.map_err(|e| StationError::Core(format!("basis history failed: {e}")))?;
let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|b| {
let mut m = BTreeMap::new();
m.insert("basis", b.basis);
m.insert("futures_price", b.futures_price.unwrap_or(f64::NAN));
m.insert("index_price", b.index_price.unwrap_or(f64::NAN));
(b.timestamp, m)
}).collect();
let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
}
Kind::Ticker => {
const LANES: &[&str] = &[
"last_price", "bid", "ask", "bid_qty", "ask_qty",
"volume", "quote_volume", "high_24h", "low_24h", "open_24h",
"weighted_avg", "last_qty", "count",
];
let ticker = rest
.get_ticker(SymbolInput::Raw(symbol), account)
.await
.map_err(|e| StationError::Core(format!("ticker failed: {e}")))?;
let mut m = BTreeMap::new();
m.insert("last_price", ticker.last_price);
m.insert("bid", ticker.bid_price.unwrap_or(f64::NAN));
m.insert("ask", ticker.ask_price.unwrap_or(f64::NAN));
m.insert("bid_qty", ticker.bid_qty.unwrap_or(f64::NAN));
m.insert("ask_qty", ticker.ask_qty.unwrap_or(f64::NAN));
m.insert("volume", ticker.volume_24h.unwrap_or(f64::NAN));
m.insert("quote_volume", ticker.quote_volume_24h.unwrap_or(f64::NAN));
m.insert("high_24h", ticker.high_24h.unwrap_or(f64::NAN));
m.insert("low_24h", ticker.low_24h.unwrap_or(f64::NAN));
m.insert("open_24h", ticker.open_price.unwrap_or(f64::NAN));
m.insert("weighted_avg", ticker.weighted_avg_price.unwrap_or(f64::NAN));
m.insert("last_qty", f64::NAN); m.insert("count", ticker.count.map(|c| c as f64).unwrap_or(f64::NAN));
let src = vec![(ticker.timestamp, m)];
let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
}
Kind::IndexPrice => {
const LANES: &[&str] = &["price", "high_24h", "low_24h", "open_24h"];
let sym = symbol.to_string();
let ivs = interval.as_str().to_string();
let rest2 = rest.clone();
let bars_raw = paginate_klines(start_ms, end_ms, |cursor| {
let rest2 = rest2.clone();
let sym = sym.clone();
let ivs = ivs.clone();
async move {
rest2.get_index_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor))
.await
.map(|ks| ks.iter().map(BarPoint::from_kline).collect())
.map_err(|e| StationError::Core(format!("index klines failed: {e}")))
}
}).await?;
let src: Vec<(i64, BTreeMap<&'static str, f64>)> = bars_raw.into_iter().map(|b| {
let mut m = BTreeMap::new();
m.insert("price", b.close);
m.insert("high_24h", f64::NAN);
m.insert("low_24h", f64::NAN);
m.insert("open_24h", f64::NAN);
(b.open_time, m)
}).collect();
let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
}
Kind::Liquidation => {
const LANES: &[&str] = &["total_value", "count", "long_value", "short_value"];
let items = rest
.get_liquidation_history(Some(SymbolInput::Raw(symbol)), Some(start_ms), Some(end_ms), Some(1000), account)
.await
.map_err(|e| StationError::Core(format!("liquidation history failed: {e}")))?;
let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|liq| {
let value = liq.value.unwrap_or_else(|| liq.price * liq.quantity);
let is_long_liq = matches!(liq.side, digdigdig3::core::types::TradeSide::Buy);
let mut m = BTreeMap::new();
m.insert("total_value", value);
m.insert("count", 1.0);
m.insert("long_value", if is_long_liq { value } else { 0.0 });
m.insert("short_value", if !is_long_liq { value } else { 0.0 });
(liq.timestamp, m)
}).collect();
let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ZeroFlow);
Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
}
other => Err(StationError::StreamNotSupported(format!(
"load_bar_aligned_multi does not support {other:?}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interval_parsing() {
assert_eq!(interval_millis("1m"), Some(60_000));
assert_eq!(interval_millis("5m"), Some(300_000));
assert_eq!(interval_millis("1h"), Some(3_600_000));
assert_eq!(interval_millis("4h"), Some(14_400_000));
assert_eq!(interval_millis("1d"), Some(86_400_000));
assert_eq!(interval_millis("1w"), Some(604_800_000));
assert_eq!(interval_millis("1M"), None);
assert_eq!(interval_millis("xx"), None);
}
#[test]
fn forward_fill_carries_into_empty_bars() {
let step = 60_000;
let src = vec![(0, 10.0), (130_000, 20.0)];
let out = resample(src, 0, 240_000, step, FillPolicy::ForwardFill);
assert_eq!(out.len(), 4);
assert_eq!(out[0], ScalarBar { bar_open_time: 0, value: 10.0, filled: false });
assert_eq!(out[1], ScalarBar { bar_open_time: 60_000, value: 10.0, filled: true });
assert_eq!(out[2], ScalarBar { bar_open_time: 120_000, value: 20.0, filled: false });
assert_eq!(out[3], ScalarBar { bar_open_time: 180_000, value: 20.0, filled: true });
}
#[test]
fn forward_fill_omits_leading_gap() {
let step = 60_000;
let src = vec![(150_000, 5.0)];
let out = resample(src, 0, 240_000, step, FillPolicy::ForwardFill);
assert_eq!(out.len(), 2); assert_eq!(out[0].bar_open_time, 120_000);
assert_eq!(out[0].value, 5.0);
assert!(!out[0].filled);
assert_eq!(out[1], ScalarBar { bar_open_time: 180_000, value: 5.0, filled: true });
}
#[test]
fn zero_flow_buckets_and_zeros_gaps() {
let step = 60_000;
let src = vec![(1_000, 3.0), (50_000, 4.0), (125_000, 9.0)];
let out = resample(src, 0, 180_000, step, FillPolicy::ZeroFlow);
assert_eq!(out.len(), 3);
assert_eq!(out[0], ScalarBar { bar_open_time: 0, value: 7.0, filled: false });
assert_eq!(out[1], ScalarBar { bar_open_time: 60_000, value: 0.0, filled: false });
assert_eq!(out[2], ScalarBar { bar_open_time: 120_000, value: 9.0, filled: false });
}
#[test]
fn bar_align_points_flow_from_recorded() {
use crate::data::LiquidationPoint;
let pts = vec![
LiquidationPoint { ts_ms: 1_000, price: 100.0, quantity: 1.0, value: 5_000.0, side: 0 },
LiquidationPoint { ts_ms: 40_000, price: 100.0, quantity: 1.0, value: 3_000.0, side: 1 },
LiquidationPoint { ts_ms: 125_000, price: 100.0, quantity: 1.0, value: 9_000.0, side: 0 },
];
let out = bar_align_points(
&pts, |p| p.value, 0, 180_000, &KlineInterval::new("1m"), FillPolicy::ZeroFlow,
).unwrap();
assert_eq!(out.len(), 3);
assert_eq!(out[0], ScalarBar { bar_open_time: 0, value: 8_000.0, filled: false });
assert_eq!(out[1], ScalarBar { bar_open_time: 60_000, value: 0.0, filled: false });
assert_eq!(out[2], ScalarBar { bar_open_time: 120_000, value: 9_000.0, filled: false });
}
#[test]
fn grid_aligns_to_interval_boundary() {
let step = 60_000;
let src = vec![(70_000, 1.0)];
let out = resample(src, 35_000, 130_000, step, FillPolicy::ForwardFill);
assert_eq!(out[0].bar_open_time, 60_000); assert_eq!(out[0].value, 1.0);
}
fn make_multi_src(entries: Vec<(i64, &[(&'static str, f64)])>) -> Vec<(i64, BTreeMap<&'static str, f64>)> {
entries.into_iter().map(|(ts, pairs)| {
let m: BTreeMap<&'static str, f64> = pairs.iter().copied().collect();
(ts, m)
}).collect()
}
#[test]
fn resample_multi_forward_fill_two_lanes() {
let step = 60_000;
let src = make_multi_src(vec![
(0, &[("a", 1.0)]),
(130_000, &[("b", 2.0)]),
]);
const LANES: &[&str] = &["a", "b"];
let out = resample_multi(src, LANES, 0, 240_000, step, FillPolicy::ForwardFill);
assert_eq!(out[0].bar_open_time, 0);
assert_eq!(out[0].lanes["a"], 1.0);
assert!(out[0].lanes["b"].is_nan());
assert!(!out[0].filled);
assert_eq!(out[1].bar_open_time, 60_000);
assert_eq!(out[1].lanes["a"], 1.0);
assert!(out[1].lanes["b"].is_nan());
assert!(out[1].filled);
assert_eq!(out[2].bar_open_time, 120_000);
assert_eq!(out[2].lanes["a"], 1.0);
assert_eq!(out[2].lanes["b"], 2.0);
assert!(!out[2].filled, "b is fresh at 130k");
assert_eq!(out[3].bar_open_time, 180_000);
assert!(out[3].filled);
}
#[test]
fn resample_multi_zero_flow_two_lanes() {
let step = 60_000;
let src = make_multi_src(vec![
(1_000, &[("total_value", 5_000.0), ("count", 1.0)]),
(50_000, &[("total_value", 3_000.0), ("count", 1.0)]),
(125_000, &[("total_value", 9_000.0), ("count", 1.0)]),
]);
const LANES: &[&str] = &["total_value", "count"];
let out = resample_multi(src, LANES, 0, 180_000, step, FillPolicy::ZeroFlow);
assert_eq!(out.len(), 3);
assert_eq!(out[0].lanes["total_value"], 8_000.0, "sum of bar-0 events");
assert_eq!(out[0].lanes["count"], 2.0);
assert_eq!(out[1].lanes["total_value"], 0.0, "gap bar = 0");
assert_eq!(out[1].lanes["count"], 0.0);
assert_eq!(out[2].lanes["total_value"], 9_000.0);
}
}