1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum SeedSource {
49 AggTradesPaginated,
51 RecentTradesFallback,
54 KlineSynthetic,
57 Klines,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum TruncationReason {
65 VenueRecentOnly,
68 VenueWindowCap,
71 VenueHistoryExhausted,
75 Error,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct SeedOutcome {
89 pub requested: usize,
91 pub achieved: usize,
93 pub source: SeedSource,
95 pub truncated_by: Option<TruncationReason>,
99}
100
101impl SeedOutcome {
102 pub const fn full(requested: usize, achieved: usize, source: SeedSource) -> Self {
104 Self { requested, achieved, source, truncated_by: None }
105 }
106
107 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 pub fn is_satisfied(&self) -> bool {
120 self.truncated_by.is_none() || self.achieved >= self.requested
121 }
122}
123
124pub 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 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
170fn 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
180pub 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
227pub 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
257pub 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 break;
340 }
341 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
359pub 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
390pub 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 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 _ => 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 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 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 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 hit_window_wall = true;
552 break;
553 }
554 }
555 if was_partial {
556 break;
558 }
559 if is_ts_window {
560 if min_id == 0 {
565 break;
567 }
568 next_from_id = Some(min_id - 1);
569 } else {
570 if min_id <= limit as u64 {
572 break;
574 }
575 next_from_id = Some(min_id.saturating_sub(limit as u64));
576 }
577 }
578
579 if agg_fallback {
580 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
627pub 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
665pub 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
695pub 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
726pub 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
769pub 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
798pub 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
831pub 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
864pub 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
897pub 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
922pub 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
942pub 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
961pub 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
980pub 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
1000pub 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
1020pub 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
1047pub 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
1073pub 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 use super::*;
1109 use digdigdig3::core::types::HistoryCursor;
1110 use std::sync::Arc;
1111
1112 #[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 assert!(!o.is_satisfied());
1132 }
1133
1134 #[test]
1135 fn seed_outcome_truncated_but_fully_achieved_is_satisfied() {
1136 let o = SeedOutcome::truncated(1000, 1000, SeedSource::AggTradesPaginated, TruncationReason::VenueHistoryExhausted);
1141 assert!(o.is_satisfied());
1142 }
1143
1144 #[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 #[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 assert_eq!(caps.tier_for(AccountType::Margin), caps.spot);
1181 }
1182
1183 #[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 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}