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::sync::Arc;
24
25use digdigdig3::connector_manager::ExchangeHub;
26use digdigdig3::core::types::{AccountType, ExchangeId, SymbolInput};
27use digdigdig3::core::websocket::KlineInterval;
28
29use crate::data::BarPoint;
30use crate::error::{Result, StationError};
31use crate::series::{Kind, SeriesKey};
32
33/// How a stream's observations collapse onto a bar.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum FillPolicy {
36    /// State/level stream — carry the last observation forward into empty bars.
37    ForwardFill,
38    /// Event-flow stream — sum observations falling inside the bar; empty = 0.
39    ZeroFlow,
40}
41
42/// One bar's worth of a scalar non-OHLCV stream, aligned to the OHLCV grid.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct ScalarBar {
45    /// Bar open time (ms) — aligned to the interval grid, matches the kline key.
46    pub bar_open_time: i64,
47    /// The stream's value for this bar (rate / OI / ratio / price / flow-sum).
48    pub value: f64,
49    /// `true` if this bar carried a prior value (ForwardFill) rather than
50    /// observing a fresh one. Always `false` for ZeroFlow.
51    pub filled: bool,
52}
53
54/// Bar-aligned series, shaped by the stream kind. Mirrors the kline path so
55/// mlq keys it the same way and warmup feeds it to the matching `update_*`.
56#[derive(Debug, Clone)]
57pub enum BarAlignedSeries {
58    /// Mark/index/premium price klines — native OHLCV bars.
59    Klines(Vec<BarPoint>),
60    /// Scalar state or flow stream collapsed onto the bar grid.
61    Scalar(Vec<ScalarBar>),
62}
63
64impl BarAlignedSeries {
65    /// Number of bars in the series.
66    pub fn len(&self) -> usize {
67        match self {
68            BarAlignedSeries::Klines(v) => v.len(),
69            BarAlignedSeries::Scalar(v) => v.len(),
70        }
71    }
72    pub fn is_empty(&self) -> bool {
73        self.len() == 0
74    }
75}
76
77impl Kind {
78    /// Fill policy for collapsing this stream's observations onto a bar.
79    /// State streams carry forward; flow streams bucket-sum with zero gaps.
80    pub fn fill_policy(&self) -> FillPolicy {
81        match self {
82            Kind::Liquidation | Kind::AggTrade | Kind::Trade => FillPolicy::ZeroFlow,
83            _ => FillPolicy::ForwardFill,
84        }
85    }
86}
87
88/// Interval string → bar step in milliseconds. Supports the standard kline
89/// intervals up to weekly. Calendar-month (`"1M"`) has no fixed ms width and
90/// returns `None` (kline-family months don't need this path; scalar resample
91/// at monthly is out of scope).
92fn interval_millis(iv: &str) -> Option<i64> {
93    let iv = iv.trim();
94    let (num, unit) = iv.split_at(iv.len().saturating_sub(1));
95    let n: i64 = num.parse().ok()?;
96    let per = match unit {
97        "m" => 60_000,
98        "h" => 60 * 60_000,
99        "d" | "D" => 24 * 60 * 60_000,
100        "w" | "W" => 7 * 24 * 60 * 60_000,
101        _ => return None,
102    };
103    Some(n * per)
104}
105
106/// Resample a sorted `(ts_ms, value)` source onto the `[start, end)` bar grid
107/// of width `step` ms, per `policy`. The grid is aligned to interval boundaries
108/// (`bar_open_time % step == 0`), matching exchange kline semantics.
109///
110/// * `ForwardFill`: each bar's value is the most recent observation known by
111///   the bar's close (`ts < bar_open + step`); leading bars with no prior
112///   observation are omitted (gap = none at the head).
113/// * `ZeroFlow`: each bar's value is the sum of observations with
114///   `ts ∈ [bar_open, bar_open + step)`; every bar in range is emitted (0 if
115///   none).
116fn resample(mut src: Vec<(i64, f64)>, start: i64, end: i64, step: i64, policy: FillPolicy) -> Vec<ScalarBar> {
117    src.sort_unstable_by_key(|(ts, _)| *ts);
118    let first_bar = start.div_euclid(step) * step;
119    let mut out = Vec::new();
120
121    match policy {
122        FillPolicy::ForwardFill => {
123            let mut idx = 0usize;
124            let mut last: Option<f64> = None;
125            let mut t = first_bar;
126            while t < end {
127                let bar_close = t + step;
128                let mut fresh = false;
129                while idx < src.len() && src[idx].0 < bar_close {
130                    last = Some(src[idx].1);
131                    if src[idx].0 >= t {
132                        fresh = true;
133                    }
134                    idx += 1;
135                }
136                if let Some(v) = last {
137                    out.push(ScalarBar { bar_open_time: t, value: v, filled: !fresh });
138                }
139                t += step;
140            }
141        }
142        FillPolicy::ZeroFlow => {
143            let mut idx = 0usize;
144            let mut t = first_bar;
145            while t < end {
146                let bar_close = t + step;
147                let mut sum = 0.0;
148                while idx < src.len() && src[idx].0 < bar_close {
149                    if src[idx].0 >= t {
150                        sum += src[idx].1;
151                    }
152                    idx += 1;
153                }
154                out.push(ScalarBar { bar_open_time: t, value: sum, filled: false });
155                t += step;
156            }
157        }
158    }
159    out
160}
161
162/// Bar-align an in-memory series of recorded [`DataPoint`]s onto the OHLCV grid
163/// (Track C — daemon-recorded streams with no REST history: liquidations, L2,
164/// aggTrades). The forward-recording daemon (`dig3 watch` / Station persistence)
165/// writes typed points to a `DiskStore<T>`; a consumer reads them back
166/// (`DiskStore::read_tail` / `Series`) and feeds the slice here with a scalar
167/// projection to get the same bar-aligned shape the REST loader produces.
168///
169/// `project` extracts the scalar per point (e.g. liquidation notional, aggTrade
170/// size). Fill policy is the caller's choice: `ZeroFlow` for event flows
171/// (liq/aggTrade — sum per bar, gap=0), `ForwardFill` for recorded levels.
172///
173/// This is the in-process resample step; reading the points off disk and
174/// choosing the range is the caller's job (the recording is forward-only — you
175/// can only bar-align what the daemon has captured).
176pub fn bar_align_points<T, F>(
177    points: &[T],
178    project: F,
179    start_ms: i64,
180    end_ms: i64,
181    interval: &KlineInterval,
182    policy: FillPolicy,
183) -> Result<Vec<ScalarBar>>
184where
185    T: crate::series::DataPoint,
186    F: Fn(&T) -> f64,
187{
188    let step = interval_millis(interval.as_str())
189        .ok_or_else(|| StationError::Core(format!("interval {interval} has no fixed ms width for resample")))?;
190    let src: Vec<(i64, f64)> = points
191        .iter()
192        .filter(|p| {
193            let t = p.timestamp_ms();
194            t >= start_ms && t < end_ms
195        })
196        .map(|p| (p.timestamp_ms(), project(p)))
197        .collect();
198    Ok(resample(src, start_ms, end_ms, step, policy))
199}
200
201/// Track-C read-path: read a recorded stream off its `DiskStore` and bar-align
202/// it in one call. Reads the last `max_points` records (the daemon appends
203/// forward-only, so the tail is the captured window), range-filters, and
204/// resamples per `project`/`policy`. Native-only — the wasm OPFS store has a
205/// divergent read error type; wasm consumers read points then call
206/// [`bar_align_points`] directly.
207#[cfg(not(target_arch = "wasm32"))]
208pub async fn bar_align_from_disk<T, F>(
209    store: &crate::series::DiskStore<T>,
210    project: F,
211    start_ms: i64,
212    end_ms: i64,
213    interval: &KlineInterval,
214    policy: FillPolicy,
215    max_points: usize,
216) -> Result<Vec<ScalarBar>>
217where
218    T: crate::series::DataPoint,
219    F: Fn(&T) -> f64,
220{
221    let pts = store
222        .read_tail(max_points)
223        .await
224        .map_err(|e| StationError::Core(format!("DiskStore read_tail failed: {e}")))?;
225    bar_align_points(&pts, project, start_ms, end_ms, interval, policy)
226}
227
228/// Cap a REST kline `limit` to the per-call exchange ceiling.
229const KLINE_PAGE: u16 = 1000;
230
231/// Paginate kline-family history backwards over `[start, end)` and return all
232/// bars in `[start, end)`, oldest-first, deduped by `open_time`.
233///
234/// `fetch` pulls up to `KLINE_PAGE` bars ending at the given exclusive
235/// `end_time` (oldest→newest). We walk `end_time` backwards until we reach
236/// `start` or the exchange stops returning new bars.
237async fn paginate_klines<F, Fut>(start: i64, end: i64, mut fetch: F) -> Result<Vec<BarPoint>>
238where
239    F: FnMut(i64) -> Fut,
240    Fut: std::future::Future<Output = Result<Vec<BarPoint>>>,
241{
242    let mut all: Vec<BarPoint> = Vec::new();
243    let mut cursor = end;
244    // Safety bound: never loop forever on a misbehaving venue.
245    for _ in 0..256 {
246        // One retry to absorb a transient gateway error (e.g. Gate.io's
247        // premium-index endpoint intermittently 504s on heavy pages).
248        let page = match fetch(cursor).await {
249            Ok(p) => p,
250            Err(_) => fetch(cursor).await?,
251        };
252        if page.is_empty() {
253            break;
254        }
255        let oldest = page.iter().map(|b| b.open_time).min().unwrap_or(cursor);
256        all.extend(page);
257        if oldest <= start {
258            break;
259        }
260        // Next page ends just before the oldest bar we already have.
261        let next = oldest - 1;
262        if next >= cursor {
263            break; // no progress
264        }
265        cursor = next;
266    }
267    all.retain(|b| b.open_time >= start && b.open_time < end);
268    all.sort_unstable_by_key(|b| b.open_time);
269    all.dedup_by_key(|b| b.open_time);
270    Ok(all)
271}
272
273/// Load a bar-aligned historical series for one `(exchange, account, symbol,
274/// kind, interval)` over `[start_ms, end_ms)`.
275///
276/// `symbol` must be exchange-native (already normalized) — matching the
277/// `SubscriptionSet::add_raw` / `fetch_history` convention.
278///
279/// Supported now (REST-historical, no daemon):
280/// * Kline-family: `Kline`, `MarkPriceKline`, `IndexPriceKline`,
281///   `PremiumIndexKline` → [`BarAlignedSeries::Klines`].
282/// * Scalar state: `FundingRate`, `OpenInterest`, `LongShortRatio`,
283///   `MarkPrice`, `IndexPrice` → [`BarAlignedSeries::Scalar`].
284///
285/// Flow streams (`Liquidation`, `AggTrade`) and book streams require the
286/// recording daemon and return `StreamNotSupported` here.
287pub async fn load_bar_aligned(
288    hub: &Arc<ExchangeHub>,
289    exchange: ExchangeId,
290    account: AccountType,
291    symbol: &str,
292    kind: &Kind,
293    interval: &KlineInterval,
294    start_ms: i64,
295    end_ms: i64,
296) -> Result<BarAlignedSeries> {
297    let rest = hub
298        .rest(exchange)
299        .ok_or_else(|| StationError::Core(format!("{exchange:?} not connected in hub")))?;
300
301    // ── Kline-family — native bar grid, just paginate + range-filter. ──────────
302    if let Some(klines) = match kind {
303        Kind::Kline(iv) => Some(("kline", iv.clone())),
304        Kind::MarkPriceKline(iv) => Some(("mark", iv.clone())),
305        Kind::IndexPriceKline(iv) => Some(("index", iv.clone())),
306        Kind::PremiumIndexKline(iv) => Some(("premium", iv.clone())),
307        _ => None,
308    } {
309        let (which, iv) = klines;
310        let rest = rest.clone();
311        let sym = symbol.to_string();
312        let bars = paginate_klines(start_ms, end_ms, |cursor| {
313            let rest = rest.clone();
314            let sym = sym.clone();
315            let ivs = iv.as_str().to_string();
316            async move {
317                let res = match which {
318                    "kline" => rest
319                        .get_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE), account, Some(cursor))
320                        .await,
321                    "mark" => rest
322                        .get_mark_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor))
323                        .await,
324                    "index" => rest
325                        .get_index_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor))
326                        .await,
327                    _ => rest
328                        .get_premium_index_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor))
329                        .await,
330                };
331                res.map(|ks| ks.iter().map(BarPoint::from_kline).collect())
332                    .map_err(|e| StationError::Core(format!("{which} klines failed: {e}")))
333            }
334        })
335        .await?;
336        return Ok(BarAlignedSeries::Klines(bars));
337    }
338
339    // ── Scalar streams — fetch source observations then resample to the grid. ──
340    let step = interval_millis(interval.as_str())
341        .ok_or_else(|| StationError::Core(format!("interval {interval} has no fixed ms width for resample")))?;
342
343    let src: Vec<(i64, f64)> = match kind {
344        Kind::FundingRate => rest
345            .get_funding_rate_history(SymbolInput::Raw(symbol), Some(start_ms), Some(end_ms), Some(1000), account)
346            .await
347            .map_err(|e| StationError::Core(format!("funding history failed: {e}")))?
348            .into_iter()
349            .map(|f| (f.timestamp, f.rate))
350            .collect(),
351
352        Kind::OpenInterest => rest
353            .get_open_interest_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
354            .await
355            .map_err(|e| StationError::Core(format!("open interest history failed: {e}")))?
356            .into_iter()
357            .map(|oi| (oi.timestamp, oi.open_interest))
358            .collect(),
359
360        Kind::Basis => rest
361            .get_basis_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
362            .await
363            .map_err(|e| StationError::Core(format!("basis history failed: {e}")))?
364            .into_iter()
365            .map(|b| (b.timestamp, b.basis))
366            .collect(),
367
368        Kind::LongShortRatio => rest
369            .get_long_short_ratio_history(SymbolInput::Raw(symbol), interval.as_str(), Some(start_ms), Some(end_ms), Some(500), account)
370            .await
371            .map_err(|e| StationError::Core(format!("long/short ratio history failed: {e}")))?
372            .into_iter()
373            .map(|r| {
374                // Prefer the exchange-provided combined ratio; else long/short.
375                let v = r.ratio.unwrap_or_else(|| {
376                    if r.short_ratio > 0.0 { r.long_ratio / r.short_ratio } else { r.long_ratio }
377                });
378                (r.timestamp, v)
379            })
380            .collect(),
381
382        // Scalar mark/index price: project the close of the corresponding
383        // derived kline (native bar grid — no separate resample).
384        Kind::MarkPrice | Kind::IndexPrice => {
385            let which = if matches!(kind, Kind::MarkPrice) { "mark" } else { "index" };
386            let rest2 = rest.clone();
387            let sym = symbol.to_string();
388            let ivs = interval.as_str().to_string();
389            let bars = paginate_klines(start_ms, end_ms, |cursor| {
390                let rest2 = rest2.clone();
391                let sym = sym.clone();
392                let ivs = ivs.clone();
393                async move {
394                    let res = if which == "mark" {
395                        rest2.get_mark_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor)).await
396                    } else {
397                        rest2.get_index_price_klines(SymbolInput::Raw(&sym), &ivs, Some(KLINE_PAGE as u32), account, Some(cursor)).await
398                    };
399                    res.map(|ks| ks.iter().map(BarPoint::from_kline).collect())
400                        .map_err(|e| StationError::Core(format!("{which} klines failed: {e}")))
401                }
402            })
403            .await?;
404            // Already bar-aligned; project close directly.
405            let scalars = bars
406                .into_iter()
407                .map(|b| ScalarBar { bar_open_time: b.open_time, value: b.close, filled: false })
408                .collect();
409            return Ok(BarAlignedSeries::Scalar(scalars));
410        }
411
412        Kind::Liquidation | Kind::AggTrade => {
413            return Err(StationError::StreamNotSupported(format!(
414                "{kind:?} bar-aligned history needs the recording daemon (no usable REST history)"
415            )));
416        }
417
418        other => {
419            return Err(StationError::StreamNotSupported(format!(
420                "bar-aligned loader does not yet support {other:?}"
421            )));
422        }
423    };
424
425    Ok(BarAlignedSeries::Scalar(resample(src, start_ms, end_ms, step, kind.fill_policy())))
426}
427
428/// Convenience wrapper keyed by [`SeriesKey`]. The interval is read from the
429/// key's kline-family kind; non-kline scalar kinds require `interval` to be
430/// passed via [`load_bar_aligned`] directly (they carry no interval in the key).
431pub async fn load_for_key(
432    hub: &Arc<ExchangeHub>,
433    key: &SeriesKey,
434    interval: &KlineInterval,
435    start_ms: i64,
436    end_ms: i64,
437) -> Result<BarAlignedSeries> {
438    let iv = match &key.kind {
439        Kind::Kline(iv) | Kind::MarkPriceKline(iv) | Kind::IndexPriceKline(iv) | Kind::PremiumIndexKline(iv) => iv.clone(),
440        _ => interval.clone(),
441    };
442    load_bar_aligned(hub, key.exchange, key.account_type, &key.symbol, &key.kind, &iv, start_ms, end_ms).await
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn interval_parsing() {
451        assert_eq!(interval_millis("1m"), Some(60_000));
452        assert_eq!(interval_millis("5m"), Some(300_000));
453        assert_eq!(interval_millis("1h"), Some(3_600_000));
454        assert_eq!(interval_millis("4h"), Some(14_400_000));
455        assert_eq!(interval_millis("1d"), Some(86_400_000));
456        assert_eq!(interval_millis("1w"), Some(604_800_000));
457        assert_eq!(interval_millis("1M"), None);
458        assert_eq!(interval_millis("xx"), None);
459    }
460
461    #[test]
462    fn forward_fill_carries_into_empty_bars() {
463        // step 60s. Observations at t=0 (v=10) and t=130s (v=20).
464        let step = 60_000;
465        let src = vec![(0, 10.0), (130_000, 20.0)];
466        let out = resample(src, 0, 240_000, step, FillPolicy::ForwardFill);
467        // bars: 0,60k,120k,180k
468        assert_eq!(out.len(), 4);
469        assert_eq!(out[0], ScalarBar { bar_open_time: 0, value: 10.0, filled: false });
470        // bar 60k: no fresh obs → carry 10.0, filled
471        assert_eq!(out[1], ScalarBar { bar_open_time: 60_000, value: 10.0, filled: true });
472        // bar 120k: obs at 130k is inside [120k,180k) → fresh 20.0
473        assert_eq!(out[2], ScalarBar { bar_open_time: 120_000, value: 20.0, filled: false });
474        // bar 180k: carry 20.0
475        assert_eq!(out[3], ScalarBar { bar_open_time: 180_000, value: 20.0, filled: true });
476    }
477
478    #[test]
479    fn forward_fill_omits_leading_gap() {
480        let step = 60_000;
481        // First observation only at 150s — bars 0 and 60k have nothing to carry.
482        let src = vec![(150_000, 5.0)];
483        let out = resample(src, 0, 240_000, step, FillPolicy::ForwardFill);
484        assert_eq!(out.len(), 2); // only 120k and 180k
485        assert_eq!(out[0].bar_open_time, 120_000);
486        assert_eq!(out[0].value, 5.0);
487        assert!(!out[0].filled);
488        assert_eq!(out[1], ScalarBar { bar_open_time: 180_000, value: 5.0, filled: true });
489    }
490
491    #[test]
492    fn zero_flow_buckets_and_zeros_gaps() {
493        let step = 60_000;
494        // Two events in bar 0, none in bar 60k, one in bar 120k.
495        let src = vec![(1_000, 3.0), (50_000, 4.0), (125_000, 9.0)];
496        let out = resample(src, 0, 180_000, step, FillPolicy::ZeroFlow);
497        assert_eq!(out.len(), 3);
498        assert_eq!(out[0], ScalarBar { bar_open_time: 0, value: 7.0, filled: false });
499        assert_eq!(out[1], ScalarBar { bar_open_time: 60_000, value: 0.0, filled: false });
500        assert_eq!(out[2], ScalarBar { bar_open_time: 120_000, value: 9.0, filled: false });
501    }
502
503    #[test]
504    fn bar_align_points_flow_from_recorded() {
505        use crate::data::LiquidationPoint;
506        // Recorded liquidations: two in bar 0, none in bar 60k, one in bar 120k.
507        let pts = vec![
508            LiquidationPoint { ts_ms: 1_000, price: 100.0, quantity: 1.0, value: 5_000.0, side: 0 },
509            LiquidationPoint { ts_ms: 40_000, price: 100.0, quantity: 1.0, value: 3_000.0, side: 1 },
510            LiquidationPoint { ts_ms: 125_000, price: 100.0, quantity: 1.0, value: 9_000.0, side: 0 },
511        ];
512        let out = bar_align_points(
513            &pts, |p| p.value, 0, 180_000, &KlineInterval::new("1m"), FillPolicy::ZeroFlow,
514        ).unwrap();
515        assert_eq!(out.len(), 3);
516        assert_eq!(out[0], ScalarBar { bar_open_time: 0, value: 8_000.0, filled: false });
517        assert_eq!(out[1], ScalarBar { bar_open_time: 60_000, value: 0.0, filled: false });
518        assert_eq!(out[2], ScalarBar { bar_open_time: 120_000, value: 9_000.0, filled: false });
519    }
520
521    #[test]
522    fn grid_aligns_to_interval_boundary() {
523        let step = 60_000;
524        // start mid-bar → grid still snaps to boundary below start.
525        let src = vec![(70_000, 1.0)];
526        let out = resample(src, 35_000, 130_000, step, FillPolicy::ForwardFill);
527        // first_bar = floor(35000/60000)*60000 = 0
528        assert_eq!(out[0].bar_open_time, 60_000); // bar 0 had no obs (omitted); 60k has obs at 70k
529        assert_eq!(out[0].value, 1.0);
530    }
531}