1use std::collections::BTreeMap;
24use std::sync::Arc;
25
26use digdigdig3::connector_manager::ExchangeHub;
27use digdigdig3::core::types::{AccountType, ExchangeId, SymbolInput};
28use digdigdig3::core::websocket::KlineInterval;
29
30use crate::data::BarPoint;
31use crate::error::{Result, StationError};
32use crate::series::{Kind, SeriesKey};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FillPolicy {
37 ForwardFill,
39 ZeroFlow,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct ScalarBar {
46 pub bar_open_time: i64,
48 pub value: f64,
50 pub filled: bool,
53}
54
55#[derive(Debug, Clone)]
58pub enum BarAlignedSeries {
59 Klines(Vec<BarPoint>),
61 Scalar(Vec<ScalarBar>),
63}
64
65impl BarAlignedSeries {
66 pub fn len(&self) -> usize {
68 match self {
69 BarAlignedSeries::Klines(v) => v.len(),
70 BarAlignedSeries::Scalar(v) => v.len(),
71 }
72 }
73 pub fn is_empty(&self) -> bool {
74 self.len() == 0
75 }
76}
77
78impl Kind {
79 pub fn fill_policy(&self) -> FillPolicy {
82 match self {
83 Kind::Liquidation | Kind::AggTrade | Kind::Trade
84 | Kind::TakerVolume | Kind::LiquidationBucket => FillPolicy::ZeroFlow,
85 _ => FillPolicy::ForwardFill,
86 }
87 }
88}
89
90fn interval_millis(iv: &str) -> Option<i64> {
95 let iv = iv.trim();
96 let (num, unit) = iv.split_at(iv.len().saturating_sub(1));
97 let n: i64 = num.parse().ok()?;
98 let per = match unit {
99 "m" => 60_000,
100 "h" => 60 * 60_000,
101 "d" | "D" => 24 * 60 * 60_000,
102 "w" | "W" => 7 * 24 * 60 * 60_000,
103 _ => return None,
104 };
105 Some(n * per)
106}
107
108fn resample(mut src: Vec<(i64, f64)>, start: i64, end: i64, step: i64, policy: FillPolicy) -> Vec<ScalarBar> {
119 src.sort_unstable_by_key(|(ts, _)| *ts);
120 let first_bar = start.div_euclid(step) * step;
121 let mut out = Vec::new();
122
123 match policy {
124 FillPolicy::ForwardFill => {
125 let mut idx = 0usize;
126 let mut last: Option<f64> = None;
127 let mut t = first_bar;
128 while t < end {
129 let bar_close = t + step;
130 let mut fresh = false;
131 while idx < src.len() && src[idx].0 < bar_close {
132 last = Some(src[idx].1);
133 if src[idx].0 >= t {
134 fresh = true;
135 }
136 idx += 1;
137 }
138 if let Some(v) = last {
139 out.push(ScalarBar { bar_open_time: t, value: v, filled: !fresh });
140 }
141 t += step;
142 }
143 }
144 FillPolicy::ZeroFlow => {
145 let mut idx = 0usize;
146 let mut t = first_bar;
147 while t < end {
148 let bar_close = t + step;
149 let mut sum = 0.0;
150 while idx < src.len() && src[idx].0 < bar_close {
151 if src[idx].0 >= t {
152 sum += src[idx].1;
153 }
154 idx += 1;
155 }
156 out.push(ScalarBar { bar_open_time: t, value: sum, filled: false });
157 t += step;
158 }
159 }
160 }
161 out
162}
163
164pub fn bar_align_points<T, F>(
179 points: &[T],
180 project: F,
181 start_ms: i64,
182 end_ms: i64,
183 interval: &KlineInterval,
184 policy: FillPolicy,
185) -> Result<Vec<ScalarBar>>
186where
187 T: crate::series::DataPoint,
188 F: Fn(&T) -> f64,
189{
190 let step = interval_millis(interval.as_str())
191 .ok_or_else(|| StationError::Core(format!("interval {interval} has no fixed ms width for resample")))?;
192 let src: Vec<(i64, f64)> = points
193 .iter()
194 .filter(|p| {
195 let t = p.timestamp_ms();
196 t >= start_ms && t < end_ms
197 })
198 .map(|p| (p.timestamp_ms(), project(p)))
199 .collect();
200 Ok(resample(src, start_ms, end_ms, step, policy))
201}
202
203#[cfg(not(target_arch = "wasm32"))]
210pub async fn bar_align_from_disk<T, F>(
211 store: &crate::series::DiskStore<T>,
212 project: F,
213 start_ms: i64,
214 end_ms: i64,
215 interval: &KlineInterval,
216 policy: FillPolicy,
217 max_points: usize,
218) -> Result<Vec<ScalarBar>>
219where
220 T: crate::series::DataPoint,
221 F: Fn(&T) -> f64,
222{
223 let pts = store
224 .read_tail(max_points)
225 .await
226 .map_err(|e| StationError::Core(format!("DiskStore read_tail failed: {e}")))?;
227 bar_align_points(&pts, project, start_ms, end_ms, interval, policy)
228}
229
230const KLINE_PAGE: u16 = 1000;
232
233async fn paginate_klines<F, Fut>(start: i64, end: i64, mut fetch: F) -> Result<Vec<BarPoint>>
240where
241 F: FnMut(i64) -> Fut,
242 Fut: std::future::Future<Output = Result<Vec<BarPoint>>>,
243{
244 let mut all: Vec<BarPoint> = Vec::new();
245 let mut cursor = end;
246 for _ in 0..256 {
248 let page = match fetch(cursor).await {
251 Ok(p) => p,
252 Err(_) => fetch(cursor).await?,
253 };
254 if page.is_empty() {
255 break;
256 }
257 let oldest = page.iter().map(|b| b.open_time).min().unwrap_or(cursor);
258 all.extend(page);
259 if oldest <= start {
260 break;
261 }
262 let next = oldest - 1;
264 if next >= cursor {
265 break; }
267 cursor = next;
268 }
269 all.retain(|b| b.open_time >= start && b.open_time < end);
270 all.sort_unstable_by_key(|b| b.open_time);
271 all.dedup_by_key(|b| b.open_time);
272 Ok(all)
273}
274
275pub async fn load_bar_aligned(
290 hub: &Arc<ExchangeHub>,
291 exchange: ExchangeId,
292 account: AccountType,
293 symbol: &str,
294 kind: &Kind,
295 interval: &KlineInterval,
296 start_ms: i64,
297 end_ms: i64,
298) -> Result<BarAlignedSeries> {
299 let rest = hub
300 .rest(exchange)
301 .ok_or_else(|| StationError::Core(format!("{exchange:?} not connected in hub")))?;
302
303 if let Some(klines) = match kind {
305 Kind::Kline(iv) => Some(("kline", iv.clone())),
306 Kind::MarkPriceKline(iv) => Some(("mark", iv.clone())),
307 Kind::IndexPriceKline(iv) => Some(("index", iv.clone())),
308 Kind::PremiumIndexKline(iv) => Some(("premium", iv.clone())),
309 _ => None,
310 } {
311 let (which, iv) = klines;
312 let rest = rest.clone();
313 let sym = symbol.to_string();
314 let bars = paginate_klines(start_ms, end_ms, |cursor| {
315 let rest = rest.clone();
316 let sym = sym.clone();
317 let ivs = iv.as_str().to_string();
318 async move {
319 let res = match which {
320 "kline" => rest
321 .get_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE), account, Some(cursor))
322 .await,
323 "mark" => rest
324 .get_mark_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor))
325 .await,
326 "index" => rest
327 .get_index_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor))
328 .await,
329 _ => rest
330 .get_premium_index_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor))
331 .await,
332 };
333 res.map(|ks| ks.iter().map(BarPoint::from_kline).collect())
334 .map_err(|e| StationError::Core(format!("{which} klines failed: {e}")))
335 }
336 })
337 .await?;
338 return Ok(BarAlignedSeries::Klines(bars));
339 }
340
341 let step = interval_millis(interval.as_str())
343 .ok_or_else(|| StationError::Core(format!("interval {interval} has no fixed ms width for resample")))?;
344
345 let src: Vec<(i64, f64)> = match kind {
346 Kind::FundingRate => rest
347 .get_funding_rate_history(SymbolInput::Raw(symbol), Some(start_ms), Some(end_ms), Some(1000), account)
348 .await
349 .map_err(|e| StationError::Core(format!("funding history failed: {e}")))?
350 .into_iter()
351 .map(|f| (f.timestamp, f.rate))
352 .collect(),
353
354 Kind::OpenInterest => rest
355 .get_open_interest_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
356 .await
357 .map_err(|e| StationError::Core(format!("open interest history failed: {e}")))?
358 .into_iter()
359 .map(|oi| (oi.timestamp, oi.open_interest))
360 .collect(),
361
362 Kind::Basis => rest
363 .get_basis_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
364 .await
365 .map_err(|e| StationError::Core(format!("basis history failed: {e}")))?
366 .into_iter()
367 .map(|b| (b.timestamp, b.basis))
368 .collect(),
369
370 Kind::LongShortRatio => rest
371 .get_long_short_ratio_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
372 .await
373 .map_err(|e| StationError::Core(format!("long/short ratio history failed: {e}")))?
374 .into_iter()
375 .map(|r| {
376 let v = r.ratio.unwrap_or_else(|| {
378 if r.short_ratio > 0.0 { r.long_ratio / r.short_ratio } else { r.long_ratio }
379 });
380 (r.timestamp, v)
381 })
382 .collect(),
383
384 Kind::MarkPrice | Kind::IndexPrice => {
387 let which = if matches!(kind, Kind::MarkPrice) { "mark" } else { "index" };
388 let rest2 = rest.clone();
389 let sym = symbol.to_string();
390 let ivs = interval.as_str().to_string();
391 let bars = paginate_klines(start_ms, end_ms, |cursor| {
392 let rest2 = rest2.clone();
393 let sym = sym.clone();
394 let ivs = ivs.clone();
395 async move {
396 let res = if which == "mark" {
397 rest2.get_mark_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor)).await
398 } else {
399 rest2.get_index_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor)).await
400 };
401 res.map(|ks| ks.iter().map(BarPoint::from_kline).collect())
402 .map_err(|e| StationError::Core(format!("{which} klines failed: {e}")))
403 }
404 })
405 .await?;
406 let scalars = bars
408 .into_iter()
409 .map(|b| ScalarBar { bar_open_time: b.open_time, value: b.close, filled: false })
410 .collect();
411 return Ok(BarAlignedSeries::Scalar(scalars));
412 }
413
414 Kind::Liquidation => rest
416 .get_liquidation_history(Some(SymbolInput::Raw(symbol)), Some(start_ms), Some(end_ms), Some(1000), account)
417 .await
418 .map_err(|e| StationError::Core(format!("liquidation history failed: {e}")))?
419 .into_iter()
420 .filter_map(|liq| {
421 let value = liq.value.unwrap_or_else(|| liq.price * liq.quantity);
422 if value > 0.0 { Some((liq.timestamp, value)) } else { None }
423 })
424 .collect(),
425
426 Kind::InsuranceFund => rest
428 .get_insurance_fund(Some(SymbolInput::Raw(symbol)), account)
429 .await
430 .map_err(|e| StationError::Core(format!("insurance fund failed: {e}")))?
431 .into_iter()
432 .map(|f| (f.timestamp, f.balance))
433 .collect(),
434
435 Kind::TakerVolume => rest
437 .get_taker_volume_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
438 .await
439 .map_err(|e| StationError::Core(format!("taker volume history failed: {e}")))?
440 .into_iter()
441 .map(|tv| (tv.timestamp, tv.buy_volume + tv.sell_volume))
442 .collect(),
443
444 Kind::LiquidationBucket => rest
446 .get_liquidation_bucket_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
447 .await
448 .map_err(|e| StationError::Core(format!("liquidation bucket history failed: {e}")))?
449 .into_iter()
450 .map(|lb| {
451 let total = lb.long_liq_usd.unwrap_or(0.0) + lb.short_liq_usd.unwrap_or(0.0);
452 (lb.timestamp, total)
453 })
454 .collect(),
455
456 Kind::AggTrade => {
457 return Err(StationError::StreamNotSupported(format!(
458 "AggTrade bar-aligned history needs the recording daemon (no usable REST history endpoint)"
459 )));
460 }
461
462 other => {
463 return Err(StationError::StreamNotSupported(format!(
464 "bar-aligned loader does not yet support {other:?}"
465 )));
466 }
467 };
468
469 Ok(BarAlignedSeries::Scalar(resample(src, start_ms, end_ms, step, kind.fill_policy())))
470}
471
472pub async fn load_for_key(
476 hub: &Arc<ExchangeHub>,
477 key: &SeriesKey,
478 interval: &KlineInterval,
479 start_ms: i64,
480 end_ms: i64,
481) -> Result<BarAlignedSeries> {
482 let iv = match &key.kind {
483 Kind::Kline(iv) | Kind::MarkPriceKline(iv) | Kind::IndexPriceKline(iv) | Kind::PremiumIndexKline(iv) => iv.clone(),
484 _ => interval.clone(),
485 };
486 load_bar_aligned(hub, key.exchange, key.account_type, &key.symbol, &key.kind, &iv, start_ms, end_ms).await
487}
488
489#[derive(Debug, Clone)]
496pub struct MultiScalarBar {
497 pub bar_open_time: i64,
499 pub lanes: BTreeMap<&'static str, f64>,
502 pub filled: bool,
504}
505
506#[derive(Debug, Clone)]
509pub struct MultiScalarSeries {
510 pub lanes: Vec<&'static str>,
512 pub bars: Vec<MultiScalarBar>,
514}
515
516impl MultiScalarSeries {
517 pub fn len(&self) -> usize { self.bars.len() }
519 pub fn is_empty(&self) -> bool { self.bars.is_empty() }
521}
522
523fn resample_multi(
530 mut src: Vec<(i64, BTreeMap<&'static str, f64>)>,
531 lanes: &[&'static str],
532 start: i64,
533 end: i64,
534 step: i64,
535 policy: FillPolicy,
536) -> Vec<MultiScalarBar> {
537 src.sort_unstable_by_key(|(ts, _)| *ts);
538 let first_bar = start.div_euclid(step) * step;
539 let mut out = Vec::new();
540
541 match policy {
542 FillPolicy::ForwardFill => {
543 let mut last: BTreeMap<&'static str, f64> = BTreeMap::new();
545 let mut idx = 0usize;
546 let mut t = first_bar;
547 while t < end {
548 let bar_close = t + step;
549 let mut fresh = false;
550 while idx < src.len() && src[idx].0 < bar_close {
551 for lane in lanes {
552 if let Some(&v) = src[idx].1.get(lane) {
553 last.insert(lane, v);
554 }
555 }
556 if src[idx].0 >= t { fresh = true; }
557 idx += 1;
558 }
559 if !last.is_empty() {
561 let mut bar_lanes: BTreeMap<&'static str, f64> = BTreeMap::new();
562 for lane in lanes {
563 bar_lanes.insert(lane, *last.get(lane).unwrap_or(&f64::NAN));
564 }
565 out.push(MultiScalarBar { bar_open_time: t, lanes: bar_lanes, filled: !fresh });
566 }
567 t += step;
568 }
569 }
570 FillPolicy::ZeroFlow => {
571 let mut idx = 0usize;
572 let mut t = first_bar;
573 while t < end {
574 let bar_close = t + step;
575 let mut sums: BTreeMap<&'static str, f64> = lanes.iter().map(|&l| (l, 0.0)).collect();
576 while idx < src.len() && src[idx].0 < bar_close {
577 if src[idx].0 >= t {
578 for lane in lanes {
579 if let Some(&v) = src[idx].1.get(lane) {
580 if !v.is_nan() {
581 *sums.entry(lane).or_insert(0.0) += v;
582 }
583 }
584 }
585 }
586 idx += 1;
587 }
588 out.push(MultiScalarBar { bar_open_time: t, lanes: sums, filled: false });
589 t += step;
590 }
591 }
592 }
593 out
594}
595
596pub async fn load_bar_aligned_multi(
614 hub: &Arc<ExchangeHub>,
615 exchange: ExchangeId,
616 account: AccountType,
617 symbol: &str,
618 kind: &Kind,
619 interval: &KlineInterval,
620 start_ms: i64,
621 end_ms: i64,
622) -> Result<MultiScalarSeries> {
623 let rest = hub
624 .rest(exchange)
625 .ok_or_else(|| StationError::Core(format!("{exchange:?} not connected in hub")))?;
626
627 let step = interval_millis(interval.as_str())
628 .ok_or_else(|| StationError::Core(format!("interval {interval} has no fixed ms width")))?;
629
630 match kind {
631 Kind::FundingRate => {
632 const LANES: &[&str] = &[
633 "rate", "interest_8h", "index_price", "premium",
634 "realized_rate", "estimated_rate", "accrued_funding",
635 "funding_step", "funding_interval_hours",
636 ];
637 let items = rest
638 .get_funding_rate_history(SymbolInput::Raw(symbol), Some(start_ms), Some(end_ms), Some(1000), account)
639 .await
640 .map_err(|e| StationError::Core(format!("funding history failed: {e}")))?;
641 let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|f| {
642 let mut m = BTreeMap::new();
643 m.insert("rate", f.rate);
644 m.insert("interest_8h", f.interest_8h.unwrap_or(f64::NAN));
645 m.insert("index_price", f.index_price.unwrap_or(f64::NAN));
646 m.insert("premium", f.premium.unwrap_or(f64::NAN));
647 m.insert("realized_rate", f.realized_rate.unwrap_or(f64::NAN));
648 m.insert("estimated_rate", f.estimated_rate.unwrap_or(f64::NAN));
649 m.insert("accrued_funding", f.accrued_funding.unwrap_or(f64::NAN));
650 m.insert("funding_step", f.funding_step.unwrap_or(0) as f64);
651 m.insert("funding_interval_hours", f.funding_interval_hours.unwrap_or(f64::NAN));
652 (f.timestamp, m)
653 }).collect();
654 let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
655 Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
656 }
657
658 Kind::MarkPrice => {
659 const LANES: &[&str] = &[
660 "mark", "index", "estimated_settle", "funding_rate",
661 "interest_rate", "indicative_index", "deriv_price",
662 ];
663 let items = rest
664 .get_premium_index(Some(SymbolInput::Raw(symbol)), account)
665 .await
666 .map_err(|e| StationError::Core(format!("mark price failed: {e}")))?;
667 let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|mp| {
668 let mut m = BTreeMap::new();
669 m.insert("mark", mp.mark_price);
670 m.insert("index", mp.index_price.unwrap_or(f64::NAN));
671 m.insert("estimated_settle", mp.estimated_settle_price.unwrap_or(f64::NAN));
672 m.insert("funding_rate", mp.funding_rate.unwrap_or(f64::NAN));
673 m.insert("interest_rate", mp.interest_rate.unwrap_or(f64::NAN));
674 m.insert("indicative_index", mp.indicative_settle_price.unwrap_or(f64::NAN));
675 m.insert("deriv_price", mp.deriv_price.unwrap_or(f64::NAN));
676 (mp.timestamp, m)
677 }).collect();
678 let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
679 Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
680 }
681
682 Kind::OpenInterest => {
683 const LANES: &[&str] = &[
684 "open_interest", "open_interest_value", "oi_ccy", "oi_usd", "sum_oi",
685 ];
686 let items = rest
687 .get_open_interest_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
688 .await
689 .map_err(|e| StationError::Core(format!("open interest history failed: {e}")))?;
690 let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|oi| {
691 let mut m = BTreeMap::new();
692 m.insert("open_interest", oi.open_interest);
693 m.insert("open_interest_value", oi.open_interest_value.unwrap_or(f64::NAN));
694 m.insert("oi_ccy", oi.open_interest_ccy.unwrap_or(f64::NAN));
695 m.insert("oi_usd", oi.open_interest_usd.unwrap_or(f64::NAN));
696 m.insert("sum_oi", oi.sum_open_interest.unwrap_or(f64::NAN));
697 (oi.timestamp, m)
698 }).collect();
699 let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
700 Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
701 }
702
703 Kind::LongShortRatio => {
704 const LANES: &[&str] = &["ratio", "long_pct", "short_pct"];
705 let items = rest
706 .get_long_short_ratio_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
707 .await
708 .map_err(|e| StationError::Core(format!("long/short ratio history failed: {e}")))?;
709 let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|r| {
710 let combined = r.ratio.unwrap_or_else(|| {
711 if r.short_ratio > 0.0 { r.long_ratio / r.short_ratio } else { r.long_ratio }
712 });
713 let mut m = BTreeMap::new();
714 m.insert("ratio", combined);
715 m.insert("long_pct", r.long_ratio);
716 m.insert("short_pct", r.short_ratio);
717 (r.timestamp, m)
718 }).collect();
719 let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
720 Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
721 }
722
723 Kind::Basis => {
724 const LANES: &[&str] = &["basis", "futures_price", "index_price"];
725 let items = rest
726 .get_basis_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
727 .await
728 .map_err(|e| StationError::Core(format!("basis history failed: {e}")))?;
729 let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|b| {
730 let mut m = BTreeMap::new();
731 m.insert("basis", b.basis);
732 m.insert("futures_price", b.futures_price.unwrap_or(f64::NAN));
733 m.insert("index_price", b.index_price.unwrap_or(f64::NAN));
734 (b.timestamp, m)
735 }).collect();
736 let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
737 Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
738 }
739
740 Kind::Ticker => {
741 const LANES: &[&str] = &[
742 "last_price", "bid", "ask", "bid_qty", "ask_qty",
743 "volume", "quote_volume", "high_24h", "low_24h", "open_24h",
744 "weighted_avg", "last_qty", "count",
745 ];
746 let ticker = rest
747 .get_ticker(SymbolInput::Raw(symbol), account)
748 .await
749 .map_err(|e| StationError::Core(format!("ticker failed: {e}")))?;
750 let mut m = BTreeMap::new();
752 m.insert("last_price", ticker.last_price);
753 m.insert("bid", ticker.bid_price.unwrap_or(f64::NAN));
754 m.insert("ask", ticker.ask_price.unwrap_or(f64::NAN));
755 m.insert("bid_qty", ticker.bid_qty.unwrap_or(f64::NAN));
756 m.insert("ask_qty", ticker.ask_qty.unwrap_or(f64::NAN));
757 m.insert("volume", ticker.volume_24h.unwrap_or(f64::NAN));
758 m.insert("quote_volume", ticker.quote_volume_24h.unwrap_or(f64::NAN));
759 m.insert("high_24h", ticker.high_24h.unwrap_or(f64::NAN));
760 m.insert("low_24h", ticker.low_24h.unwrap_or(f64::NAN));
761 m.insert("open_24h", ticker.open_price.unwrap_or(f64::NAN));
762 m.insert("weighted_avg", ticker.weighted_avg_price.unwrap_or(f64::NAN));
763 m.insert("last_qty", f64::NAN); m.insert("count", ticker.count.map(|c| c as f64).unwrap_or(f64::NAN));
765 let src = vec![(ticker.timestamp, m)];
766 let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
767 Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
768 }
769
770 Kind::IndexPrice => {
771 const LANES: &[&str] = &["price", "high_24h", "low_24h", "open_24h"];
772 let sym = symbol.to_string();
776 let ivs = interval.as_str().to_string();
777 let rest2 = rest.clone();
778 let bars_raw = paginate_klines(start_ms, end_ms, |cursor| {
779 let rest2 = rest2.clone();
780 let sym = sym.clone();
781 let ivs = ivs.clone();
782 async move {
783 rest2.get_index_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor))
784 .await
785 .map(|ks| ks.iter().map(BarPoint::from_kline).collect())
786 .map_err(|e| StationError::Core(format!("index klines failed: {e}")))
787 }
788 }).await?;
789 let src: Vec<(i64, BTreeMap<&'static str, f64>)> = bars_raw.into_iter().map(|b| {
790 let mut m = BTreeMap::new();
791 m.insert("price", b.close);
792 m.insert("high_24h", f64::NAN);
793 m.insert("low_24h", f64::NAN);
794 m.insert("open_24h", f64::NAN);
795 (b.open_time, m)
796 }).collect();
797 let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ForwardFill);
798 Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
799 }
800
801 Kind::Liquidation => {
802 const LANES: &[&str] = &["total_value", "count", "long_value", "short_value"];
803 let items = rest
804 .get_liquidation_history(Some(SymbolInput::Raw(symbol)), Some(start_ms), Some(end_ms), Some(1000), account)
805 .await
806 .map_err(|e| StationError::Core(format!("liquidation history failed: {e}")))?;
807 let src: Vec<(i64, BTreeMap<&'static str, f64>)> = items.into_iter().map(|liq| {
808 let value = liq.value.unwrap_or_else(|| liq.price * liq.quantity);
809 let is_long_liq = matches!(liq.side, digdigdig3::core::types::TradeSide::Buy);
810 let mut m = BTreeMap::new();
811 m.insert("total_value", value);
812 m.insert("count", 1.0);
813 m.insert("long_value", if is_long_liq { value } else { 0.0 });
814 m.insert("short_value", if !is_long_liq { value } else { 0.0 });
815 (liq.timestamp, m)
816 }).collect();
817 let bars = resample_multi(src, LANES, start_ms, end_ms, step, FillPolicy::ZeroFlow);
818 Ok(MultiScalarSeries { lanes: LANES.to_vec(), bars })
819 }
820
821 other => Err(StationError::StreamNotSupported(format!(
822 "load_bar_aligned_multi does not support {other:?}"
823 ))),
824 }
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830
831 #[test]
832 fn interval_parsing() {
833 assert_eq!(interval_millis("1m"), Some(60_000));
834 assert_eq!(interval_millis("5m"), Some(300_000));
835 assert_eq!(interval_millis("1h"), Some(3_600_000));
836 assert_eq!(interval_millis("4h"), Some(14_400_000));
837 assert_eq!(interval_millis("1d"), Some(86_400_000));
838 assert_eq!(interval_millis("1w"), Some(604_800_000));
839 assert_eq!(interval_millis("1M"), None);
840 assert_eq!(interval_millis("xx"), None);
841 }
842
843 #[test]
844 fn forward_fill_carries_into_empty_bars() {
845 let step = 60_000;
847 let src = vec![(0, 10.0), (130_000, 20.0)];
848 let out = resample(src, 0, 240_000, step, FillPolicy::ForwardFill);
849 assert_eq!(out.len(), 4);
851 assert_eq!(out[0], ScalarBar { bar_open_time: 0, value: 10.0, filled: false });
852 assert_eq!(out[1], ScalarBar { bar_open_time: 60_000, value: 10.0, filled: true });
854 assert_eq!(out[2], ScalarBar { bar_open_time: 120_000, value: 20.0, filled: false });
856 assert_eq!(out[3], ScalarBar { bar_open_time: 180_000, value: 20.0, filled: true });
858 }
859
860 #[test]
861 fn forward_fill_omits_leading_gap() {
862 let step = 60_000;
863 let src = vec![(150_000, 5.0)];
865 let out = resample(src, 0, 240_000, step, FillPolicy::ForwardFill);
866 assert_eq!(out.len(), 2); assert_eq!(out[0].bar_open_time, 120_000);
868 assert_eq!(out[0].value, 5.0);
869 assert!(!out[0].filled);
870 assert_eq!(out[1], ScalarBar { bar_open_time: 180_000, value: 5.0, filled: true });
871 }
872
873 #[test]
874 fn zero_flow_buckets_and_zeros_gaps() {
875 let step = 60_000;
876 let src = vec![(1_000, 3.0), (50_000, 4.0), (125_000, 9.0)];
878 let out = resample(src, 0, 180_000, step, FillPolicy::ZeroFlow);
879 assert_eq!(out.len(), 3);
880 assert_eq!(out[0], ScalarBar { bar_open_time: 0, value: 7.0, filled: false });
881 assert_eq!(out[1], ScalarBar { bar_open_time: 60_000, value: 0.0, filled: false });
882 assert_eq!(out[2], ScalarBar { bar_open_time: 120_000, value: 9.0, filled: false });
883 }
884
885 #[test]
886 fn bar_align_points_flow_from_recorded() {
887 use crate::data::LiquidationPoint;
888 let pts = vec![
890 LiquidationPoint { ts_ms: 1_000, price: 100.0, quantity: 1.0, value: 5_000.0, side: 0 },
891 LiquidationPoint { ts_ms: 40_000, price: 100.0, quantity: 1.0, value: 3_000.0, side: 1 },
892 LiquidationPoint { ts_ms: 125_000, price: 100.0, quantity: 1.0, value: 9_000.0, side: 0 },
893 ];
894 let out = bar_align_points(
895 &pts, |p| p.value, 0, 180_000, &KlineInterval::new("1m"), FillPolicy::ZeroFlow,
896 ).unwrap();
897 assert_eq!(out.len(), 3);
898 assert_eq!(out[0], ScalarBar { bar_open_time: 0, value: 8_000.0, filled: false });
899 assert_eq!(out[1], ScalarBar { bar_open_time: 60_000, value: 0.0, filled: false });
900 assert_eq!(out[2], ScalarBar { bar_open_time: 120_000, value: 9_000.0, filled: false });
901 }
902
903 #[test]
904 fn grid_aligns_to_interval_boundary() {
905 let step = 60_000;
906 let src = vec![(70_000, 1.0)];
908 let out = resample(src, 35_000, 130_000, step, FillPolicy::ForwardFill);
909 assert_eq!(out[0].bar_open_time, 60_000); assert_eq!(out[0].value, 1.0);
912 }
913
914 fn make_multi_src(entries: Vec<(i64, &[(&'static str, f64)])>) -> Vec<(i64, BTreeMap<&'static str, f64>)> {
919 entries.into_iter().map(|(ts, pairs)| {
920 let m: BTreeMap<&'static str, f64> = pairs.iter().copied().collect();
921 (ts, m)
922 }).collect()
923 }
924
925 #[test]
926 fn resample_multi_forward_fill_two_lanes() {
927 let step = 60_000;
928 let src = make_multi_src(vec![
930 (0, &[("a", 1.0)]),
931 (130_000, &[("b", 2.0)]),
932 ]);
933 const LANES: &[&str] = &["a", "b"];
934 let out = resample_multi(src, LANES, 0, 240_000, step, FillPolicy::ForwardFill);
935 assert_eq!(out[0].bar_open_time, 0);
938 assert_eq!(out[0].lanes["a"], 1.0);
939 assert!(out[0].lanes["b"].is_nan());
940 assert!(!out[0].filled);
941
942 assert_eq!(out[1].bar_open_time, 60_000);
944 assert_eq!(out[1].lanes["a"], 1.0);
945 assert!(out[1].lanes["b"].is_nan());
946 assert!(out[1].filled);
947
948 assert_eq!(out[2].bar_open_time, 120_000);
950 assert_eq!(out[2].lanes["a"], 1.0);
951 assert_eq!(out[2].lanes["b"], 2.0);
952 assert!(!out[2].filled, "b is fresh at 130k");
953
954 assert_eq!(out[3].bar_open_time, 180_000);
956 assert!(out[3].filled);
957 }
958
959 #[test]
960 fn resample_multi_zero_flow_two_lanes() {
961 let step = 60_000;
962 let src = make_multi_src(vec![
964 (1_000, &[("total_value", 5_000.0), ("count", 1.0)]),
965 (50_000, &[("total_value", 3_000.0), ("count", 1.0)]),
966 (125_000, &[("total_value", 9_000.0), ("count", 1.0)]),
967 ]);
968 const LANES: &[&str] = &["total_value", "count"];
969 let out = resample_multi(src, LANES, 0, 180_000, step, FillPolicy::ZeroFlow);
970 assert_eq!(out.len(), 3);
971 assert_eq!(out[0].lanes["total_value"], 8_000.0, "sum of bar-0 events");
972 assert_eq!(out[0].lanes["count"], 2.0);
973 assert_eq!(out[1].lanes["total_value"], 0.0, "gap bar = 0");
974 assert_eq!(out[1].lanes["count"], 0.0);
975 assert_eq!(out[2].lanes["total_value"], 9_000.0);
976 }
977}