use std::sync::Arc;
use std::time::Duration;
use digdigdig3::connector_manager::ExchangeHub;
use digdigdig3::core::types::{AccountType, ExchangeId};
use crate::series::DataPoint;
use crate::Result;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::atomic::Ordering;
#[cfg(not(target_arch = "wasm32"))]
use tokio::sync::{broadcast, oneshot};
#[cfg(not(target_arch = "wasm32"))]
use crate::data::{HistoricalVolatilityPoint, LongShortRatioPoint};
#[cfg(not(target_arch = "wasm32"))]
use crate::series::{DiskStore, PollSpec, SeriesKey};
#[cfg(not(target_arch = "wasm32"))]
use crate::subscription::Event;
#[cfg(not(target_arch = "wasm32"))]
use crate::StationError;
#[cfg(not(target_arch = "wasm32"))]
use crate::station::{Station, EventFrom};
pub trait PollSource<T: DataPoint>: Send + Sync + 'static {
fn poll(
&self,
hub: Arc<ExchangeHub>,
exchange: ExchangeId,
account_type: AccountType,
symbol: String,
) -> impl std::future::Future<Output = Result<Vec<T>>> + Send;
fn cadence(&self) -> Duration;
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn spawn_poller<T, S>(
station: &Station,
key: &SeriesKey,
source: S,
poll_spec: PollSpec,
bcast_tx: broadcast::Sender<Event>,
shutdown_rx: oneshot::Receiver<()>,
symbol_label: String,
) where
T: DataPoint + 'static,
S: PollSource<T>,
Event: EventFrom<T>,
{
let inner = station.inner.clone();
let key = key.clone();
let storage_root = inner.storage_root.clone();
let persistence = inner.persistence.clone();
let exchange = key.exchange;
let hub = inner.hub.clone();
let account_type = key.account_type;
let raw_symbol = key.symbol.clone();
tokio::spawn(async move {
let mut disk: Option<DiskStore<T>> = None;
if persistence.is_enabled_for(&key.kind) {
match DiskStore::<T>::new(&storage_root, key.clone()).await {
Ok(store) => disk = Some(store),
Err(e) => tracing::warn!(?e, ?key, "poll: disk store open failed"),
}
}
let mut last_emitted_ms: i64 = 0;
if let Some(d) = disk.as_ref() {
if let Ok(tail) = d.read_tail(500).await {
for p in &tail {
let _ = bcast_tx
.send(Event::from_point(exchange, key.account_type, &symbol_label, &key.kind, p.clone()));
last_emitted_ms = last_emitted_ms.max(p.timestamp_ms());
}
}
}
{
let jitter_max_ms = (poll_spec.cadence.as_millis() as u64)
.saturating_mul(poll_spec.jitter_pct as u64)
/ 100;
if jitter_max_ms > 0 {
let seed = key
.symbol
.as_bytes()
.iter()
.fold(0u64, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as u64));
let sleep_ms = seed % jitter_max_ms.max(1);
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
}
}
let mut interval = tokio::time::interval(source.cadence());
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut consecutive_errors: u32 = 0;
const DEGRADE_THRESHOLD: u32 = 10;
let mut shutdown_rx = shutdown_rx;
loop {
tokio::select! {
biased;
_ = &mut shutdown_rx => break,
_ = interval.tick() => {}
}
let pts = match source.poll(hub.clone(), exchange, account_type, raw_symbol.clone()).await {
Ok(v) => {
consecutive_errors = 0;
v
}
Err(e) => {
consecutive_errors += 1;
if consecutive_errors == 1 || consecutive_errors == DEGRADE_THRESHOLD {
tracing::warn!(
target: "dig3::poll",
?key,
consecutive_errors,
error = %e,
"poller REST error{}",
if consecutive_errors >= DEGRADE_THRESHOLD { " — poller degraded" } else { "" }
);
}
continue;
}
};
for pt in pts {
if pt.timestamp_ms() <= last_emitted_ms {
continue; }
if let Some(d) = disk.as_mut() {
if let Err(e) = d.append(&pt) {
tracing::warn!(?e, "poll: disk append failed");
}
}
last_emitted_ms = pt.timestamp_ms();
let _ =
bcast_tx.send(Event::from_point(exchange, key.account_type, &symbol_label, &key.kind, pt));
}
}
if let Some(mut d) = disk {
let _ = d.flush().await;
}
let still_consumers = inner
.muxes
.get(&key)
.map(|m| m.consumers.load(Ordering::SeqCst))
.unwrap_or(0);
if still_consumers == 0 {
inner.muxes.remove(&key);
}
});
}
#[cfg(not(target_arch = "wasm32"))]
pub struct LongShortRatioPoll {
cadence: Duration,
}
#[cfg(not(target_arch = "wasm32"))]
impl LongShortRatioPoll {
pub fn new() -> Self {
Self {
cadence: Duration::from_secs(5 * 60),
}
}
fn period_for(exchange: ExchangeId) -> &'static str {
match exchange {
ExchangeId::Bybit => "5min",
_ => "5m", }
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Default for LongShortRatioPoll {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(target_arch = "wasm32"))]
impl PollSource<LongShortRatioPoint> for LongShortRatioPoll {
fn poll(
&self,
hub: Arc<ExchangeHub>,
exchange: ExchangeId,
account_type: AccountType,
symbol: String,
) -> impl std::future::Future<Output = Result<Vec<LongShortRatioPoint>>> + Send {
let period = Self::period_for(exchange);
async move {
let connector = hub
.rest(exchange)
.ok_or_else(|| StationError::Core("REST connector missing for LSR poll".into()))?;
let raw = connector
.get_long_short_ratio_history(
symbol.as_str().into(),
period,
None,
None,
Some(500),
account_type,
)
.await
.map_err(|e| StationError::Core(format!("poll LSR: {e}")))?;
Ok(raw
.into_iter()
.map(|r| LongShortRatioPoint {
ts_ms: r.timestamp,
ratio: r.ratio.unwrap_or_else(|| {
if r.short_ratio > 0.0 {
r.long_ratio / r.short_ratio
} else {
1.0
}
}),
long_pct: r.long_ratio,
short_pct: r.short_ratio,
})
.collect())
}
}
fn cadence(&self) -> Duration {
self.cadence
}
}
#[cfg(not(target_arch = "wasm32"))]
pub struct DeribitHvPoll {
cadence: Duration,
}
#[cfg(not(target_arch = "wasm32"))]
impl DeribitHvPoll {
pub fn new() -> Self {
Self {
cadence: Duration::from_secs(60 * 60),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Default for DeribitHvPoll {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(target_arch = "wasm32"))]
impl PollSource<HistoricalVolatilityPoint> for DeribitHvPoll {
fn poll(
&self,
hub: Arc<ExchangeHub>,
_exchange: ExchangeId,
_account_type: AccountType,
symbol: String, ) -> impl std::future::Future<Output = Result<Vec<HistoricalVolatilityPoint>>> + Send {
async move {
let connector = hub
.rest(ExchangeId::Deribit)
.ok_or_else(|| StationError::Core("Deribit REST connector missing for HV poll".into()))?;
let raw = connector
.get_historical_volatility(&symbol)
.await
.map_err(|e| StationError::Core(format!("poll HV: {e}")))?;
Ok(raw
.into_iter()
.map(|h| HistoricalVolatilityPoint {
ts_ms: h.timestamp,
volatility: h.volatility,
})
.collect())
}
}
fn cadence(&self) -> Duration {
self.cadence
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn lsr_poll_source(exchange: ExchangeId) -> Option<LongShortRatioPoll> {
match exchange {
ExchangeId::Binance | ExchangeId::Bybit | ExchangeId::OKX => {
Some(LongShortRatioPoll::new())
}
_ => None,
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn hv_poll_source(exchange: ExchangeId) -> Option<DeribitHvPoll> {
match exchange {
ExchangeId::Deribit => Some(DeribitHvPoll::new()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use crate::series::Kind;
#[test]
fn kind_lsr_poll_spec() {
let spec = Kind::LongShortRatio.is_poll_only().unwrap();
assert_eq!(spec.cadence, std::time::Duration::from_secs(300));
assert_eq!(spec.jitter_pct, 10);
}
#[test]
fn kind_hv_poll_spec() {
let spec = Kind::HistoricalVolatility.is_poll_only().unwrap();
assert_eq!(spec.cadence, std::time::Duration::from_secs(3600));
assert_eq!(spec.jitter_pct, 5);
}
#[cfg(not(target_arch = "wasm32"))]
mod native {
use super::super::*;
#[test]
fn lsr_poll_cadence() {
assert_eq!(LongShortRatioPoll::new().cadence(), Duration::from_secs(300));
}
#[test]
fn hv_poll_cadence() {
assert_eq!(DeribitHvPoll::new().cadence(), Duration::from_secs(3600));
}
#[test]
fn lsr_poll_source_allow_list() {
assert!(lsr_poll_source(ExchangeId::Binance).is_some());
assert!(lsr_poll_source(ExchangeId::Bybit).is_some());
assert!(lsr_poll_source(ExchangeId::OKX).is_some());
assert!(lsr_poll_source(ExchangeId::Deribit).is_none());
assert!(lsr_poll_source(ExchangeId::Kraken).is_none());
}
#[test]
fn hv_poll_source_allow_list() {
assert!(hv_poll_source(ExchangeId::Deribit).is_some());
assert!(hv_poll_source(ExchangeId::Binance).is_none());
assert!(hv_poll_source(ExchangeId::Bybit).is_none());
assert!(hv_poll_source(ExchangeId::OKX).is_none());
}
#[test]
fn lsr_period_for_exchange() {
assert_eq!(LongShortRatioPoll::period_for(ExchangeId::Bybit), "5min");
assert_eq!(LongShortRatioPoll::period_for(ExchangeId::Binance), "5m");
assert_eq!(LongShortRatioPoll::period_for(ExchangeId::OKX), "5m");
}
}
}