Skip to main content

digdigdig3_station/
backfill.rs

1//! REST backfill for warm-start.
2//!
3//! When the disk store has fewer than `warm` records, the forwarder calls one
4//! of these helpers to pull recent history from the exchange REST API. Each
5//! helper returns oldest→newest, already deduped against `existing_count`
6//! (so the caller can simply emit them to broadcast).
7//!
8//! Supported data-classes and their REST sources:
9//! - `trades_recent`            — `get_recent_trades`
10//! - `agg_trades_recent`        — `get_agg_trades`
11//! - `klines_recent`            — `get_klines`
12//! - `open_interest_recent`     — `get_open_interest_history`
13//! - `mark_price_recent`        — `get_premium_index` (current snapshot)
14//! - `funding_rate_recent`      — `get_funding_rate_history`
15//! - `liquidations_recent`      — `get_liquidation_history`
16//! - `insurance_fund_recent`    — `get_insurance_fund` (current snapshot)
17//! - `mark_price_klines_recent` — `get_mark_price_klines`
18//! - `index_price_klines_recent`   — `get_index_price_klines`
19//! - `premium_index_klines_recent` — `get_premium_index_klines`
20//!
21//! All helpers return empty vec on any error or unsupported endpoint.
22
23use std::sync::Arc;
24
25use digdigdig3::connector_manager::ExchangeHub;
26use digdigdig3::core::types::{
27    AccountType, AggTrade, ExchangeId, FundingRate, HistoryCursor, InsuranceFund, Liquidation,
28    MarkPrice, OpenInterest, SymbolInput, TradeHistoryTier, TradeSide,
29};
30use digdigdig3::core::websocket::KlineInterval;
31
32use crate::data::{
33    AggTradePoint, BarPoint, FundingRatePoint, FundingRateIndicatorsPoint, FundingRateFullPoint,
34    IndexPriceKlinePoint, InsuranceFundPoint,
35    LiquidationPoint, LiquidationIndicatorsPoint, LiquidationFullPoint,
36    MarkPriceKlinePoint, MarkPricePoint, MarkPriceIndicatorsPoint, MarkPriceFullPoint,
37    OpenInterestPoint, OpenInterestIndicatorsPoint,
38    PremiumIndexKlinePoint, TickerIndicatorsPoint, TickerFullPoint, TradePoint,
39};
40use crate::error::{Result, StationError};
41
42// ═══════════════════════════════════════════════════════════════════════════════
43// SEED OUTCOME — honest requested-vs-achieved reporting for trade/kline seeds
44// ═══════════════════════════════════════════════════════════════════════════════
45
46/// Where a trade/kline seed's points came from.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum SeedSource {
49    /// Paginated `get_agg_trades` walk (deep or window-capped).
50    AggTradesPaginated,
51    /// Single shallow `get_recent_trades` call (venue has no aggTrade
52    /// pagination, or capability says `RecentOnly`).
53    RecentTradesFallback,
54    /// Synthetic trades derived from paginated 1m klines (price-path
55    /// deep-seed path: renko/pnf/kagi/three-line-break).
56    KlineSynthetic,
57    /// Raw kline bars (no synthesis) — `klines_paginated` result used
58    /// directly, not folded into synthetic trades.
59    Klines,
60}
61
62/// Why a seed stopped short of `requested`.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum TruncationReason {
65    /// Capability says `RecentOnly` — a single shallow call is the venue's
66    /// entire history, no pagination was attempted.
67    VenueRecentOnly,
68    /// Capability says `RestWindow` and the cursor walk hit `max_back_ms`
69    /// before reaching `requested` points.
70    VenueWindowCap,
71    /// Pagination ran until the venue returned an empty/partial page —
72    /// genuinely exhausted history (or hit the ID/time origin), not a
73    /// declared ceiling.
74    VenueHistoryExhausted,
75    /// A REST call failed mid-walk; the seed carries whatever was
76    /// collected before the error.
77    Error,
78}
79
80/// Honest requested-vs-achieved report for one backfill/rewarm seed call.
81///
82/// Every `backfill::*` helper that used to return a bare `Vec<T>` (silently
83/// empty on error/truncation) now pairs its `Vec<T>` with one of these, so
84/// callers (and ultimately the bridge/UI) can distinguish "got everything
85/// asked for" from "venue capped us" from "call failed outright" instead of
86/// treating all three as the same empty-looking result.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct SeedOutcome {
89    /// Points the caller asked for (e.g. `warm_n`, or `page_size * n_pages`).
90    pub requested: usize,
91    /// Points actually returned.
92    pub achieved: usize,
93    /// Where the points came from.
94    pub source: SeedSource,
95    /// `None` = fully satisfied (`achieved >= requested` or the venue's
96    /// deep pagination ran to genuine exhaustion with no ceiling involved).
97    /// `Some(_)` = stopped short for a specific, nameable reason.
98    pub truncated_by: Option<TruncationReason>,
99}
100
101impl SeedOutcome {
102    /// Build a fully-satisfied outcome (no truncation).
103    pub const fn full(requested: usize, achieved: usize, source: SeedSource) -> Self {
104        Self { requested, achieved, source, truncated_by: None }
105    }
106
107    /// Build a truncated outcome.
108    pub const fn truncated(
109        requested: usize,
110        achieved: usize,
111        source: SeedSource,
112        reason: TruncationReason,
113    ) -> Self {
114        Self { requested, achieved, source, truncated_by: Some(reason) }
115    }
116
117    /// True when the seed produced at least as many points as requested,
118    /// or ended without a nameable truncation reason.
119    pub fn is_satisfied(&self) -> bool {
120        self.truncated_by.is_none() || self.achieved >= self.requested
121    }
122}
123
124/// Pull up to `limit` recent trades from REST for (exchange, account, symbol).
125/// Returns oldest→newest, paired with a [`SeedOutcome`] reporting
126/// requested-vs-achieved. Empty vec + `TruncationReason::Error` on any REST
127/// error or unsupported endpoint.
128pub async fn trades_recent(
129    hub: &Arc<ExchangeHub>,
130    exchange: ExchangeId,
131    account: AccountType,
132    symbol: &str,
133    limit: usize,
134) -> (Vec<TradePoint>, SeedOutcome) {
135    let Some(rest) = hub.rest(exchange) else {
136        return (
137            Vec::new(),
138            SeedOutcome::truncated(limit, 0, SeedSource::RecentTradesFallback, TruncationReason::Error),
139        );
140    };
141    // Clamp the request to what the venue's RecentOnly tier (or the
142    // conservative default) actually returns per call — no point asking
143    // for more than the venue can ever give back in one shot.
144    let cap = recent_only_cap(rest.trade_history_capabilities().tier_for(account));
145    let effective_limit = limit.min(cap.unwrap_or(1000)).min(1000).max(1);
146    let res = rest
147        .get_recent_trades(SymbolInput::Raw(symbol), Some(effective_limit as u32), account)
148        .await;
149    match res {
150        Ok(trades) => {
151            let points: Vec<TradePoint> = trades.iter().map(TradePoint::from_public).collect();
152            let achieved = points.len();
153            let outcome = if cap.is_some_and(|c| limit > c) {
154                SeedOutcome::truncated(limit, achieved, SeedSource::RecentTradesFallback, TruncationReason::VenueRecentOnly)
155            } else {
156                SeedOutcome::full(limit, achieved, SeedSource::RecentTradesFallback)
157            };
158            (points, outcome)
159        }
160        Err(e) => {
161            tracing::debug!(?e, exchange = ?exchange, "rest backfill trades failed");
162            (
163                Vec::new(),
164                SeedOutcome::truncated(limit, 0, SeedSource::RecentTradesFallback, TruncationReason::Error),
165            )
166        }
167    }
168}
169
170/// Maximum trades a single `RecentOnly` call can return, if the tier is
171/// `RecentOnly`. `None` for tiers that support pagination (no single-call
172/// ceiling relevant to this clamp).
173fn recent_only_cap(tier: TradeHistoryTier) -> Option<usize> {
174    match tier {
175        TradeHistoryTier::RecentOnly { max_trades } => Some(max_trades as usize),
176        _ => None,
177    }
178}
179
180/// Fetch up to `limit` historical bars ending at `end_time_ms` (exclusive)
181/// for the given series. Used by chart UIs for scroll-left pagination past
182/// the warm-start window.
183///
184/// Bypasses Station's persisted Series (pure REST through the shared
185/// `ExchangeHub`). Caller decides what to do with the result — typically
186/// merge into a local cache and re-render.
187///
188/// `end_time_ms` is exclusive: bars with `open_time >= end_time_ms` are
189/// excluded (matches the existing dig3-core `get_klines` semantic).
190///
191/// `symbol` must be in raw exchange-native form — no `SymbolNormalizer`
192/// is applied internally. The caller is responsible for normalization,
193/// matching the `SubscriptionSet::add_raw` convention.
194///
195/// Returns the raw `BarPoint` Vec sorted oldest-first.
196/// Returns `Ok(Vec::new())` if REST returns zero bars.
197/// Returns `Err(StationError::Core(...))` if `exchange` is not connected
198/// in `hub`.
199pub async fn fetch_history(
200    hub: &Arc<ExchangeHub>,
201    exchange: ExchangeId,
202    symbol: &str,
203    account_type: AccountType,
204    interval: &KlineInterval,
205    end_time_ms: i64,
206    limit: u16,
207) -> Result<Vec<BarPoint>> {
208    let rest = hub
209        .rest(exchange)
210        .ok_or_else(|| StationError::Core(format!("{exchange:?} not connected in hub")))?;
211    let limit = limit.min(1000).max(1);
212    let bars = rest
213        .get_klines(
214            SymbolInput::Raw(symbol),
215            interval.as_str(),
216            Some(limit),
217            account_type,
218            Some(end_time_ms),
219        )
220        .await
221        .map_err(|e| StationError::Core(format!("get_klines failed: {e}")))?;
222    let mut points: Vec<BarPoint> = bars.iter().map(BarPoint::from_kline).collect();
223    points.sort_unstable_by_key(|p| p.open_time);
224    Ok(points)
225}
226
227/// Pull up to `limit` klines (interval = `interval`) from REST for
228/// (exchange, account, symbol). Returns oldest→newest. Empty on any error.
229pub async fn klines_recent(
230    hub: &Arc<ExchangeHub>,
231    exchange: ExchangeId,
232    account: AccountType,
233    symbol: &str,
234    interval: &str,
235    limit: usize,
236) -> Vec<BarPoint> {
237    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
238    let limit = limit.min(1000).max(1) as u16;
239    let res = rest
240        .get_klines(
241            SymbolInput::Raw(symbol),
242            interval,
243            Some(limit),
244            account,
245            None,
246        )
247        .await;
248    match res {
249        Ok(bars) => bars.iter().map(BarPoint::from_kline).collect(),
250        Err(e) => {
251            tracing::debug!(?e, exchange = ?exchange, interval, "rest backfill klines failed");
252            Vec::new()
253        }
254    }
255}
256
257/// Paginated kline backfill for deep warm-seeding of price-path-triggered
258/// derived bars (Renko/PnF/Kagi/Three-Line-Break). Walks the `end_time_ms`
259/// cursor backwards `n_pages` times to gather weeks of 1m history — a
260/// single page (1000 bars of `1m`) covers under a day, not enough to
261/// bootstrap a multi-week brick/column/segment chart.
262///
263/// Algorithm (mirrors `agg_trades_paginated`'s `from_id` cursor walk, using
264/// `get_klines`'s `end_time` cursor instead — see `fetch_history` for the
265/// same semantic on a single page):
266/// 1. First page: request `page_size` bars ending at `now_ms` (exclusive
267///    cursor) → newest `page_size` bars.
268/// 2. For each subsequent page: `end_time_ms = min(open_time)_of_prev_page`
269///    → returns `page_size` bars strictly earlier.
270/// 3. Stop when the venue returns fewer than `page_size` bars (history
271///    exhausted) or when `n_pages` have been collected.
272///
273/// Dedupes by `open_time` and returns oldest→newest, paired with a
274/// [`SeedOutcome`]. Empty vec + `TruncationReason::Error` on any error or
275/// unsupported endpoint (graceful degrade — caller falls back to today's
276/// shallow aggTrade-only seed).
277///
278/// Consults `TradeHistoryCapabilities::kline_backpage` first: when the
279/// connector's `get_klines` does not actually wire `end_time` through to
280/// the REST call, a paginated walk here would just refetch the same page
281/// `n_pages` times — so a false flag skips straight to a single page and
282/// reports `TruncationReason::VenueWindowCap` (the honesty flag, not a
283/// venue-side depth ceiling, is what stopped the walk).
284pub async fn klines_paginated(
285    hub: &Arc<ExchangeHub>,
286    exchange: ExchangeId,
287    account: AccountType,
288    symbol: &str,
289    interval: &str,
290    page_size: usize,
291    n_pages: usize,
292) -> (Vec<BarPoint>, SeedOutcome) {
293    use std::collections::BTreeMap;
294    let requested = page_size.saturating_mul(n_pages);
295    if n_pages == 0 || page_size == 0 {
296        return (Vec::new(), SeedOutcome::full(0, 0, SeedSource::Klines));
297    }
298    let Some(rest) = hub.rest(exchange) else {
299        return (Vec::new(), SeedOutcome::truncated(requested, 0, SeedSource::Klines, TruncationReason::Error));
300    };
301    let limit = page_size.min(1000).max(1) as u16;
302    let kline_backpage = rest.trade_history_capabilities().kline_backpage;
303    let effective_n_pages = if kline_backpage { n_pages } else { 1 };
304
305    let mut by_open_time: BTreeMap<i64, BarPoint> = BTreeMap::new();
306    let mut next_end_time: Option<i64> = Some(chrono::Utc::now().timestamp_millis());
307    let mut pages_done = 0usize;
308    let mut had_error = false;
309
310    while pages_done < effective_n_pages {
311        let res = rest
312            .get_klines(SymbolInput::Raw(symbol), interval, Some(limit), account, next_end_time)
313            .await;
314        let page: Vec<BarPoint> = match res {
315            Ok(bars) => bars.iter().map(BarPoint::from_kline).collect(),
316            Err(e) => {
317                tracing::debug!(
318                    ?e,
319                    exchange = ?exchange,
320                    interval,
321                    page = pages_done,
322                    "klines_paginated page failed; stopping at partial result"
323                );
324                had_error = true;
325                break;
326            }
327        };
328        if page.is_empty() {
329            break;
330        }
331        let min_open_time = page.iter().map(|p| p.open_time).min().unwrap_or(0);
332        let was_partial = page.len() < limit as usize;
333        for p in page {
334            by_open_time.entry(p.open_time).or_insert(p);
335        }
336        pages_done += 1;
337        if was_partial {
338            // Venue exhausted its history window — no point in another call.
339            break;
340        }
341        // Next page: bars strictly earlier than this page's oldest bar.
342        next_end_time = Some(min_open_time);
343    }
344
345    let achieved = by_open_time.len();
346    let outcome = if had_error {
347        SeedOutcome::truncated(requested, achieved, SeedSource::Klines, TruncationReason::Error)
348    } else if !kline_backpage && n_pages > 1 {
349        SeedOutcome::truncated(requested, achieved, SeedSource::Klines, TruncationReason::VenueWindowCap)
350    } else if pages_done < effective_n_pages {
351        SeedOutcome::truncated(requested, achieved, SeedSource::Klines, TruncationReason::VenueHistoryExhausted)
352    } else {
353        SeedOutcome::full(requested, achieved, SeedSource::Klines)
354    };
355
356    (by_open_time.into_values().collect(), outcome)
357}
358
359/// Pull up to `limit` aggregated trades from REST for (exchange, account, symbol).
360/// Returns oldest→newest, paired with a [`SeedOutcome`]. Empty on any error
361/// or unsupported endpoint.
362pub async fn agg_trades_recent(
363    hub: &Arc<ExchangeHub>,
364    exchange: ExchangeId,
365    account: AccountType,
366    symbol: &str,
367    limit: usize,
368) -> (Vec<AggTradePoint>, SeedOutcome) {
369    let Some(rest) = hub.rest(exchange) else {
370        return (Vec::new(), SeedOutcome::truncated(limit, 0, SeedSource::AggTradesPaginated, TruncationReason::Error));
371    };
372    let effective_limit = limit.min(1000).max(1) as u32;
373    let res = rest
374        .get_agg_trades(SymbolInput::Raw(symbol), Some(effective_limit), None, account)
375        .await;
376    match res {
377        Ok(trades) => {
378            let points: Vec<AggTradePoint> = trades.iter().map(agg_trade_point_from).collect();
379            let achieved = points.len();
380            let outcome = SeedOutcome::full(limit, achieved, SeedSource::AggTradesPaginated);
381            (points, outcome)
382        }
383        Err(e) => {
384            tracing::debug!(?e, exchange = ?exchange, "rest backfill agg_trades failed");
385            (Vec::new(), SeedOutcome::truncated(limit, 0, SeedSource::AggTradesPaginated, TruncationReason::Error))
386        }
387    }
388}
389
390/// Paginated aggTrade backfill for deep warm-seeding of derived bars
391/// (range/volume/tick/footprint). Walks `from_id` backwards `n_pages`
392/// times to gather a real warm window — a single page (1000) covers
393/// only ~1-5 seconds of BTC liquidity, which is not enough to derive
394/// even one closed range bar at typical parameters.
395///
396/// Algorithm (Binance aggTrades / MEXC spot):
397/// 1. First page: request `limit` trades, no cursor → newest 1000.
398/// 2. For each subsequent page: `from_id = min_id_of_prev_page - limit`
399///    → returns 1000 strictly earlier aggregates.
400/// 3. Stop when the venue returns < limit results (history exhausted)
401///    or when n_pages have been collected.
402///
403/// Dedupes by `aggregate_id` and returns oldest→newest, paired with a
404/// [`SeedOutcome`]. On exchanges without paginated aggTrades support
405/// (`NotImplemented` from `get_agg_trades`), returns the result of
406/// `trades_recent` (single page recent trades) as a graceful fallback.
407///
408/// Consults `TradeHistoryCapabilities::tier_for(account)` BEFORE ever
409/// calling `get_agg_trades`:
410/// - `RecentOnly` → skip pagination entirely, go straight to the
411///   `trades_recent` fallback clamped to `max_trades`. No "attempt" against
412///   an endpoint the venue caps to a single shallow call — hammering it
413///   with a from_id cursor buys nothing. `truncated_by =
414///   TruncationReason::VenueRecentOnly`.
415/// - `RestWindow` → page normally, but stop the walk once a page's oldest
416///   trade crosses `now - max_back_ms` (the venue-side wall, e.g. Binance
417///   futures aggTrades 24h) instead of discovering the wall by an empty
418///   page. `truncated_by = TruncationReason::VenueWindowCap`.
419/// - `RestDeep` / `FileDump` (FileDump has no REST path yet — treated like
420///   RestDeep for this fn) → current behavior: page until `n_pages` are
421///   collected or the venue returns a partial/empty page
422///   (`TruncationReason::VenueHistoryExhausted`).
423///
424/// ## Cursor arithmetic branches by `HistoryCursor` (Wave 2)
425///
426/// The `u64` cursor threaded through `get_agg_trades`'s `from_id` parameter
427/// means two different things depending on the tier's declared
428/// [`HistoryCursor`]:
429/// - `FromId` / `TsCursor` (Binance, OKX, MEXC-once-fixed): the cursor is a
430///   trade/aggregate ID. Next-page cursor = `min_id_of_prev_page - limit`
431///   (ID-dense venues where subtracting the page size walks back
432///   approximately one page).
433/// - `TsWindow` (Bitfinex `/hist`, Gate.io `from`/`to`): the venue has NO id
434///   cursor — pagination is a sliding `(start, end)` timestamp window. For
435///   these connectors `get_agg_trades`'s `from_id: Option<u64>` is
436///   RE-PURPOSED to carry a millisecond timestamp (the `end` bound for the
437///   next call), and each returned `AggTrade.aggregate_id` is set to that
438///   same timestamp (ms) rather than a real per-trade ID — the venue has no
439///   ID concept, and encoding ts-as-agg_id makes the shared `min_id`/dedup
440///   machinery below produce the correct "next window ends 1ms before the
441///   oldest ts of the previous page" cursor without a value it can't
442///   diminish safely: `min_ts - 1`, not `min_ts - limit` (subtracting a
443///   trade COUNT from a millisecond timestamp is dimensionally wrong and
444///   would either barely move the window in a busy market or skip real
445///   history in a quiet one).
446pub async fn agg_trades_paginated(
447    hub: &Arc<ExchangeHub>,
448    exchange: ExchangeId,
449    account: AccountType,
450    symbol: &str,
451    page_size: usize,
452    n_pages: usize,
453) -> (Vec<AggTradePoint>, SeedOutcome) {
454    use std::collections::BTreeMap;
455    let requested = page_size.saturating_mul(n_pages);
456    if n_pages == 0 || page_size == 0 {
457        return (Vec::new(), SeedOutcome::full(0, 0, SeedSource::AggTradesPaginated));
458    }
459    let Some(rest) = hub.rest(exchange) else {
460        return (Vec::new(), SeedOutcome::truncated(requested, 0, SeedSource::AggTradesPaginated, TruncationReason::Error));
461    };
462    let limit = page_size.min(1000).max(1) as u32;
463
464    let tier = rest.trade_history_capabilities().tier_for(account);
465    if let TradeHistoryTier::RecentOnly { max_trades } = tier {
466        // Venue has no pagination cursor at all — a from_id walk against
467        // this endpoint would just refetch the same fixed window forever.
468        // Go straight to the shallow fallback.
469        let cap = (max_trades as usize).min(limit as usize).max(1);
470        let (trades, _inner_outcome) = trades_recent(hub, exchange, account, symbol, cap).await;
471        let achieved = trades.len();
472        let points: Vec<AggTradePoint> = trades
473            .into_iter()
474            .enumerate()
475            .map(|(i, t)| AggTradePoint {
476                ts_ms: t.ts_ms,
477                price: t.price,
478                quantity: t.quantity,
479                side: t.side,
480                agg_id: i as u64,
481            })
482            .collect();
483        return (
484            points,
485            SeedOutcome::truncated(requested, achieved, SeedSource::RecentTradesFallback, TruncationReason::VenueRecentOnly),
486        );
487    }
488    let window_wall_ms: Option<i64> = match tier {
489        TradeHistoryTier::RestWindow { max_back_ms, .. } if max_back_ms > 0 => {
490            Some(chrono::Utc::now().timestamp_millis() - max_back_ms as i64)
491        }
492        _ => None,
493    };
494    let cursor_kind = match tier {
495        TradeHistoryTier::RestDeep { cursor } => cursor,
496        TradeHistoryTier::RestWindow { cursor, .. } => cursor,
497        // FileDump has no REST path yet; RecentOnly already returned above.
498        _ => HistoryCursor::FromId,
499    };
500    let is_ts_window = cursor_kind == HistoryCursor::TsWindow;
501
502    let mut by_id: BTreeMap<u64, AggTradePoint> = BTreeMap::new();
503    let mut next_from_id: Option<u64> = None;
504    let mut pages_done = 0usize;
505    let mut agg_fallback = false;
506    let mut hit_window_wall = false;
507
508    while pages_done < n_pages {
509        let res = rest
510            .get_agg_trades(SymbolInput::Raw(symbol), Some(limit), next_from_id, account)
511            .await;
512        let page: Vec<AggTradePoint> = match res {
513            Ok(trades) => trades.iter().map(agg_trade_point_from).collect(),
514            Err(e) => {
515                if pages_done == 0 {
516                    // First page failed → venue lacks aggTrades; fall back.
517                    tracing::debug!(
518                        ?e,
519                        exchange = ?exchange,
520                        "agg_trades_paginated first page failed; falling back to trades_recent"
521                    );
522                    agg_fallback = true;
523                } else {
524                    // Subsequent page failed → keep what we have.
525                    tracing::debug!(
526                        ?e,
527                        exchange = ?exchange,
528                        page = pages_done,
529                        "agg_trades_paginated page failed; stopping at partial result"
530                    );
531                }
532                break;
533            }
534        };
535        if page.is_empty() {
536            break;
537        }
538        // Find the earliest aggregate_id / timestamp on this page (cursor
539        // for next / wall check).
540        let min_id = page.iter().map(|p| p.agg_id).min().unwrap_or(0);
541        let min_ts = page.iter().map(|p| p.ts_ms).min().unwrap_or(i64::MAX);
542        let was_partial = page.len() < limit as usize;
543        for p in page {
544            by_id.entry(p.agg_id).or_insert(p);
545        }
546        pages_done += 1;
547        if let Some(wall) = window_wall_ms {
548            if min_ts <= wall {
549                // This page already crossed the venue-side lookback wall —
550                // stop paging further, we're past the productive window.
551                hit_window_wall = true;
552                break;
553            }
554        }
555        if was_partial {
556            // Venue exhausted its history window — no point in another call.
557            break;
558        }
559        if is_ts_window {
560            // TsWindow venues (Bitfinex/Gate.io): `agg_id` on returned points
561            // IS the millisecond timestamp (see doc comment above) — advance
562            // the window by 1ms strictly before the oldest point, not by
563            // subtracting a trade count from a timestamp.
564            if min_id == 0 {
565                // Reached the beginning of recorded history (ts 0).
566                break;
567            }
568            next_from_id = Some(min_id - 1);
569        } else {
570            // Next page: trades strictly earlier than min_id.
571            if min_id <= limit as u64 {
572                // Reached the beginning of the venue history.
573                break;
574            }
575            next_from_id = Some(min_id.saturating_sub(limit as u64));
576        }
577    }
578
579    if agg_fallback {
580        // aggTrades not implemented for this venue. Single-shot
581        // trades_recent through SymbolInput is the best we can do
582        // without per-venue REST paths; wrap as AggTradePoint with
583        // synthetic agg_ids (the bar derivation does not depend on
584        // agg_id, only ts/price/qty/side).
585        let (trades, _inner_outcome) = trades_recent(hub, exchange, account, symbol, limit as usize).await;
586        let achieved = trades.len();
587        let points: Vec<AggTradePoint> = trades
588            .into_iter()
589            .enumerate()
590            .map(|(i, t)| AggTradePoint {
591                ts_ms: t.ts_ms,
592                price: t.price,
593                quantity: t.quantity,
594                side: t.side,
595                agg_id: i as u64,
596            })
597            .collect();
598        return (
599            points,
600            SeedOutcome::truncated(requested, achieved, SeedSource::RecentTradesFallback, TruncationReason::Error),
601        );
602    }
603
604    let achieved = by_id.len();
605    let outcome = if hit_window_wall {
606        SeedOutcome::truncated(requested, achieved, SeedSource::AggTradesPaginated, TruncationReason::VenueWindowCap)
607    } else if pages_done < n_pages {
608        SeedOutcome::truncated(requested, achieved, SeedSource::AggTradesPaginated, TruncationReason::VenueHistoryExhausted)
609    } else {
610        SeedOutcome::full(requested, achieved, SeedSource::AggTradesPaginated)
611    };
612
613    (by_id.into_values().collect(), outcome)
614}
615
616fn agg_trade_point_from(t: &AggTrade) -> AggTradePoint {
617    let side = if t.is_buy { 0u8 } else { 1u8 };
618    AggTradePoint {
619        ts_ms: t.timestamp,
620        price: t.price,
621        quantity: t.quantity,
622        side,
623        agg_id: t.aggregate_id as u64,
624    }
625}
626
627/// Pull up to `limit` open-interest history snapshots from REST for
628/// (exchange, account, symbol). Returns oldest→newest. Empty on any error.
629pub async fn open_interest_recent(
630    hub: &Arc<ExchangeHub>,
631    exchange: ExchangeId,
632    account: AccountType,
633    symbol: &str,
634    limit: usize,
635) -> Vec<OpenInterestPoint> {
636    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
637    let limit = limit.min(1000).max(1) as u32;
638    let res = rest
639        .get_open_interest_history(
640            SymbolInput::Raw(symbol),
641            "5m",
642            None,
643            None,
644            Some(limit),
645            account,
646        )
647        .await;
648    match res {
649        Ok(items) => items.iter().map(oi_point_from).collect(),
650        Err(e) => {
651            tracing::debug!(?e, exchange = ?exchange, "rest backfill open_interest failed");
652            Vec::new()
653        }
654    }
655}
656
657fn oi_point_from(oi: &OpenInterest) -> OpenInterestPoint {
658    OpenInterestPoint {
659        ts_ms: oi.timestamp,
660        open_interest: oi.open_interest,
661        open_interest_value: oi.open_interest_value.unwrap_or(f64::NAN),
662    }
663}
664
665/// Fetch the current mark-price snapshot from REST for (exchange, account, symbol).
666/// Returns 0 or 1 element (single snapshot, not history). Empty on any error.
667pub async fn mark_price_recent(
668    hub: &Arc<ExchangeHub>,
669    exchange: ExchangeId,
670    account: AccountType,
671    symbol: &str,
672    _limit: usize,
673) -> Vec<MarkPricePoint> {
674    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
675    let res = rest
676        .get_premium_index(Some(SymbolInput::Raw(symbol)), account)
677        .await;
678    match res {
679        Ok(items) => items.iter().map(mark_price_point_from).collect(),
680        Err(e) => {
681            tracing::debug!(?e, exchange = ?exchange, "rest backfill mark_price failed");
682            Vec::new()
683        }
684    }
685}
686
687fn mark_price_point_from(mp: &MarkPrice) -> MarkPricePoint {
688    MarkPricePoint {
689        ts_ms: mp.timestamp,
690        mark: mp.mark_price,
691        index: mp.index_price.unwrap_or(f64::NAN),
692    }
693}
694
695/// Pull up to `limit` historical funding rates from REST for (exchange, account, symbol).
696/// Returns oldest→newest. Empty on any error.
697pub async fn funding_rate_recent(
698    hub: &Arc<ExchangeHub>,
699    exchange: ExchangeId,
700    account: AccountType,
701    symbol: &str,
702    limit: usize,
703) -> Vec<FundingRatePoint> {
704    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
705    let limit = limit.min(1000).max(1) as u32;
706    let res = rest
707        .get_funding_rate_history(SymbolInput::Raw(symbol), None, None, Some(limit), account)
708        .await;
709    match res {
710        Ok(items) => items.iter().map(funding_rate_point_from).collect(),
711        Err(e) => {
712            tracing::debug!(?e, exchange = ?exchange, "rest backfill funding_rate failed");
713            Vec::new()
714        }
715    }
716}
717
718fn funding_rate_point_from(fr: &FundingRate) -> FundingRatePoint {
719    FundingRatePoint {
720        ts_ms: fr.timestamp,
721        rate: fr.rate,
722        next_funding_time_ms: fr.next_funding_time.unwrap_or(0),
723    }
724}
725
726/// Pull up to `limit` historical liquidation events from REST for
727/// (exchange, account, symbol). Returns oldest→newest. Empty on any error.
728pub async fn liquidations_recent(
729    hub: &Arc<ExchangeHub>,
730    exchange: ExchangeId,
731    account: AccountType,
732    symbol: &str,
733    limit: usize,
734) -> Vec<LiquidationPoint> {
735    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
736    let limit = limit.min(1000).max(1) as u32;
737    let res = rest
738        .get_liquidation_history(
739            Some(SymbolInput::Raw(symbol)),
740            None,
741            None,
742            Some(limit),
743            account,
744        )
745        .await;
746    match res {
747        Ok(items) => items.iter().map(liquidation_point_from).collect(),
748        Err(e) => {
749            tracing::debug!(?e, exchange = ?exchange, "rest backfill liquidations failed");
750            Vec::new()
751        }
752    }
753}
754
755fn liquidation_point_from(liq: &Liquidation) -> LiquidationPoint {
756    let side = match liq.side {
757        TradeSide::Buy => 0u8,
758        TradeSide::Sell => 1u8,
759    };
760    LiquidationPoint {
761        ts_ms: liq.timestamp,
762        price: liq.price,
763        quantity: liq.quantity,
764        value: liq.value.unwrap_or(f64::NAN),
765        side,
766    }
767}
768
769/// Fetch the current insurance fund snapshot from REST for (exchange, account, symbol).
770/// Returns 0 or 1 element. Empty on any error.
771pub async fn insurance_fund_recent(
772    hub: &Arc<ExchangeHub>,
773    exchange: ExchangeId,
774    account: AccountType,
775    symbol: &str,
776    _limit: usize,
777) -> Vec<InsuranceFundPoint> {
778    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
779    let res = rest
780        .get_insurance_fund(Some(SymbolInput::Raw(symbol)), account)
781        .await;
782    match res {
783        Ok(items) => items.iter().map(insurance_fund_point_from).collect(),
784        Err(e) => {
785            tracing::debug!(?e, exchange = ?exchange, "rest backfill insurance_fund failed");
786            Vec::new()
787        }
788    }
789}
790
791fn insurance_fund_point_from(fund: &InsuranceFund) -> InsuranceFundPoint {
792    InsuranceFundPoint {
793        ts_ms: fund.timestamp,
794        balance: fund.balance,
795    }
796}
797
798/// Pull up to `limit` mark-price klines from REST for (exchange, account, symbol, interval).
799/// Returns oldest→newest. Empty on any error.
800pub async fn mark_price_klines_recent(
801    hub: &Arc<ExchangeHub>,
802    exchange: ExchangeId,
803    account: AccountType,
804    symbol: &str,
805    interval: &str,
806    limit: usize,
807) -> Vec<MarkPriceKlinePoint> {
808    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
809    let limit = limit.min(1000).max(1) as u32;
810    let res = rest
811        .get_mark_price_klines(SymbolInput::Raw(symbol), interval, Some(limit), account, None)
812        .await;
813    match res {
814        Ok(bars) => bars.iter().map(|k| MarkPriceKlinePoint {
815            open_time: k.open_time,
816            open: k.open,
817            high: k.high,
818            low: k.low,
819            close: k.close,
820            volume: k.volume,
821            quote_volume: k.quote_volume.unwrap_or(f64::NAN),
822            trades_count: k.trades.unwrap_or(0),
823        }).collect(),
824        Err(e) => {
825            tracing::debug!(?e, exchange = ?exchange, interval, "rest backfill mark_price_klines failed");
826            Vec::new()
827        }
828    }
829}
830
831/// Pull up to `limit` index-price klines from REST for (exchange, account, symbol, interval).
832/// Returns oldest→newest. Empty on any error.
833pub async fn index_price_klines_recent(
834    hub: &Arc<ExchangeHub>,
835    exchange: ExchangeId,
836    account: AccountType,
837    symbol: &str,
838    interval: &str,
839    limit: usize,
840) -> Vec<IndexPriceKlinePoint> {
841    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
842    let limit = limit.min(1000).max(1) as u32;
843    let res = rest
844        .get_index_price_klines(SymbolInput::Raw(symbol), interval, Some(limit), account, None)
845        .await;
846    match res {
847        Ok(bars) => bars.iter().map(|k| IndexPriceKlinePoint {
848            open_time: k.open_time,
849            open: k.open,
850            high: k.high,
851            low: k.low,
852            close: k.close,
853            volume: k.volume,
854            quote_volume: k.quote_volume.unwrap_or(f64::NAN),
855            trades_count: k.trades.unwrap_or(0),
856        }).collect(),
857        Err(e) => {
858            tracing::debug!(?e, exchange = ?exchange, interval, "rest backfill index_price_klines failed");
859            Vec::new()
860        }
861    }
862}
863
864/// Pull up to `limit` premium-index klines from REST for (exchange, account, symbol, interval).
865/// Returns oldest→newest. Empty on any error.
866pub async fn premium_index_klines_recent(
867    hub: &Arc<ExchangeHub>,
868    exchange: ExchangeId,
869    account: AccountType,
870    symbol: &str,
871    interval: &str,
872    limit: usize,
873) -> Vec<PremiumIndexKlinePoint> {
874    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
875    let limit = limit.min(1000).max(1) as u32;
876    let res = rest
877        .get_premium_index_klines(SymbolInput::Raw(symbol), interval, Some(limit), account, None)
878        .await;
879    match res {
880        Ok(bars) => bars.iter().map(|k| PremiumIndexKlinePoint {
881            open_time: k.open_time,
882            open: k.open,
883            high: k.high,
884            low: k.low,
885            close: k.close,
886            volume: k.volume,
887            quote_volume: k.quote_volume.unwrap_or(f64::NAN),
888            trades_count: k.trades.unwrap_or(0),
889        }).collect(),
890        Err(e) => {
891            tracing::debug!(?e, exchange = ?exchange, interval, "rest backfill premium_index_klines failed");
892            Vec::new()
893        }
894    }
895}
896
897// ─── Indicators / Full warm-seed helpers ──────────────────────────────────────
898//
899// Each helper mirrors its Compact sibling but maps to the enriched DataPoint
900// type. Called by `station.rs` when `PersistDepth` is `Indicators` or `Full`.
901
902/// Fetch the current ticker snapshot (Indicators depth) from REST.
903/// Returns 0 or 1 element. Empty on any error or unsupported.
904pub async fn tickers_indicators_recent(
905    hub: &Arc<ExchangeHub>,
906    exchange: ExchangeId,
907    account: AccountType,
908    symbol: &str,
909    _limit: usize,
910) -> Vec<TickerIndicatorsPoint> {
911    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
912    let sym = SymbolInput::Raw(symbol);
913    match rest.get_ticker(sym, account).await {
914        Ok(t) => vec![TickerIndicatorsPoint::from_ticker(&t)],
915        Err(e) => {
916            tracing::debug!(?e, exchange = ?exchange, "rest backfill ticker_indicators failed");
917            Vec::new()
918        }
919    }
920}
921
922/// Fetch the current ticker snapshot (Full depth) from REST.
923/// Returns 0 or 1 element. Empty on any error or unsupported.
924pub async fn tickers_full_recent(
925    hub: &Arc<ExchangeHub>,
926    exchange: ExchangeId,
927    account: AccountType,
928    symbol: &str,
929    _limit: usize,
930) -> Vec<TickerFullPoint> {
931    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
932    let sym = SymbolInput::Raw(symbol);
933    match rest.get_ticker(sym, account).await {
934        Ok(t) => vec![TickerFullPoint::from_ticker(&t)],
935        Err(e) => {
936            tracing::debug!(?e, exchange = ?exchange, "rest backfill ticker_full failed");
937            Vec::new()
938        }
939    }
940}
941
942/// Fetch the current mark-price snapshot (Indicators depth) from REST.
943/// Returns 0 or 1+ elements. Empty on any error.
944pub async fn mark_price_indicators_recent(
945    hub: &Arc<ExchangeHub>,
946    exchange: ExchangeId,
947    account: AccountType,
948    symbol: &str,
949    _limit: usize,
950) -> Vec<MarkPriceIndicatorsPoint> {
951    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
952    match rest.get_premium_index(Some(SymbolInput::Raw(symbol)), account).await {
953        Ok(items) => items.iter().map(MarkPriceIndicatorsPoint::from_mark_price).collect(),
954        Err(e) => {
955            tracing::debug!(?e, exchange = ?exchange, "rest backfill mark_price_indicators failed");
956            Vec::new()
957        }
958    }
959}
960
961/// Fetch the current mark-price snapshot (Full depth) from REST.
962/// Returns 0 or 1+ elements. Empty on any error.
963pub async fn mark_price_full_recent(
964    hub: &Arc<ExchangeHub>,
965    exchange: ExchangeId,
966    account: AccountType,
967    symbol: &str,
968    _limit: usize,
969) -> Vec<MarkPriceFullPoint> {
970    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
971    match rest.get_premium_index(Some(SymbolInput::Raw(symbol)), account).await {
972        Ok(items) => items.iter().map(MarkPriceFullPoint::from_mark_price).collect(),
973        Err(e) => {
974            tracing::debug!(?e, exchange = ?exchange, "rest backfill mark_price_full failed");
975            Vec::new()
976        }
977    }
978}
979
980/// Pull up to `limit` historical funding rates (Indicators depth) from REST.
981/// Returns oldest→newest. Empty on any error.
982pub async fn funding_rate_indicators_recent(
983    hub: &Arc<ExchangeHub>,
984    exchange: ExchangeId,
985    account: AccountType,
986    symbol: &str,
987    limit: usize,
988) -> Vec<FundingRateIndicatorsPoint> {
989    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
990    let limit = limit.min(1000).max(1) as u32;
991    match rest.get_funding_rate_history(SymbolInput::Raw(symbol), None, None, Some(limit), account).await {
992        Ok(items) => items.iter().map(FundingRateIndicatorsPoint::from_funding_rate).collect(),
993        Err(e) => {
994            tracing::debug!(?e, exchange = ?exchange, "rest backfill funding_rate_indicators failed");
995            Vec::new()
996        }
997    }
998}
999
1000/// Pull up to `limit` historical funding rates (Full depth) from REST.
1001/// Returns oldest→newest. Empty on any error.
1002pub async fn funding_rate_full_recent(
1003    hub: &Arc<ExchangeHub>,
1004    exchange: ExchangeId,
1005    account: AccountType,
1006    symbol: &str,
1007    limit: usize,
1008) -> Vec<FundingRateFullPoint> {
1009    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
1010    let limit = limit.min(1000).max(1) as u32;
1011    match rest.get_funding_rate_history(SymbolInput::Raw(symbol), None, None, Some(limit), account).await {
1012        Ok(items) => items.iter().map(FundingRateFullPoint::from_funding_rate).collect(),
1013        Err(e) => {
1014            tracing::debug!(?e, exchange = ?exchange, "rest backfill funding_rate_full failed");
1015            Vec::new()
1016        }
1017    }
1018}
1019
1020/// Pull up to `limit` open-interest snapshots (Indicators depth) from REST.
1021/// Returns oldest→newest. Empty on any error.
1022pub async fn open_interest_indicators_recent(
1023    hub: &Arc<ExchangeHub>,
1024    exchange: ExchangeId,
1025    account: AccountType,
1026    symbol: &str,
1027    limit: usize,
1028) -> Vec<OpenInterestIndicatorsPoint> {
1029    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
1030    let limit = limit.min(1000).max(1) as u32;
1031    match rest.get_open_interest_history(
1032        SymbolInput::Raw(symbol),
1033        "5m",
1034        None,
1035        None,
1036        Some(limit),
1037        account,
1038    ).await {
1039        Ok(items) => items.iter().map(OpenInterestIndicatorsPoint::from_open_interest).collect(),
1040        Err(e) => {
1041            tracing::debug!(?e, exchange = ?exchange, "rest backfill open_interest_indicators failed");
1042            Vec::new()
1043        }
1044    }
1045}
1046
1047/// Pull up to `limit` historical liquidation events (Indicators depth) from REST.
1048/// Returns oldest→newest. Empty on any error.
1049pub async fn liquidation_indicators_recent(
1050    hub: &Arc<ExchangeHub>,
1051    exchange: ExchangeId,
1052    account: AccountType,
1053    symbol: &str,
1054    limit: usize,
1055) -> Vec<LiquidationIndicatorsPoint> {
1056    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
1057    let limit = limit.min(1000).max(1) as u32;
1058    match rest.get_liquidation_history(
1059        Some(SymbolInput::Raw(symbol)),
1060        None,
1061        None,
1062        Some(limit),
1063        account,
1064    ).await {
1065        Ok(items) => items.iter().map(LiquidationIndicatorsPoint::from_liquidation).collect(),
1066        Err(e) => {
1067            tracing::debug!(?e, exchange = ?exchange, "rest backfill liquidation_indicators failed");
1068            Vec::new()
1069        }
1070    }
1071}
1072
1073/// Pull up to `limit` historical liquidation events (Full depth) from REST.
1074/// Returns oldest→newest. Empty on any error.
1075pub async fn liquidation_full_recent(
1076    hub: &Arc<ExchangeHub>,
1077    exchange: ExchangeId,
1078    account: AccountType,
1079    symbol: &str,
1080    limit: usize,
1081) -> Vec<LiquidationFullPoint> {
1082    let Some(rest) = hub.rest(exchange) else { return Vec::new(); };
1083    let limit = limit.min(1000).max(1) as u32;
1084    match rest.get_liquidation_history(
1085        Some(SymbolInput::Raw(symbol)),
1086        None,
1087        None,
1088        Some(limit),
1089        account,
1090    ).await {
1091        Ok(items) => items.iter().map(LiquidationFullPoint::from_liquidation).collect(),
1092        Err(e) => {
1093            tracing::debug!(?e, exchange = ?exchange, "rest backfill liquidation_full failed");
1094            Vec::new()
1095        }
1096    }
1097}
1098
1099#[cfg(test)]
1100mod seed_outcome_tests {
1101    //! Unit tests for the pure `SeedOutcome`/capability-gating logic in this
1102    //! module. Live REST-dependent behavior (the actual paginated wall-clamp
1103    //! against a real venue) is covered by the `--ignored` e2e examples —
1104    //! these tests exercise the deterministic, no-network parts: outcome
1105    //! construction, `is_satisfied`, `recent_only_cap`, and the
1106    //! not-connected-hub error path every `backfill::*` fn shares.
1107
1108    use super::*;
1109    use digdigdig3::core::types::HistoryCursor;
1110    use std::sync::Arc;
1111
1112    // ── SeedOutcome: requested vs achieved reporting ──────────────────────
1113
1114    #[test]
1115    fn seed_outcome_full_reports_requested_and_achieved() {
1116        let o = SeedOutcome::full(1000, 1000, SeedSource::AggTradesPaginated);
1117        assert_eq!(o.requested, 1000);
1118        assert_eq!(o.achieved, 1000);
1119        assert_eq!(o.source, SeedSource::AggTradesPaginated);
1120        assert!(o.truncated_by.is_none());
1121        assert!(o.is_satisfied());
1122    }
1123
1124    #[test]
1125    fn seed_outcome_truncated_reports_short_achieved_and_reason() {
1126        let o = SeedOutcome::truncated(50_000, 60, SeedSource::RecentTradesFallback, TruncationReason::VenueRecentOnly);
1127        assert_eq!(o.requested, 50_000);
1128        assert_eq!(o.achieved, 60);
1129        assert_eq!(o.truncated_by, Some(TruncationReason::VenueRecentOnly));
1130        // Achieved << requested AND a truncation reason is set → not satisfied.
1131        assert!(!o.is_satisfied());
1132    }
1133
1134    #[test]
1135    fn seed_outcome_truncated_but_fully_achieved_is_satisfied() {
1136        // A RestDeep walk that hit `VenueHistoryExhausted` exactly at
1137        // `requested` (e.g. n_pages consumed exactly) still counts as
1138        // satisfied — the reason names WHY the walk stopped, not that it
1139        // under-delivered.
1140        let o = SeedOutcome::truncated(1000, 1000, SeedSource::AggTradesPaginated, TruncationReason::VenueHistoryExhausted);
1141        assert!(o.is_satisfied());
1142    }
1143
1144    // ── recent_only_cap: per-tier clamp lookup ────────────────────────────
1145
1146    #[test]
1147    fn recent_only_cap_returns_max_trades_for_recent_only_tier() {
1148        let tier = TradeHistoryTier::RecentOnly { max_trades: 60 };
1149        assert_eq!(recent_only_cap(tier), Some(60));
1150    }
1151
1152    #[test]
1153    fn recent_only_cap_is_none_for_paginating_tiers() {
1154        assert_eq!(recent_only_cap(TradeHistoryTier::RestDeep { cursor: HistoryCursor::FromId }), None);
1155        assert_eq!(
1156            recent_only_cap(TradeHistoryTier::RestWindow { cursor: HistoryCursor::FromId, max_back_ms: 86_400_000 }),
1157            None,
1158        );
1159        assert_eq!(
1160            recent_only_cap(TradeHistoryTier::FileDump { url_pattern: "https://example.test/%s.csv.gz", since_ms: 0 }),
1161            None,
1162        );
1163    }
1164
1165    // ── tier_for: account-type dispatch (spot vs futures split) ──────────
1166
1167    #[test]
1168    fn tier_for_dispatches_futures_account_types_to_futures_tier() {
1169        use digdigdig3::core::types::{AccountType, TradeHistoryCapabilities};
1170        let caps = TradeHistoryCapabilities {
1171            spot: TradeHistoryTier::RestDeep { cursor: HistoryCursor::FromId },
1172            futures: TradeHistoryTier::RestWindow { cursor: HistoryCursor::FromId, max_back_ms: 86_400_000 },
1173            kline_backpage: true,
1174        };
1175        assert_eq!(caps.tier_for(AccountType::FuturesCross), caps.futures);
1176        assert_eq!(caps.tier_for(AccountType::FuturesIsolated), caps.futures);
1177        assert_eq!(caps.tier_for(AccountType::Spot), caps.spot);
1178        // Non-spot/futures account types (Margin/Earn/Lending/Options/Convert)
1179        // fall back to the spot tier per the documented contract.
1180        assert_eq!(caps.tier_for(AccountType::Margin), caps.spot);
1181    }
1182
1183    // ── Not-connected-hub path: every backfill fn reports Error honestly ──
1184
1185    #[tokio::test]
1186    async fn trades_recent_on_unconnected_hub_reports_error_truncation() {
1187        let hub = Arc::new(ExchangeHub::new());
1188        let (points, outcome) = trades_recent(&hub, ExchangeId::Binance, AccountType::Spot, "BTCUSDT", 500).await;
1189        assert!(points.is_empty());
1190        assert_eq!(outcome.requested, 500);
1191        assert_eq!(outcome.achieved, 0);
1192        assert_eq!(outcome.truncated_by, Some(TruncationReason::Error));
1193    }
1194
1195    #[tokio::test]
1196    async fn agg_trades_paginated_on_unconnected_hub_reports_error_truncation() {
1197        let hub = Arc::new(ExchangeHub::new());
1198        let (points, outcome) = agg_trades_paginated(&hub, ExchangeId::Binance, AccountType::FuturesCross, "BTCUSDT", 1000, 5).await;
1199        assert!(points.is_empty());
1200        assert_eq!(outcome.requested, 5000);
1201        assert_eq!(outcome.achieved, 0);
1202        assert_eq!(outcome.truncated_by, Some(TruncationReason::Error));
1203    }
1204
1205    #[tokio::test]
1206    async fn klines_paginated_on_unconnected_hub_reports_error_truncation() {
1207        let hub = Arc::new(ExchangeHub::new());
1208        let (points, outcome) = klines_paginated(&hub, ExchangeId::Binance, AccountType::FuturesCross, "BTCUSDT", "1m", 1000, 3).await;
1209        assert!(points.is_empty());
1210        assert_eq!(outcome.requested, 3000);
1211        assert_eq!(outcome.achieved, 0);
1212        assert_eq!(outcome.truncated_by, Some(TruncationReason::Error));
1213    }
1214
1215    #[tokio::test]
1216    async fn klines_paginated_zero_pages_is_full_empty_not_error() {
1217        // n_pages=0 short-circuits BEFORE the hub lookup — this is a
1218        // legitimate "nothing requested" outcome, not a failure.
1219        let hub = Arc::new(ExchangeHub::new());
1220        let (points, outcome) = klines_paginated(&hub, ExchangeId::Binance, AccountType::Spot, "BTCUSDT", "1m", 1000, 0).await;
1221        assert!(points.is_empty());
1222        assert_eq!(outcome.requested, 0);
1223        assert!(outcome.truncated_by.is_none());
1224    }
1225}