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}