use super::mapper::subscription_id;
use crate::Identifier;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use rustrade_integration::subscription::SubscriptionId;
use serde::{
Deserialize, Deserializer,
de::{self, Unexpected, Visitor},
};
use smol_str::SmolStr;
use std::fmt;
#[derive(Clone, PartialEq, Eq, Debug, Deserialize)]
pub struct LseTick {
#[serde(rename = "symbol", deserialize_with = "de_tick_subscription_id")]
pub subscription_id: SubscriptionId,
#[serde(rename = "ts", deserialize_with = "de_timestamp")]
pub time_exchange: DateTime<Utc>,
pub price: Decimal,
#[serde(default)]
pub bid: Option<Decimal>,
#[serde(default)]
pub ask: Option<Decimal>,
pub volume: Decimal,
#[serde(default)]
pub replay: bool,
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LseMessage {
Tick(LseTick),
ReplayStarted {
#[serde(rename = "symbol", deserialize_with = "de_tick_subscription_id")]
subscription_id: SubscriptionId,
#[serde(deserialize_with = "de_timestamp")]
from: DateTime<Utc>,
},
Error {
#[serde(default)]
code: Option<SmolStr>,
#[serde(default)]
message: Option<SmolStr>,
},
#[serde(other)]
Other,
}
impl Identifier<Option<SubscriptionId>> for LseMessage {
fn id(&self) -> Option<SubscriptionId> {
match self {
Self::Tick(tick) => Some(tick.subscription_id.clone()),
Self::ReplayStarted { .. } | Self::Error { .. } | Self::Other => None,
}
}
}
fn de_tick_subscription_id<'de, D>(deserializer: D) -> Result<SubscriptionId, D::Error>
where
D: Deserializer<'de>,
{
SmolStr::deserialize(deserializer).map(|symbol| subscription_id(&symbol))
}
fn de_timestamp<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(TimestampVisitor)
}
struct TimestampVisitor;
impl Visitor<'_> for TimestampVisitor {
type Value = DateTime<Utc>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(
"an RFC 3339 timestamp, the same with a space separating date and time, or epoch \
seconds as a number",
)
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
parse_timestamp(value).ok_or_else(|| E::invalid_value(Unexpected::Str(value), &self))
}
fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
where
E: de::Error,
{
time_from_epoch_seconds(value)
.ok_or_else(|| E::invalid_value(Unexpected::Float(value), &self))
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: de::Error,
{
i64::try_from(value)
.ok()
.and_then(|seconds| DateTime::from_timestamp(seconds, 0))
.ok_or_else(|| E::invalid_value(Unexpected::Unsigned(value), &self))
}
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: de::Error,
{
DateTime::from_timestamp(value, 0)
.ok_or_else(|| E::invalid_value(Unexpected::Signed(value), &self))
}
}
fn parse_timestamp(raw: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(raw)
.ok()
.map(|parsed| parsed.with_timezone(&Utc))
.and_then(round_to_micros)
}
fn round_to_micros(time: DateTime<Utc>) -> Option<DateTime<Utc>> {
let round_up = i64::from(time.timestamp_subsec_nanos() % 1_000 >= 500);
DateTime::from_timestamp_micros(time.timestamp_micros() + round_up)
}
fn time_from_epoch_seconds(epoch: f64) -> Option<DateTime<Utc>> {
const MICROS_PER_SECOND: f64 = 1_000_000.0;
if !epoch.is_finite() {
return None;
}
let micros = (epoch * MICROS_PER_SECOND).round();
if micros < i64::MIN as f64 || micros > i64::MAX as f64 {
return None;
}
#[allow(clippy::cast_possible_truncation)]
let micros = micros as i64;
DateTime::from_timestamp_micros(micros)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use rust_decimal_macros::dec;
const LIVE_T_SEPARATED: &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}"#;
const LIVE_SPACE_SEPARATED: &str = r#"{"type":"tick","symbol":"EUR/USD",
"ts":"2026-01-02 09:37:21.690159+00:00","price":1.1,"bid":1.1,"ask":1.1001,
"volume":1.0}"#;
#[test]
fn a_t_separated_timestamp_decodes() {
let tick: LseTick = serde_json::from_str(LIVE_T_SEPARATED).unwrap();
assert_eq!(
tick.time_exchange,
"2026-01-02T09:37:24.760146Z"
.parse::<DateTime<Utc>>()
.unwrap()
);
assert_eq!(tick.price, dec!(42000.5));
assert_eq!(tick.volume, dec!(0.00155));
}
#[test]
fn a_space_separated_timestamp_decodes_to_the_same_instant_as_the_t_form() {
let tick: LseTick = serde_json::from_str(LIVE_SPACE_SEPARATED).unwrap();
assert_eq!(
tick.time_exchange,
"2026-01-02T09:37:21.690159Z"
.parse::<DateTime<Utc>>()
.unwrap()
);
}
#[test]
fn a_timestamp_without_a_subsecond_component_decodes() {
let input = r#"{"type":"tick","symbol":"VIX/USD","ts":"2026-01-02T19:59:46+00:00",
"price":16.9,"bid":16.9,"ask":16.93,"volume":0.0}"#;
let tick: LseTick = serde_json::from_str(input).unwrap();
assert_eq!(
tick.time_exchange,
"2026-01-02T19:59:46Z".parse::<DateTime<Utc>>().unwrap()
);
}
#[test]
fn a_float_epoch_timestamp_decodes() {
let input = r#"{"type":"tick","symbol":"BTC/USD","ts":1767346644.592,"name":"Bitcoin",
"replay":true,"price":42000.5,"bid":42000.5,"ask":42001.0,"volume":0.00155}"#;
let tick: LseTick = serde_json::from_str(input).unwrap();
assert_eq!(
tick.time_exchange,
DateTime::from_timestamp_micros(1_767_346_644_592_000).unwrap()
);
assert!(tick.replay);
}
#[test]
fn the_string_and_float_spellings_of_one_instant_decode_equal() {
let as_string = parse_timestamp("2026-01-02T09:37:24.760146+00:00").unwrap();
let as_float = time_from_epoch_seconds(1_767_346_644.760_146).unwrap();
assert_eq!(as_string, as_float);
}
#[test]
fn a_sub_microsecond_string_is_quantised_so_it_still_matches_the_float_spelling() {
let seven_digits = parse_timestamp("2026-01-02T09:37:24.7601461+00:00").unwrap();
let as_float = time_from_epoch_seconds(1_767_346_644.760_146).unwrap();
assert_eq!(seven_digits, as_float);
assert_eq!(seven_digits.timestamp_subsec_nanos() % 1_000, 0);
assert_eq!(
parse_timestamp("2026-01-02T09:37:24.7601465+00:00").unwrap(),
"2026-01-02T09:37:24.760147Z"
.parse::<DateTime<Utc>>()
.unwrap()
);
}
#[test]
fn an_integer_epoch_timestamp_decodes() {
let input = r#"{"type":"tick","symbol":"BTC/USD","ts":1767346644,"replay":true,
"price":42000.5,"bid":42000.5,"ask":42001.0,"volume":0.00155}"#;
let tick: LseTick = serde_json::from_str(input).unwrap();
assert_eq!(
tick.time_exchange,
DateTime::from_timestamp(1_767_346_644, 0).unwrap()
);
}
#[test]
fn replay_defaults_to_false_when_the_key_is_absent() {
let tick: LseTick = serde_json::from_str(LIVE_T_SEPARATED).unwrap();
assert!(!tick.replay);
}
const OPTION_TICK: &str = r#"{"type":"tick","symbol":"TEST261231C00010500",
"ts":"2026-01-02T15:00:00+00:00","price":1.25,"bid":null,"ask":null,"volume":3,
"name":"TEST $10.50 Call Dec 31"}"#;
#[test]
fn an_option_tick_decodes_with_no_quote() {
let tick: LseTick = serde_json::from_str(OPTION_TICK).unwrap();
assert_eq!(tick.subscription_id.as_ref(), "TEST261231C00010500");
assert_eq!(tick.price, dec!(1.25));
assert_eq!(tick.volume, dec!(3));
assert_eq!(tick.bid, None);
assert_eq!(tick.ask, None);
assert_eq!(
tick.time_exchange,
"2026-01-02T15:00:00Z".parse::<DateTime<Utc>>().unwrap()
);
}
#[test]
fn an_option_ticks_name_does_not_mark_it_as_replayed() {
let tick: LseTick = serde_json::from_str(OPTION_TICK).unwrap();
assert!(!tick.replay);
}
#[test]
fn a_long_root_option_ticks_identifier_is_not_allocated() {
let frame = OPTION_TICK.replace("TEST261231C00010500", "GOOGLX261231C00010500");
let tick: LseTick = serde_json::from_str(&frame).unwrap();
assert_eq!(tick.subscription_id.as_ref(), "GOOGLX261231C00010500");
assert!(!tick.subscription_id.0.is_heap_allocated());
}
#[test]
fn the_subscription_id_is_built_from_the_symbol() {
let tick: LseTick = serde_json::from_str(LIVE_T_SEPARATED).unwrap();
assert_eq!(tick.subscription_id.as_ref(), "BTC/USD");
}
#[test]
fn a_timestamp_without_an_offset_is_rejected() {
assert!(parse_timestamp("2026-01-02 09:37:21.690159").is_none());
assert!(parse_timestamp("2026-01-02T09:37:21.690159").is_none());
}
#[test]
fn a_non_timestamp_string_is_rejected() {
assert!(parse_timestamp("").is_none());
assert!(parse_timestamp("not a timestamp").is_none());
}
#[test]
fn a_non_finite_epoch_is_rejected() {
assert!(time_from_epoch_seconds(f64::NAN).is_none());
assert!(time_from_epoch_seconds(f64::INFINITY).is_none());
assert!(time_from_epoch_seconds(f64::MAX).is_none());
}
#[test]
fn a_tick_frame_decodes_as_a_tick() {
let message: LseMessage = serde_json::from_str(LIVE_T_SEPARATED).unwrap();
assert!(matches!(message, LseMessage::Tick(_)));
}
#[test]
fn a_tick_identifies_itself_by_its_subscription() {
let message: LseMessage = serde_json::from_str(LIVE_T_SEPARATED).unwrap();
assert_eq!(
message.id(),
Some(SubscriptionId::from("BTC/USD")),
"a tick must resolve to the subscription it was registered under"
);
}
#[test]
fn a_control_frame_identifies_no_subscription() {
let message: LseMessage =
serde_json::from_str(r#"{"type":"replay_complete","symbol":"BTC/USD","rows":41}"#)
.unwrap();
assert_eq!(message.id(), None);
}
#[test]
fn control_frames_decode_as_other_rather_than_failing_the_parse() {
for input in [
r#"{"type":"subscribed","symbol":"BTC/USD","count":1,"max":16}"#,
r#"{"type":"replay_complete","symbol":"BTC/USD","rows":41,"buffered_drained":9}"#,
r#"{"type":"welcome","message":"hello","symbols_available":1}"#,
] {
let message: LseMessage = serde_json::from_str(input).unwrap();
assert_eq!(message, LseMessage::Other, "failed on {input}");
}
}
#[test]
fn a_rejection_decodes_as_an_error_rather_than_being_collapsed_into_other() {
let message: LseMessage = serde_json::from_str(
r#"{"type":"error","code":"LIMIT_REACHED","message":"Max 16 symbols"}"#,
)
.unwrap();
assert_eq!(
message,
LseMessage::Error {
code: Some("LIMIT_REACHED".into()),
message: Some("Max 16 symbols".into()),
}
);
}
#[test]
fn a_rejection_missing_its_fields_still_decodes_as_an_error() {
let message: LseMessage = serde_json::from_str(r#"{"type":"error"}"#).unwrap();
assert_eq!(
message,
LseMessage::Error {
code: None,
message: None,
}
);
}
#[test]
fn a_rejection_identifies_no_subscription() {
let message: LseMessage =
serde_json::from_str(r#"{"type":"error","code":"LIMIT_REACHED"}"#).unwrap();
assert_eq!(message.id(), None);
}
#[test]
fn a_symbol_carrying_an_escaped_solidus_decodes_to_the_same_subscription() {
let escaped: 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();
assert_eq!(escaped.id(), Some(SubscriptionId::from("BTC/USD")));
let boundary: LseMessage = serde_json::from_str(
r#"{"type":"replay_started","symbol":"BTC\/USD","from":"2026-01-02T09:37:24+00:00"}"#,
)
.unwrap();
assert_eq!(
boundary,
LseMessage::ReplayStarted {
subscription_id: SubscriptionId::from("BTC/USD"),
from: "2026-01-02T09:37:24Z".parse::<DateTime<Utc>>().unwrap(),
}
);
}
#[test]
fn a_replay_boundary_decodes_with_its_window_in_every_timestamp_spelling() {
for raw in [
r#""2026-01-02 09:39:31.716622+00:00""#,
r#""2026-01-02T09:39:31.716622+00:00""#,
"1767346771.716622",
] {
let input = format!(r#"{{"type":"replay_started","symbol":"BTC/USD","from":{raw}}}"#);
let message: LseMessage = serde_json::from_str(&input).unwrap();
assert_eq!(
message,
LseMessage::ReplayStarted {
subscription_id: SubscriptionId::from("BTC/USD"),
from: "2026-01-02T09:39:31.716622Z"
.parse::<DateTime<Utc>>()
.unwrap(),
},
"failed on {raw}"
);
}
}
#[test]
fn a_replay_boundary_identifies_no_subscription() {
let message: LseMessage = serde_json::from_str(
r#"{"type":"replay_started","symbol":"BTC/USD","from":"2026-01-02T09:37:24+00:00"}"#,
)
.unwrap();
assert_eq!(message.id(), None);
}
}