use chrono::{DateTime, Utc};
use fnv::FnvHashMap;
use rustrade_instrument::exchange::ExchangeId;
use rustrade_integration::subscription::SubscriptionId;
use std::sync::{Mutex, PoisonError};
use tracing::warn;
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub(super) struct LseWatermark {
pub time_exchange: DateTime<Utc>,
pub count_at_time: usize,
}
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub(super) struct LseResumeKey {
exchange: ExchangeId,
subscription: SubscriptionId,
kind: &'static str,
}
impl LseResumeKey {
pub(super) fn new(
exchange: ExchangeId,
subscription: SubscriptionId,
kind: &'static str,
) -> Self {
Self {
exchange,
subscription,
kind,
}
}
}
#[derive(Debug, Default)]
pub struct LseResumeState {
marks: Mutex<FnvHashMap<LseResumeKey, MarkState>>,
}
#[derive(Copy, Clone, Debug)]
struct MarkState {
watermark: LseWatermark,
reported_out_of_order: bool,
}
impl LseResumeState {
pub fn new() -> Self {
Self::default()
}
pub(super) fn record(&self, key: &LseResumeKey, time_exchange: DateTime<Utc>) {
let out_of_order = {
let mut marks = self.lock();
match marks.get_mut(key) {
Some(state) if time_exchange > state.watermark.time_exchange => {
state.watermark = LseWatermark {
time_exchange,
count_at_time: 1,
};
None
}
Some(state) if time_exchange == state.watermark.time_exchange => {
state.watermark.count_at_time += 1;
None
}
Some(state) if !state.reported_out_of_order => {
state.reported_out_of_order = true;
Some(state.watermark.time_exchange)
}
Some(_) => None,
None => {
marks.insert(
key.clone(),
MarkState {
watermark: LseWatermark {
time_exchange,
count_at_time: 1,
},
reported_out_of_order: false,
},
);
None
}
}
};
if let Some(watermark) = out_of_order {
warn!(
exchange = %key.exchange,
subscription = %key.subscription,
kind = key.kind,
instant = %time_exchange,
watermark = %watermark,
"London Strategic Edge emitted an event older than this subscription's resume \
point, which a monotonic feed cannot do; the resume point is left where it is, so \
a reconnect will not re-request the span between the two",
);
}
}
pub(super) fn watermark(&self, key: &LseResumeKey) -> Option<LseWatermark> {
self.lock().get(key).map(|state| state.watermark)
}
fn lock(&self) -> std::sync::MutexGuard<'_, FnvHashMap<LseResumeKey, MarkState>> {
self.marks.lock().unwrap_or_else(PoisonError::into_inner)
}
}
pub(super) fn epoch_seconds(time: DateTime<Utc>) -> f64 {
#[allow(clippy::cast_precision_loss)]
let micros = time.timestamp_micros() as f64;
micros / 1_000_000.0
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::{super::mapper::subscription_id, *};
use crate::subscription::{SubscriptionKind, book::OrderBooksL1, trade::PublicTrades};
fn at(spelling: &str) -> DateTime<Utc> {
spelling.parse::<DateTime<Utc>>().unwrap()
}
fn id() -> SubscriptionId {
subscription_id("BTC/USD")
}
fn key() -> LseResumeKey {
LseResumeKey::new(ExchangeId::LseCrypto, id(), PublicTrades.as_str())
}
fn key_for(symbol: &str) -> LseResumeKey {
LseResumeKey::new(
ExchangeId::LseCrypto,
subscription_id(symbol),
PublicTrades.as_str(),
)
}
#[test]
fn the_first_event_seeds_the_watermark_with_a_count_of_one() {
let state = LseResumeState::new();
state.record(&key(), at("2026-01-02T09:37:24.760146Z"));
assert_eq!(
state.watermark(&key()),
Some(LseWatermark {
time_exchange: at("2026-01-02T09:37:24.760146Z"),
count_at_time: 1,
})
);
}
#[test]
fn events_sharing_an_instant_accumulate_a_count() {
let state = LseResumeState::new();
for _ in 0..141 {
state.record(&key(), at("2026-01-02T09:37:24.760Z"));
}
assert_eq!(state.watermark(&key()).unwrap().count_at_time, 141);
}
#[test]
fn a_newer_instant_advances_the_watermark_and_resets_the_count() {
let state = LseResumeState::new();
state.record(&key(), at("2026-01-02T09:37:24.760146Z"));
state.record(&key(), at("2026-01-02T09:37:24.760146Z"));
state.record(&key(), at("2026-01-02T09:37:24.760147Z"));
assert_eq!(
state.watermark(&key()),
Some(LseWatermark {
time_exchange: at("2026-01-02T09:37:24.760147Z"),
count_at_time: 1,
})
);
}
#[test]
fn an_older_instant_leaves_the_watermark_untouched() {
let state = LseResumeState::new();
state.record(&key(), at("2026-01-02T09:37:24.760147Z"));
state.record(&key(), at("2026-01-02T09:37:24.760146Z"));
assert_eq!(
state.watermark(&key()),
Some(LseWatermark {
time_exchange: at("2026-01-02T09:37:24.760147Z"),
count_at_time: 1,
})
);
}
#[test]
fn subscriptions_are_tracked_independently() {
let state = LseResumeState::new();
state.record(&key_for("BTC/USD"), at("2026-01-02T09:00:00Z"));
state.record(&key_for("ETH/USD"), at("2026-01-02T10:00:00Z"));
assert_eq!(
state.watermark(&key_for("BTC/USD")).unwrap().time_exchange,
at("2026-01-02T09:00:00Z")
);
assert_eq!(
state.watermark(&key_for("ETH/USD")).unwrap().time_exchange,
at("2026-01-02T10:00:00Z")
);
}
#[test]
fn one_symbol_served_as_two_kinds_keeps_two_watermarks() {
let state = LseResumeState::new();
let trades = LseResumeKey::new(ExchangeId::LseCrypto, id(), PublicTrades.as_str());
let books = LseResumeKey::new(ExchangeId::LseCrypto, id(), OrderBooksL1.as_str());
state.record(&trades, at("2026-01-02T09:00:00Z"));
state.record(&trades, at("2026-01-02T10:00:00Z"));
state.record(&books, at("2026-01-02T09:00:00Z"));
assert_eq!(
state.watermark(&trades).unwrap().time_exchange,
at("2026-01-02T10:00:00Z"),
);
assert_eq!(
state.watermark(&books).unwrap().time_exchange,
at("2026-01-02T09:00:00Z"),
"the trailing kind must resume from its own last delivery, not the other kind's",
);
}
#[test]
fn one_symbol_served_by_two_datasets_keeps_two_watermarks() {
let state = LseResumeState::new();
let bare = subscription_id("AAPL");
let equities =
LseResumeKey::new(ExchangeId::LseEquities, bare.clone(), PublicTrades.as_str());
let futures =
LseResumeKey::new(ExchangeId::LseFutures, bare.clone(), PublicTrades.as_str());
assert_eq!(
equities.subscription, futures.subscription,
"this test is only meaningful while both datasets file under one identifier",
);
state.record(&equities, at("2026-01-02T09:00:00Z"));
state.record(&equities, at("2026-01-02T10:00:00Z"));
state.record(&futures, at("2026-01-02T09:00:00Z"));
assert_eq!(
state.watermark(&equities).unwrap().time_exchange,
at("2026-01-02T10:00:00Z"),
);
assert_eq!(
state.watermark(&futures).unwrap().time_exchange,
at("2026-01-02T09:00:00Z"),
"the trailing dataset must resume from its own last delivery, not the other dataset's",
);
}
#[test]
fn the_two_served_kinds_spell_themselves_distinctly() {
assert_ne!(PublicTrades.as_str(), OrderBooksL1.as_str());
}
#[test]
fn an_untracked_subscription_has_no_watermark() {
assert_eq!(LseResumeState::new().watermark(&key()), None);
}
#[test]
fn the_epoch_form_round_trips_an_instant_to_the_microsecond() {
for spelling in [
"2026-01-02T09:37:24.760146Z",
"2026-08-14T10:16:55.161000Z",
"2026-08-14T10:16:55.161999Z",
"2026-01-02T09:37:24Z",
] {
let instant = at(spelling);
#[allow(clippy::cast_precision_loss)] let expected = instant.timestamp_micros() as f64;
let drift = epoch_seconds(instant) * 1_000_000.0 - expected;
assert!(drift.abs() < 0.5, "{spelling} drifted {drift} microseconds");
}
}
#[test]
fn the_subscription_id_matches_the_one_the_tick_decoder_builds() {
let tick: super::super::tick::LseMessage = serde_json::from_str(
r#"{"type":"tick","symbol":"BTC/USD","ts":"2026-01-02T09:37:24.760146+00:00",
"price":42000.5,"bid":42000.5,"ask":42001.0,"volume":0.00155}"#,
)
.unwrap();
let super::super::tick::LseMessage::Tick(tick) = tick else {
panic!("expected a tick");
};
assert_eq!(tick.subscription_id, subscription_id("BTC/USD"));
}
}