Skip to main content

digdigdig3_station/
bar_align.rs

1//! Bar-aligned historical series for non-OHLCV streams.
2//!
3//! The mlq backtester warmup is a slice factory: per `(symbol, timeframe)` it
4//! drives each indicator bar-by-bar. To feed the ~130 non-OHLCV mli indicators
5//! it needs, per `(symbol, timeframe, stream)`, a series **time-aligned to the
6//! OHLCV bar grid** — one value per bar — that warmup routes into the matching
7//! mli `update_*` method. This module is dig3's side of that contract: a fetch
8//! that returns, for `(exchange, account, symbol, kind, range)`, a bar-aligned
9//! series pulled through the shared [`ExchangeHub`] REST surface.
10//!
11//! Fill policy is decided by the *nature* of the stream, not a global flag
12//! (see [`Kind::fill_policy`]):
13//!
14//! * **State** streams (funding, OI, mark/index price, long/short ratio) are
15//!   levels → **last-value carry-forward** onto the bar grid. A bar with no
16//!   fresh observation carries the previous value (`filled = true`).
17//! * **Flow** streams (liquidation, aggTrade) are event flows → **bucket-sum
18//!   per bar**, gaps are a real `0.0` (`filled = false`).
19//!
20//! Kline-family kinds (mark/index/premium price klines) arrive already on the
21//! bar grid; they are returned verbatim as [`BarPoint`]s, no resample needed.
22
23use 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/// How a stream's observations collapse onto a bar.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FillPolicy {
37    /// State/level stream — carry the last observation forward into empty bars.
38    ForwardFill,
39    /// Event-flow stream — sum observations falling inside the bar; empty = 0.
40    ZeroFlow,
41}
42
43/// One bar's worth of a scalar non-OHLCV stream, aligned to the OHLCV grid.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct ScalarBar {
46    /// Bar open time (ms) — aligned to the interval grid, matches the kline key.
47    pub bar_open_time: i64,
48    /// The stream's value for this bar (rate / OI / ratio / price / flow-sum).
49    pub value: f64,
50    /// `true` if this bar carried a prior value (ForwardFill) rather than
51    /// observing a fresh one. Always `false` for ZeroFlow.
52    pub filled: bool,
53}
54
55/// Bar-aligned series, shaped by the stream kind. Mirrors the kline path so
56/// mlq keys it the same way and warmup feeds it to the matching `update_*`.
57#[derive(Debug, Clone)]
58pub enum BarAlignedSeries {
59    /// Mark/index/premium price klines — native OHLCV bars.
60    Klines(Vec<BarPoint>),
61    /// Scalar state or flow stream collapsed onto the bar grid.
62    Scalar(Vec<ScalarBar>),
63}
64
65impl BarAlignedSeries {
66    /// Number of bars in the series.
67    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    /// Fill policy for collapsing this stream's observations onto a bar.
80    /// State streams carry forward; flow streams bucket-sum with zero gaps.
81    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
90/// Interval string → bar step in milliseconds. Supports the standard kline
91/// intervals up to weekly. Calendar-month (`"1M"`) has no fixed ms width and
92/// returns `None` (kline-family months don't need this path; scalar resample
93/// at monthly is out of scope).
94fn 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
108/// Resample a sorted `(ts_ms, value)` source onto the `[start, end)` bar grid
109/// of width `step` ms, per `policy`. The grid is aligned to interval boundaries
110/// (`bar_open_time % step == 0`), matching exchange kline semantics.
111///
112/// * `ForwardFill`: each bar's value is the most recent observation known by
113///   the bar's close (`ts < bar_open + step`); leading bars with no prior
114///   observation are omitted (gap = none at the head).
115/// * `ZeroFlow`: each bar's value is the sum of observations with
116///   `ts ∈ [bar_open, bar_open + step)`; every bar in range is emitted (0 if
117///   none).
118fn 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
164/// Bar-align an in-memory series of recorded [`DataPoint`]s onto the OHLCV grid
165/// (Track C — daemon-recorded streams with no REST history: liquidations, L2,
166/// aggTrades). The forward-recording daemon (`dig3 watch` / Station persistence)
167/// writes typed points to a `DiskStore<T>`; a consumer reads them back
168/// (`DiskStore::read_tail` / `Series`) and feeds the slice here with a scalar
169/// projection to get the same bar-aligned shape the REST loader produces.
170///
171/// `project` extracts the scalar per point (e.g. liquidation notional, aggTrade
172/// size). Fill policy is the caller's choice: `ZeroFlow` for event flows
173/// (liq/aggTrade — sum per bar, gap=0), `ForwardFill` for recorded levels.
174///
175/// This is the in-process resample step; reading the points off disk and
176/// choosing the range is the caller's job (the recording is forward-only — you
177/// can only bar-align what the daemon has captured).
178pub 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/// Track-C read-path: read a recorded stream off its `DiskStore` and bar-align
204/// it in one call. Reads the last `max_points` records (the daemon appends
205/// forward-only, so the tail is the captured window), range-filters, and
206/// resamples per `project`/`policy`. Native-only — the wasm OPFS store has a
207/// divergent read error type; wasm consumers read points then call
208/// [`bar_align_points`] directly.
209#[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
230/// Cap a REST kline `limit` to the per-call exchange ceiling.
231const KLINE_PAGE: u16 = 1000;
232
233/// Paginate kline-family history backwards over `[start, end)` and return all
234/// bars in `[start, end)`, oldest-first, deduped by `open_time`.
235///
236/// `fetch` pulls up to `KLINE_PAGE` bars ending at the given exclusive
237/// `end_time` (oldest→newest). We walk `end_time` backwards until we reach
238/// `start` or the exchange stops returning new bars.
239async 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    // Safety bound: never loop forever on a misbehaving venue.
247    for _ in 0..256 {
248        // One retry to absorb a transient gateway error (e.g. Gate.io's
249        // premium-index endpoint intermittently 504s on heavy pages).
250        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        // Next page ends just before the oldest bar we already have.
263        let next = oldest - 1;
264        if next >= cursor {
265            break; // no progress
266        }
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
275/// Load a bar-aligned historical series for one `(exchange, account, symbol,
276/// kind, interval)` over `[start_ms, end_ms)`.
277///
278/// `symbol` must be exchange-native (already normalized) — matching the
279/// `SubscriptionSet::add_raw` / `fetch_history` convention.
280///
281/// Supported now (REST-historical, no daemon):
282/// * Kline-family: `Kline`, `MarkPriceKline`, `IndexPriceKline`,
283///   `PremiumIndexKline` → [`BarAlignedSeries::Klines`].
284/// * Scalar state: `FundingRate`, `OpenInterest`, `LongShortRatio`,
285///   `MarkPrice`, `IndexPrice` → [`BarAlignedSeries::Scalar`].
286///
287/// Flow streams (`Liquidation`, `AggTrade`) and book streams require the
288/// recording daemon and return `StreamNotSupported` here.
289pub 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    // ── Kline-family — native bar grid, just paginate + range-filter. ──────────
304    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    // ── Scalar streams — fetch source observations then resample to the grid. ──
342    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                // Prefer the exchange-provided combined ratio; else long/short.
377                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        // Scalar mark/index price: project the close of the corresponding
385        // derived kline (native bar grid — no separate resample).
386        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            // Already bar-aligned; project close directly.
407            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        // Liquidation: ZeroFlow — sum notional per bar from REST history.
415        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        // InsuranceFund: ForwardFill — snapshot balance onto the bar grid.
427        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        // TakerVolume: ZeroFlow — bucket buy_volume+sell_volume per bar.
436        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        // LiquidationBucket: ZeroFlow — sum long+short liq value per bar.
445        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
472/// Convenience wrapper keyed by [`SeriesKey`]. The interval is read from the
473/// key's kline-family kind; non-kline scalar kinds require `interval` to be
474/// passed via [`load_bar_aligned`] directly (they carry no interval in the key).
475pub 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// ── Multi-scalar bar-aligned loader ─────────────────────────────────────────
490
491/// One bar's worth of a multi-lane non-OHLCV stream, aligned to the OHLCV grid.
492/// Each lane carries one named scalar from an enriched wire payload. Missing /
493/// wire-absent values use `f64::NAN` so callers can distinguish "not reported
494/// by the exchange" from a genuine `0.0`.
495#[derive(Debug, Clone)]
496pub struct MultiScalarBar {
497    /// Bar open time (ms) — aligned to the interval grid, matches the kline key.
498    pub bar_open_time: i64,
499    /// Named lanes. Keys are short identifiers matching the source field names.
500    /// Missing wire-absent observations use `f64::NAN`.
501    pub lanes: BTreeMap<&'static str, f64>,
502    /// `true` if this bar was forward-filled (no fresh observation).
503    pub filled: bool,
504}
505
506/// Multi-lane bar-aligned historical series. All bars carry the same ordered
507/// set of lane names declared in `lanes` (the schema).
508#[derive(Debug, Clone)]
509pub struct MultiScalarSeries {
510    /// Lane names (schema), stable across all bars.
511    pub lanes: Vec<&'static str>,
512    /// Bars in ascending `bar_open_time` order.
513    pub bars: Vec<MultiScalarBar>,
514}
515
516impl MultiScalarSeries {
517    /// Number of bars.
518    pub fn len(&self) -> usize { self.bars.len() }
519    /// True when no bars are present.
520    pub fn is_empty(&self) -> bool { self.bars.is_empty() }
521}
522
523/// Resample a multi-lane source onto the `[start, end)` bar grid.
524///
525/// `src` is a time-ordered slice where each element is `(ts_ms, lane_map)`.
526/// Each lane follows the given `policy` independently. ForwardFill: carry the
527/// last known value; leading bars with no prior value emit `NAN`. ZeroFlow: sum
528/// observations per bar; gap bars emit `0.0`.
529fn 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            // Per-lane last-known value.
544            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                // Emit only if at least one lane has a value to carry.
560                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
596/// Multi-scalar bar-aligned historical series. Returns a [`MultiScalarSeries`]
597/// where each bar carries multiple named lanes from the enriched wire struct.
598///
599/// Supported kinds and their lanes:
600///
601/// | Kind | Lanes | Fill |
602/// |---|---|---|
603/// | FundingRate | rate, interest_8h, index_price, premium, realized_rate, estimated_rate, accrued_funding, funding_step, funding_interval_hours | ForwardFill |
604/// | MarkPrice | mark, index, estimated_settle, funding_rate, interest_rate, indicative_index, deriv_price | ForwardFill |
605/// | OpenInterest | open_interest, open_interest_value, oi_ccy, oi_usd, sum_oi | ForwardFill |
606/// | LongShortRatio | ratio, long_pct, short_pct | ForwardFill |
607/// | Basis | basis, futures_price, index_price | ForwardFill |
608/// | Ticker | last_price, bid, ask, bid_qty, ask_qty, volume, quote_volume, high_24h, low_24h, open_24h, weighted_avg, last_qty, count | ForwardFill |
609/// | IndexPrice | price, high_24h, low_24h, open_24h | ForwardFill |
610/// | Liquidation | total_value, count, long_value, short_value | ZeroFlow |
611///
612/// `symbol` must be exchange-native. `interval` is the bar grid width.
613pub 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            // Ticker is a single snapshot — wrap in vec for resample.
751            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); // not in Ticker — wire-absent
764            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            // Use index price klines for the price lane; OKX 24h stats from
773            // the index snapshot are not historically available per-bar via a
774            // single REST call, so those lanes carry NAN outside the snapshot ts.
775            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        // step 60s. Observations at t=0 (v=10) and t=130s (v=20).
846        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        // bars: 0,60k,120k,180k
850        assert_eq!(out.len(), 4);
851        assert_eq!(out[0], ScalarBar { bar_open_time: 0, value: 10.0, filled: false });
852        // bar 60k: no fresh obs → carry 10.0, filled
853        assert_eq!(out[1], ScalarBar { bar_open_time: 60_000, value: 10.0, filled: true });
854        // bar 120k: obs at 130k is inside [120k,180k) → fresh 20.0
855        assert_eq!(out[2], ScalarBar { bar_open_time: 120_000, value: 20.0, filled: false });
856        // bar 180k: carry 20.0
857        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        // First observation only at 150s — bars 0 and 60k have nothing to carry.
864        let src = vec![(150_000, 5.0)];
865        let out = resample(src, 0, 240_000, step, FillPolicy::ForwardFill);
866        assert_eq!(out.len(), 2); // only 120k and 180k
867        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        // Two events in bar 0, none in bar 60k, one in bar 120k.
877        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        // Recorded liquidations: two in bar 0, none in bar 60k, one in bar 120k.
889        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        // start mid-bar → grid still snaps to boundary below start.
907        let src = vec![(70_000, 1.0)];
908        let out = resample(src, 35_000, 130_000, step, FillPolicy::ForwardFill);
909        // first_bar = floor(35000/60000)*60000 = 0
910        assert_eq!(out[0].bar_open_time, 60_000); // bar 0 had no obs (omitted); 60k has obs at 70k
911        assert_eq!(out[0].value, 1.0);
912    }
913
914    // -----------------------------------------------------------------------
915    // resample_multi unit tests (Task B)
916    // -----------------------------------------------------------------------
917
918    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        // Lane "a" observed at t=0 (v=1.0). Lane "b" at t=130k (v=2.0).
929        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        // 4 bars: 0, 60k, 120k, 180k
936        // bar 0: a=1.0 fresh; b=NAN (no prior)
937        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        // bar 60k: carry a=1.0, b still NAN → filled
943        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        // bar 120k: obs at 130k → b=2.0 fresh, a=1.0 carried
949        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        // bar 180k: carry both
955        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        // Two events in bar 0, none in bar 60k, one in bar 120k.
963        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}