Skip to main content

digdigdig3_station/
polling.rs

1//! Polling subscription layer for `digdigdig3-station`.
2//!
3//! A *polling subscription* is a Station-internal actor that periodically calls
4//! a REST endpoint and emits events through the same broadcast pipeline as WS
5//! forwarders. Consumers see no difference — they call `handle.recv().await`
6//! and receive interleaved `Event` values regardless of the underlying source.
7//!
8//! Two concrete [`PollSource`] impls ship here:
9//!
10//! - [`LongShortRatioPoll`] — calls `get_long_short_ratio_history` on Binance /
11//!   Bybit / OKX every 5 minutes, normalising the `period` format divergence
12//!   (`"5m"` vs `"5min"`) internally.
13//! - [`DeribitHvPoll`] — calls `get_historical_volatility` on Deribit every
14//!   hour.
15//!
16//! The public entry point for the station dispatch loop is [`is_poll_only`] +
17//! [`spawn_poller`]; they are `pub(crate)` and called from `station.rs`.
18//!
19//! ## Wasm note
20//!
21//! `PollSource<T>`, `LongShortRatioPoll`, and `DeribitHvPoll` are available on
22//! wasm32. However, the concrete `impl PollSource` blocks and `spawn_poller`
23//! are native-only because they rely on `tokio::time::interval` (full timer
24//! feature) which is not available on wasm32. On wasm, Station returns
25//! `StationError::StreamNotSupported` for poll-only kinds before reaching
26//! `spawn_poller`.
27
28use std::sync::Arc;
29use std::time::Duration;
30
31use digdigdig3::connector_manager::ExchangeHub;
32use digdigdig3::core::types::{AccountType, ExchangeId};
33
34use crate::series::DataPoint;
35use crate::Result;
36
37// Items only needed on native (impl PollSource + spawn_poller).
38#[cfg(not(target_arch = "wasm32"))]
39use std::sync::atomic::Ordering;
40#[cfg(not(target_arch = "wasm32"))]
41use tokio::sync::{broadcast, oneshot};
42#[cfg(not(target_arch = "wasm32"))]
43use crate::data::{HistoricalVolatilityPoint, LongShortRatioPoint};
44#[cfg(not(target_arch = "wasm32"))]
45use crate::series::{DiskStore, PollSpec, SeriesKey};
46#[cfg(not(target_arch = "wasm32"))]
47use crate::subscription::Event;
48#[cfg(not(target_arch = "wasm32"))]
49use crate::StationError;
50#[cfg(not(target_arch = "wasm32"))]
51use crate::station::{Station, EventFrom};
52
53// ─────────────────────────────────────────────────────────────────────────────
54// PollSource trait
55// ─────────────────────────────────────────────────────────────────────────────
56
57/// REST poll contract for one `(kind, exchange, symbol)` combination.
58///
59/// `poll` is called on every interval tick and returns all records the exchange
60/// has available (not just the newest one), allowing the caller to dedup by
61/// `timestamp_ms` and emit only genuinely new points.
62///
63/// The trait uses stable AFIT (available since Rust 1.75). No `async_trait`
64/// macro is needed.
65///
66/// # Wasm note
67///
68/// The native `spawn_poller` requires the returned future to be `Send`
69/// (for `tokio::spawn`). Concrete implementations must therefore return `Send`
70/// futures on native. On wasm `spawn_poller` is not compiled, so no `Send`
71/// requirement is imposed — the REST future may be `!Send`.
72pub trait PollSource<T: DataPoint>: Send + Sync + 'static {
73    /// Fetch recent data points from the exchange.
74    ///
75    /// Implementations should request the last ~500 buckets with no
76    /// `start_time` filter. This gives the poller a built-in warm-start on
77    /// the first tick without a separate backfill path.
78    ///
79    /// Return `Err(String)` on any REST failure. The caller logs + retries on
80    /// the next tick without exiting the actor.
81    fn poll(
82        &self,
83        hub: Arc<ExchangeHub>,
84        exchange: ExchangeId,
85        account_type: AccountType,
86        symbol: String,
87    ) -> impl std::future::Future<Output = Result<Vec<T>>> + Send;
88
89    /// Polling cadence — taken from [`PollSpec`] at construction time.
90    fn cadence(&self) -> Duration;
91}
92
93// ─────────────────────────────────────────────────────────────────────────────
94// spawn_poller actor (native-only — tokio::time not available on wasm32)
95// ─────────────────────────────────────────────────────────────────────────────
96
97/// Spawn a poll actor for `key`. Mirrors `spawn_forwarder` in structure but is
98/// driven by a `tokio::time::interval` instead of a WS event stream.
99///
100/// On each tick:
101/// 1. Calls `source.poll(...)`.
102/// 2. For each returned point with `timestamp_ms > last_emitted_ms`: appends to
103///    disk, emits on `bcast_tx`.
104/// 3. On consecutive REST errors ≥ 10: logs "poller degraded", keeps retrying.
105/// 4. On shutdown signal: flushes disk, removes mux entry if no consumers remain.
106///
107/// Native-only: uses `tokio::time::interval` + `tokio::spawn`. On wasm,
108/// `Station::acquire_or_spawn_polled` is `#[cfg(not(target_arch = "wasm32"))]`
109/// so this function is never reachable from the wasm build.
110#[cfg(not(target_arch = "wasm32"))]
111pub(crate) fn spawn_poller<T, S>(
112    station: &Station,
113    key: &SeriesKey,
114    source: S,
115    poll_spec: PollSpec,
116    bcast_tx: broadcast::Sender<Event>,
117    shutdown_rx: oneshot::Receiver<()>,
118    symbol_label: String,
119) where
120    T: DataPoint + 'static,
121    S: PollSource<T>,
122    Event: EventFrom<T>,
123{
124    let inner = station.inner.clone();
125    let key = key.clone();
126    let storage_root = inner.storage_root.clone();
127    let persistence = inner.persistence.clone();
128    let exchange = key.exchange;
129    let hub = inner.hub.clone();
130    let account_type = key.account_type;
131    let raw_symbol = key.symbol.clone();
132
133    tokio::spawn(async move {
134        // Open disk store if persistence is enabled for this kind.
135        let mut disk: Option<DiskStore<T>> = None;
136        if persistence.is_enabled_for(&key.kind) {
137            match DiskStore::<T>::new(&storage_root, key.clone()).await {
138                Ok(store) => disk = Some(store),
139                Err(e) => tracing::warn!(?e, ?key, "poll: disk store open failed"),
140            }
141        }
142
143        // last_emitted_ms: dedup fence. Points at or below this ts are skipped.
144        let mut last_emitted_ms: i64 = 0;
145
146        // Warm-start: emit disk tail before the first live poll tick.
147        if let Some(d) = disk.as_ref() {
148            if let Ok(tail) = d.read_tail(500).await {
149                for p in &tail {
150                    let _ = bcast_tx
151                        .send(Event::from_point(exchange, key.account_type, &symbol_label, &key.kind, p.clone()));
152                    last_emitted_ms = last_emitted_ms.max(p.timestamp_ms());
153                }
154            }
155        }
156
157        // First-tick jitter: sleep a deterministic pseudo-random offset so that
158        // N symbols × M exchanges don't all fire at the same wall-clock second.
159        // Uses no `rand` crate — symbol bytes are a sufficient seed.
160        {
161            let jitter_max_ms = (poll_spec.cadence.as_millis() as u64)
162                .saturating_mul(poll_spec.jitter_pct as u64)
163                / 100;
164            if jitter_max_ms > 0 {
165                let seed = key
166                    .symbol
167                    .as_bytes()
168                    .iter()
169                    .fold(0u64, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as u64));
170                // Map seed into [0, jitter_max_ms].
171                let sleep_ms = seed % jitter_max_ms.max(1);
172                tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
173            }
174        }
175
176        let mut interval = tokio::time::interval(source.cadence());
177        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
178
179        let mut consecutive_errors: u32 = 0;
180        const DEGRADE_THRESHOLD: u32 = 10;
181
182        let mut shutdown_rx = shutdown_rx;
183
184        loop {
185            tokio::select! {
186                biased;
187                _ = &mut shutdown_rx => break,
188                _ = interval.tick() => {}
189            }
190
191            let pts = match source.poll(hub.clone(), exchange, account_type, raw_symbol.clone()).await {
192                Ok(v) => {
193                    consecutive_errors = 0;
194                    v
195                }
196                Err(e) => {
197                    consecutive_errors += 1;
198                    if consecutive_errors == 1 || consecutive_errors == DEGRADE_THRESHOLD {
199                        tracing::warn!(
200                            target: "dig3::poll",
201                            ?key,
202                            consecutive_errors,
203                            error = %e,
204                            "poller REST error{}",
205                            if consecutive_errors >= DEGRADE_THRESHOLD { " — poller degraded" } else { "" }
206                        );
207                    }
208                    // Keep retrying — never exit the actor on REST error.
209                    continue;
210                }
211            };
212
213            // Dedup + emit. Only points strictly newer than last_emitted_ms.
214            for pt in pts {
215                if pt.timestamp_ms() <= last_emitted_ms {
216                    continue; // already delivered
217                }
218                if let Some(d) = disk.as_mut() {
219                    if let Err(e) = d.append(&pt) {
220                        tracing::warn!(?e, "poll: disk append failed");
221                    }
222                }
223                last_emitted_ms = pt.timestamp_ms();
224                let _ =
225                    bcast_tx.send(Event::from_point(exchange, key.account_type, &symbol_label, &key.kind, pt));
226            }
227        }
228
229        // Flush disk on graceful shutdown.
230        if let Some(mut d) = disk {
231            let _ = d.flush().await;
232        }
233
234        // Mux cleanup — same pattern as spawn_forwarder.
235        let still_consumers = inner
236            .muxes
237            .get(&key)
238            .map(|m| m.consumers.load(Ordering::SeqCst))
239            .unwrap_or(0);
240        if still_consumers == 0 {
241            inner.muxes.remove(&key);
242        }
243    });
244}
245
246// ─────────────────────────────────────────────────────────────────────────────
247// LongShortRatioPoll (native-only)
248// ─────────────────────────────────────────────────────────────────────────────
249//
250// Concrete `impl PollSource` requires `Send` futures from `hub.rest(...)`.
251// On wasm, REST futures are `!Send` (browser fetch). Since `spawn_poller` is
252// native-only, the struct + its impl are native-only too. Custom wasm poll
253// sources can still implement the `PollSource` trait directly.
254
255/// REST poll source for `Kind::LongShortRatio`.
256///
257/// Calls `get_long_short_ratio_history` on Binance / Bybit / OKX.
258/// Normalises the `period` format divergence internally:
259/// - Binance → `"5m"`
260/// - Bybit   → `"5min"`
261/// - OKX     → `"5m"`
262#[cfg(not(target_arch = "wasm32"))]
263pub struct LongShortRatioPoll {
264    cadence: Duration,
265}
266
267#[cfg(not(target_arch = "wasm32"))]
268impl LongShortRatioPoll {
269    pub fn new() -> Self {
270        Self {
271            cadence: Duration::from_secs(5 * 60),
272        }
273    }
274
275    /// Exchange-native period string for the 5-minute bucket.
276    fn period_for(exchange: ExchangeId) -> &'static str {
277        match exchange {
278            ExchangeId::Bybit => "5min",
279            _ => "5m", // Binance, OKX, and all others
280        }
281    }
282}
283
284#[cfg(not(target_arch = "wasm32"))]
285impl Default for LongShortRatioPoll {
286    fn default() -> Self {
287        Self::new()
288    }
289}
290
291#[cfg(not(target_arch = "wasm32"))]
292impl PollSource<LongShortRatioPoint> for LongShortRatioPoll {
293    fn poll(
294        &self,
295        hub: Arc<ExchangeHub>,
296        exchange: ExchangeId,
297        account_type: AccountType,
298        symbol: String,
299    ) -> impl std::future::Future<Output = Result<Vec<LongShortRatioPoint>>> + Send {
300        let period = Self::period_for(exchange);
301        async move {
302            let connector = hub
303                .rest(exchange)
304                .ok_or_else(|| StationError::Core("REST connector missing for LSR poll".into()))?;
305            let raw = connector
306                .get_long_short_ratio_history(
307                    symbol.as_str().into(),
308                    period,
309                    None,
310                    None,
311                    Some(500),
312                    account_type,
313                )
314                .await
315                .map_err(|e| StationError::Core(format!("poll LSR: {e}")))?;
316            Ok(raw
317                .into_iter()
318                .map(|r| LongShortRatioPoint {
319                    ts_ms: r.timestamp,
320                    ratio: r.ratio.unwrap_or_else(|| {
321                        if r.short_ratio > 0.0 {
322                            r.long_ratio / r.short_ratio
323                        } else {
324                            1.0
325                        }
326                    }),
327                    long_pct: r.long_ratio,
328                    short_pct: r.short_ratio,
329                })
330                .collect())
331        }
332    }
333
334    fn cadence(&self) -> Duration {
335        self.cadence
336    }
337}
338
339// ─────────────────────────────────────────────────────────────────────────────
340// DeribitHvPoll (native-only — same rationale as LongShortRatioPoll)
341// ─────────────────────────────────────────────────────────────────────────────
342
343/// REST poll source for `Kind::HistoricalVolatility` on Deribit.
344///
345/// The `symbol` field of the `SeriesKey` is used as the `currency` parameter
346/// (e.g. `"BTC"`, `"ETH"`). Use `SubscriptionSet::add_raw` with currency
347/// strings directly.
348#[cfg(not(target_arch = "wasm32"))]
349pub struct DeribitHvPoll {
350    cadence: Duration,
351}
352
353#[cfg(not(target_arch = "wasm32"))]
354impl DeribitHvPoll {
355    pub fn new() -> Self {
356        Self {
357            cadence: Duration::from_secs(60 * 60),
358        }
359    }
360}
361
362#[cfg(not(target_arch = "wasm32"))]
363impl Default for DeribitHvPoll {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369#[cfg(not(target_arch = "wasm32"))]
370impl PollSource<HistoricalVolatilityPoint> for DeribitHvPoll {
371    fn poll(
372        &self,
373        hub: Arc<ExchangeHub>,
374        _exchange: ExchangeId,
375        _account_type: AccountType,
376        symbol: String, // used as `currency`
377    ) -> impl std::future::Future<Output = Result<Vec<HistoricalVolatilityPoint>>> + Send {
378        async move {
379            let connector = hub
380                .rest(ExchangeId::Deribit)
381                .ok_or_else(|| StationError::Core("Deribit REST connector missing for HV poll".into()))?;
382            let raw = connector
383                .get_historical_volatility(&symbol)
384                .await
385                .map_err(|e| StationError::Core(format!("poll HV: {e}")))?;
386            Ok(raw
387                .into_iter()
388                .map(|h| HistoricalVolatilityPoint {
389                    ts_ms: h.timestamp,
390                    volatility: h.volatility,
391                })
392                .collect())
393        }
394    }
395
396    fn cadence(&self) -> Duration {
397        self.cadence
398    }
399}
400
401// ─────────────────────────────────────────────────────────────────────────────
402// Factory helpers (used in station.rs acquire_or_spawn_polled)
403// ─────────────────────────────────────────────────────────────────────────────
404
405/// Returns `Some(LongShortRatioPoll)` for exchanges that support LSR REST.
406/// Returns `None` for exchanges that don't, which causes `acquire_or_spawn`
407/// to return `StationError::StreamNotSupported`.
408///
409/// Native-only: called from `acquire_or_spawn_polled` which is itself native-only.
410#[cfg(not(target_arch = "wasm32"))]
411pub(crate) fn lsr_poll_source(exchange: ExchangeId) -> Option<LongShortRatioPoll> {
412    match exchange {
413        ExchangeId::Binance | ExchangeId::Bybit | ExchangeId::OKX => {
414            Some(LongShortRatioPoll::new())
415        }
416        _ => None,
417    }
418}
419
420/// Returns `Some(DeribitHvPoll)` for Deribit only.
421///
422/// Native-only: called from `acquire_or_spawn_polled` which is itself native-only.
423#[cfg(not(target_arch = "wasm32"))]
424pub(crate) fn hv_poll_source(exchange: ExchangeId) -> Option<DeribitHvPoll> {
425    match exchange {
426        ExchangeId::Deribit => Some(DeribitHvPoll::new()),
427        _ => None,
428    }
429}
430
431// ─────────────────────────────────────────────────────────────────────────────
432// Unit tests
433// ─────────────────────────────────────────────────────────────────────────────
434
435#[cfg(test)]
436mod tests {
437    use crate::series::Kind;
438
439    // Wasm-safe tests — only use Kind (no native-only polling types).
440    #[test]
441    fn kind_lsr_poll_spec() {
442        let spec = Kind::LongShortRatio.is_poll_only().unwrap();
443        assert_eq!(spec.cadence, std::time::Duration::from_secs(300));
444        assert_eq!(spec.jitter_pct, 10);
445    }
446
447    #[test]
448    fn kind_hv_poll_spec() {
449        let spec = Kind::HistoricalVolatility.is_poll_only().unwrap();
450        assert_eq!(spec.cadence, std::time::Duration::from_secs(3600));
451        assert_eq!(spec.jitter_pct, 5);
452    }
453
454    // Native-only tests — use concrete poll types and factory functions.
455    #[cfg(not(target_arch = "wasm32"))]
456    mod native {
457        use super::super::*;
458
459        #[test]
460        fn lsr_poll_cadence() {
461            assert_eq!(LongShortRatioPoll::new().cadence(), Duration::from_secs(300));
462        }
463
464        #[test]
465        fn hv_poll_cadence() {
466            assert_eq!(DeribitHvPoll::new().cadence(), Duration::from_secs(3600));
467        }
468
469        #[test]
470        fn lsr_poll_source_allow_list() {
471            assert!(lsr_poll_source(ExchangeId::Binance).is_some());
472            assert!(lsr_poll_source(ExchangeId::Bybit).is_some());
473            assert!(lsr_poll_source(ExchangeId::OKX).is_some());
474            assert!(lsr_poll_source(ExchangeId::Deribit).is_none());
475            assert!(lsr_poll_source(ExchangeId::Kraken).is_none());
476        }
477
478        #[test]
479        fn hv_poll_source_allow_list() {
480            assert!(hv_poll_source(ExchangeId::Deribit).is_some());
481            assert!(hv_poll_source(ExchangeId::Binance).is_none());
482            assert!(hv_poll_source(ExchangeId::Bybit).is_none());
483            assert!(hv_poll_source(ExchangeId::OKX).is_none());
484        }
485
486        #[test]
487        fn lsr_period_for_exchange() {
488            assert_eq!(LongShortRatioPoll::period_for(ExchangeId::Bybit), "5min");
489            assert_eq!(LongShortRatioPoll::period_for(ExchangeId::Binance), "5m");
490            assert_eq!(LongShortRatioPoll::period_for(ExchangeId::OKX), "5m");
491        }
492    }
493}