use super::error::MassiveError;
use crate::{
books::Level,
subscription::{
book::OrderBookL1,
candle::{Candle, IntervalStep, close_time_from_open},
trade::PublicTrade,
},
};
use chrono::{DateTime, Duration, TimeZone, Utc};
use rust_decimal::Decimal;
use rustrade_instrument::Side;
use serde::{Deserialize, Deserializer, Serialize};
use smol_str::SmolStr;
pub(super) fn deserialize_first_condition<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{SeqAccess, Visitor};
struct FirstElementVisitor;
impl<'de> Visitor<'de> for FirstElementVisitor {
type Value = Option<i32>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("an array of integers or null")
}
fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_some<D: Deserializer<'de>>(
self,
deserializer: D,
) -> Result<Self::Value, D::Error> {
deserializer.deserialize_seq(self)
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let first = seq.next_element()?;
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(first)
}
}
deserializer.deserialize_option(FirstElementVisitor)
}
#[derive(Debug, Deserialize)]
pub struct AggregatesResponse {
#[allow(dead_code)] pub ticker: Option<String>,
#[serde(rename = "resultsCount", default)]
pub results_count: usize,
#[serde(default)]
#[allow(dead_code)] pub status: String,
#[allow(dead_code)] pub request_id: Option<String>,
pub next_url: Option<String>,
pub results: Option<Vec<AggregateBar>>,
}
#[derive(Debug, Deserialize)]
pub struct AggregateBar {
#[serde(rename = "o", with = "rust_decimal::serde::float")]
pub open: Decimal,
#[serde(rename = "h", with = "rust_decimal::serde::float")]
pub high: Decimal,
#[serde(rename = "l", with = "rust_decimal::serde::float")]
pub low: Decimal,
#[serde(rename = "c", with = "rust_decimal::serde::float")]
pub close: Decimal,
#[serde(rename = "v", with = "rust_decimal::serde::float")]
pub volume: Decimal,
#[serde(rename = "vw", with = "rust_decimal::serde::float_option", default)]
#[allow(dead_code)] pub vwap: Option<Decimal>,
#[serde(rename = "t")]
pub timestamp: i64,
#[serde(rename = "n")]
pub trade_count: Option<u64>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub enum AggregateProvenance {
TradeTape,
QuoteTape,
}
impl AggregateProvenance {
#[must_use]
pub fn for_ticker(ticker: &str) -> Self {
if ticker
.get(..2)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("C:"))
{
Self::QuoteTape
} else {
Self::TradeTape
}
}
pub(super) fn volume(self, volume: Decimal) -> Option<Decimal> {
match self {
Self::TradeTape => Some(volume),
Self::QuoteTape => None,
}
}
fn trade_count(self, trade_count: Option<u64>) -> Option<u64> {
match self {
Self::TradeTape => trade_count,
Self::QuoteTape => None,
}
}
}
impl AggregateBar {
#[cfg(test)]
fn into_candle(
self,
multiplier: u32,
timespan: &str,
provenance: AggregateProvenance,
) -> Result<Candle, MassiveError> {
self.into_candle_with_step(timespan_to_step(multiplier, timespan), provenance)
}
pub fn into_candle_with_step(
self,
step: IntervalStep,
provenance: AggregateProvenance,
) -> Result<Candle, MassiveError> {
let start_time = Utc
.timestamp_millis_opt(self.timestamp)
.single()
.unwrap_or_else(|| {
tracing::warn!(
timestamp_ms = self.timestamp,
"AggregateBar has out-of-range timestamp; using UNIX_EPOCH"
);
DateTime::<Utc>::UNIX_EPOCH
});
let close_time =
close_time_from_open(start_time, step).ok_or_else(|| MassiveError::InvalidInput {
message: format!("candle close_time overflow: open={start_time}, step={step:?}"),
})?;
Ok(Candle {
close_time,
open: self.open,
high: self.high,
low: self.low,
close: self.close,
volume: provenance.volume(self.volume),
trade_count: provenance.trade_count(self.trade_count),
})
}
}
pub fn parse_aggregates_response(body: &str) -> Result<AggregatesResponse, MassiveError> {
serde_json::from_str(body).map_err(|e| MassiveError::Deserialize {
message: e.to_string(),
payload: body[..body.floor_char_boundary(512)].to_owned(),
})
}
pub fn timespan_to_step(multiplier: u32, timespan: &str) -> IntervalStep {
let mult = i64::from(multiplier);
match timespan {
"second" => IntervalStep::Fixed(Duration::seconds(mult)),
"minute" => IntervalStep::Fixed(Duration::minutes(mult)),
"hour" => IntervalStep::Fixed(Duration::hours(mult)),
"day" => IntervalStep::Fixed(Duration::days(mult)),
"week" => IntervalStep::Fixed(Duration::weeks(mult)),
"month" => IntervalStep::Months(multiplier),
"quarter" => IntervalStep::Months(multiplier.saturating_mul(3)),
"year" => IntervalStep::Months(multiplier.saturating_mul(12)),
_ => {
tracing::warn!(timespan = %timespan, "unknown timespan, defaulting to minutes");
IntervalStep::Fixed(Duration::minutes(mult))
}
}
}
#[derive(Debug, Deserialize)]
pub struct TradesResponse {
#[serde(rename = "resultsCount", default)]
pub results_count: usize,
#[serde(default)]
#[allow(dead_code)] pub status: String,
pub next_url: Option<String>,
pub results: Option<Vec<TradeRecord>>,
}
#[derive(Debug, Deserialize)]
pub struct TradeRecord {
#[serde(
rename = "conditions",
default,
deserialize_with = "deserialize_first_condition"
)]
pub first_condition: Option<i32>,
#[serde(rename = "exchange")]
#[allow(dead_code)] pub exchange: Option<i32>,
#[serde(rename = "id", default)]
pub id: String,
#[serde(rename = "participant_timestamp", default)]
#[allow(dead_code)] pub participant_timestamp: i64,
#[serde(rename = "price", with = "rust_decimal::serde::float")]
pub price: Decimal,
#[serde(rename = "size", with = "rust_decimal::serde::float")]
pub size: Decimal,
#[serde(rename = "sip_timestamp", default)]
#[allow(dead_code)] pub sip_timestamp: i64,
}
impl TradeRecord {
pub fn into_public_trade(self) -> PublicTrade {
let side = self.first_condition.and_then(|c| match c {
1 => Some(Side::Sell),
2 => Some(Side::Buy),
_ => None,
});
PublicTrade {
id: SmolStr::from(self.id),
price: self.price,
amount: self.size,
side,
}
}
#[allow(dead_code)] pub fn timestamp(&self) -> DateTime<Utc> {
nanos_to_datetime(self.participant_timestamp)
}
}
fn nanos_to_datetime(nanos: i64) -> DateTime<Utc> {
let secs = nanos.div_euclid(1_000_000_000);
#[allow(clippy::cast_possible_truncation)]
let nsecs = nanos.rem_euclid(1_000_000_000) as u32;
Utc.timestamp_opt(secs, nsecs).single().unwrap_or_else(|| {
tracing::warn!(nanos, "out-of-range nanosecond timestamp; using UNIX_EPOCH");
DateTime::<Utc>::UNIX_EPOCH
})
}
pub fn parse_trades_response(body: &str) -> Result<TradesResponse, MassiveError> {
serde_json::from_str(body).map_err(|e| MassiveError::Deserialize {
message: e.to_string(),
payload: body[..body.floor_char_boundary(512)].to_owned(),
})
}
#[derive(Debug, Deserialize)]
pub struct QuotesResponse {
#[serde(rename = "resultsCount", default)]
pub results_count: usize,
#[serde(default)]
#[allow(dead_code)] pub status: String,
pub next_url: Option<String>,
pub results: Option<Vec<QuoteRecord>>,
}
#[derive(Debug, Deserialize)]
pub struct QuoteRecord {
#[serde(rename = "ask_price", with = "rust_decimal::serde::float")]
pub ask_price: Decimal,
#[serde(
rename = "ask_size",
default,
with = "rust_decimal::serde::float_option"
)]
pub ask_size: Option<Decimal>,
#[serde(rename = "bid_price", with = "rust_decimal::serde::float")]
pub bid_price: Decimal,
#[serde(
rename = "bid_size",
default,
with = "rust_decimal::serde::float_option"
)]
pub bid_size: Option<Decimal>,
#[serde(rename = "participant_timestamp", default)]
pub participant_timestamp: i64,
#[serde(rename = "sip_timestamp", default)]
#[allow(dead_code)] pub sip_timestamp: i64,
}
impl QuoteRecord {
pub fn into_order_book_l1(self) -> OrderBookL1 {
let timestamp = self.timestamp();
OrderBookL1 {
last_update_time: timestamp,
best_bid: Some(Level {
price: self.bid_price,
amount: self.bid_size.unwrap_or(Decimal::ZERO),
}),
best_ask: Some(Level {
price: self.ask_price,
amount: self.ask_size.unwrap_or(Decimal::ZERO),
}),
}
}
pub fn timestamp(&self) -> DateTime<Utc> {
nanos_to_datetime(self.participant_timestamp)
}
}
pub fn parse_quotes_response(body: &str) -> Result<QuotesResponse, MassiveError> {
serde_json::from_str(body).map_err(|e| MassiveError::Deserialize {
message: e.to_string(),
payload: body[..body.floor_char_boundary(512)].to_owned(),
})
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct FairMarketValue {
pub time: DateTime<Utc>,
pub price: Decimal,
}
impl FairMarketValue {
pub fn new(time: DateTime<Utc>, price: Decimal) -> Self {
Self { time, price }
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use rust_decimal_macros::dec;
const SAMPLE_AGGREGATES: &str = r#"{
"ticker": "X:BTCUSD",
"queryCount": 2,
"resultsCount": 2,
"adjusted": true,
"results": [
{
"v": 234.5678,
"vw": 65432.1,
"o": 65000.0,
"c": 65100.0,
"h": 65200.0,
"l": 64900.0,
"t": 1704067200000,
"n": 150
},
{
"v": 345.6789,
"vw": 65150.0,
"o": 65100.0,
"c": 65250.0,
"h": 65300.0,
"l": 65050.0,
"t": 1704067260000,
"n": 175
}
],
"status": "OK",
"request_id": "abc123"
}"#;
#[test]
fn test_parse_aggregates() {
let response = parse_aggregates_response(SAMPLE_AGGREGATES).unwrap();
assert_eq!(response.ticker, Some("X:BTCUSD".to_string()));
assert_eq!(response.results_count, 2);
assert_eq!(response.status, "OK");
let results = response.results.unwrap();
assert_eq!(results.len(), 2);
let bar = &results[0];
assert_eq!(bar.open, dec!(65000.0));
assert_eq!(bar.close, dec!(65100.0));
assert_eq!(bar.high, dec!(65200.0));
assert_eq!(bar.low, dec!(64900.0));
assert_eq!(bar.trade_count, Some(150));
}
#[test]
fn test_aggregate_bar_to_candle() {
let bar = AggregateBar {
open: dec!(65000.0),
high: dec!(65200.0),
low: dec!(64900.0),
close: dec!(65100.0),
volume: dec!(234.5678),
vwap: Some(dec!(65050.0)),
timestamp: 1704067200000,
trade_count: Some(150),
};
let candle = bar
.into_candle(1, "minute", AggregateProvenance::TradeTape)
.unwrap();
assert_eq!(candle.open, dec!(65000.0));
assert_eq!(candle.close, dec!(65100.0));
assert_eq!(candle.trade_count, Some(150));
let expected_close =
Utc.timestamp_millis_opt(1704067200000).single().unwrap() + Duration::minutes(1);
assert_eq!(candle.close_time, expected_close);
}
#[test]
fn test_aggregate_bar_monthly_calendar_boundary() {
let bar_at = || AggregateBar {
open: dec!(65000.0),
high: dec!(65200.0),
low: dec!(64900.0),
close: dec!(65100.0),
volume: dec!(234.5678),
vwap: None,
timestamp: 1_704_067_200_000, trade_count: Some(150),
};
assert_eq!(
bar_at()
.into_candle(1, "month", AggregateProvenance::TradeTape)
.unwrap()
.close_time
.timestamp_millis(),
1_706_745_600_000 );
assert_eq!(
bar_at()
.into_candle(1, "quarter", AggregateProvenance::TradeTape)
.unwrap()
.close_time
.timestamp_millis(),
1_711_929_600_000 );
assert_eq!(
bar_at()
.into_candle(1, "year", AggregateProvenance::TradeTape)
.unwrap()
.close_time
.timestamp_millis(),
1_735_689_600_000 );
}
#[test]
fn test_parse_with_next_url() {
let json = r#"{
"ticker": "X:BTCUSD",
"resultsCount": 50000,
"status": "OK",
"next_url": "https://api.massive.com/v2/aggs/ticker/X:BTCUSD/range/1/minute/1704067200000/1704153600000?cursor=abc123",
"results": []
}"#;
let response = parse_aggregates_response(json).unwrap();
assert!(response.next_url.is_some());
assert!(response.next_url.unwrap().contains("cursor="));
}
#[test]
fn test_parse_empty_results() {
let json = r#"{
"ticker": "X:BTCUSD",
"resultsCount": 0,
"status": "OK",
"results": []
}"#;
let response = parse_aggregates_response(json).unwrap();
assert_eq!(response.results_count, 0);
assert!(response.results.unwrap().is_empty());
}
#[test]
fn test_timespan_to_step() {
assert_eq!(
timespan_to_step(1, "second"),
IntervalStep::Fixed(Duration::seconds(1))
);
assert_eq!(
timespan_to_step(5, "minute"),
IntervalStep::Fixed(Duration::minutes(5))
);
assert_eq!(
timespan_to_step(1, "hour"),
IntervalStep::Fixed(Duration::hours(1))
);
assert_eq!(
timespan_to_step(1, "day"),
IntervalStep::Fixed(Duration::days(1))
);
assert_eq!(
timespan_to_step(1, "week"),
IntervalStep::Fixed(Duration::weeks(1))
);
assert_eq!(timespan_to_step(1, "month"), IntervalStep::Months(1));
assert_eq!(timespan_to_step(2, "quarter"), IntervalStep::Months(6));
assert_eq!(timespan_to_step(1, "year"), IntervalStep::Months(12));
}
#[test]
fn test_parse_trades() {
let json = r#"{
"results": [
{
"conditions": [2],
"exchange": 1,
"id": "12345",
"participant_timestamp": 1704067200000000000,
"price": 65100.50,
"size": 0.5,
"sip_timestamp": 1704067200001000000
}
],
"status": "OK",
"resultsCount": 1
}"#;
let response = parse_trades_response(json).unwrap();
assert_eq!(response.results_count, 1);
let results = response.results.unwrap();
let trade = &results[0];
assert_eq!(trade.price, dec!(65100.50));
assert_eq!(trade.size, dec!(0.5));
assert_eq!(trade.first_condition, Some(2));
let public_trade = results.into_iter().next().unwrap().into_public_trade();
assert_eq!(public_trade.side, Some(Side::Buy));
}
#[test]
fn test_parse_quotes() {
let json = r#"{
"results": [
{
"ask_price": 65200.0,
"ask_size": 1.5,
"bid_price": 65100.0,
"bid_size": 2.0,
"participant_timestamp": 1704067200000000000,
"sip_timestamp": 1704067200001000000
}
],
"status": "OK",
"resultsCount": 1
}"#;
let response = parse_quotes_response(json).unwrap();
assert_eq!(response.results_count, 1);
let results = response.results.unwrap();
let quote = results.into_iter().next().unwrap();
let l1 = quote.into_order_book_l1();
assert_eq!(l1.best_bid.unwrap().price, dec!(65100.0));
assert_eq!(l1.best_ask.unwrap().price, dec!(65200.0));
}
#[test]
fn test_fair_market_value() {
let fmv = FairMarketValue::new(
Utc.timestamp_millis_opt(1704067200000).single().unwrap(),
dec!(65150.0),
);
assert_eq!(fmv.price, dec!(65150.0));
}
#[test]
fn a_forex_rest_bar_reports_neither_volume_nor_trade_count() {
let bar = || AggregateBar {
open: dec!(1.1),
high: dec!(1.2),
low: dec!(1.0),
close: dec!(1.15),
volume: dec!(4321),
vwap: None,
timestamp: 1_704_067_200_000,
trade_count: Some(150),
};
let forex = bar()
.into_candle(1, "minute", AggregateProvenance::for_ticker("C:EURUSD"))
.unwrap();
assert_eq!(forex.volume, None);
assert_eq!(forex.trade_count, None);
let crypto = bar()
.into_candle(1, "minute", AggregateProvenance::for_ticker("X:BTCUSD"))
.unwrap();
assert_eq!(crypto.volume, Some(dec!(4321)));
assert_eq!(crypto.trade_count, Some(150));
}
#[test]
fn aggregate_provenance_classifies_only_the_forex_prefix_as_a_quote_tape() {
assert_eq!(
AggregateProvenance::for_ticker("C:EURUSD"),
AggregateProvenance::QuoteTape
);
for traded in ["X:BTCUSD", "AAPL", "O:SPY251219C00650000", "I:SPX"] {
assert_eq!(
AggregateProvenance::for_ticker(traded),
AggregateProvenance::TradeTape,
"{traded}"
);
}
}
#[test]
fn aggregate_provenance_classifies_the_forex_prefix_regardless_of_case() {
for forex in ["c:eurusd", "c:EURUSD", "C:eurusd"] {
assert_eq!(
AggregateProvenance::for_ticker(forex),
AggregateProvenance::QuoteTape,
"{forex}"
);
}
}
#[test]
fn aggregate_provenance_does_not_panic_on_a_short_or_non_ascii_ticker() {
for ticker in ["", "C", "€:EURUSD", "é"] {
assert_eq!(
AggregateProvenance::for_ticker(ticker),
AggregateProvenance::TradeTape,
"{ticker}"
);
}
}
}