use pretty_simple_display::{DebugPretty, DisplaySimple};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::api::query::QueryBuilder;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeBack {
OneDay,
OneWeek,
OneMonth,
ThreeMonths,
SixMonths,
OneYear,
All,
}
impl TimeBack {
pub fn as_wire(&self) -> &'static str {
match self {
Self::OneDay => "1d",
Self::OneWeek => "1w",
Self::OneMonth => "1m",
Self::ThreeMonths => "3m",
Self::SixMonths => "6m",
Self::OneYear => "1y",
Self::All => "all",
}
}
}
impl std::fmt::Display for TimeBack {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_wire())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NetLiqRange {
Back(TimeBack),
Window {
start_time: Option<String>,
end_time: Option<String>,
},
}
impl NetLiqRange {
pub fn back(time_back: TimeBack) -> Self {
Self::Back(time_back)
}
pub fn window(start_time: impl Into<String>, end_time: impl Into<String>) -> Self {
Self::Window {
start_time: Some(start_time.into()),
end_time: Some(end_time.into()),
}
}
pub fn from(start_time: impl Into<String>) -> Self {
Self::Window {
start_time: Some(start_time.into()),
end_time: None,
}
}
fn write_into(&self, query: &mut QueryBuilder) {
match self {
Self::Back(time_back) => query.push("time-back", time_back.as_wire()),
Self::Window {
start_time,
end_time,
} => {
query.push_opt("start-time", start_time.as_ref());
query.push_opt("end-time", end_time.as_ref());
}
}
}
}
impl Default for NetLiqRange {
fn default() -> Self {
Self::Window {
start_time: None,
end_time: None,
}
}
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct NetLiqOhlc {
#[serde(default, alias = "open", with = "crate::types::wire::decimal_option")]
pub open: Option<Decimal>,
#[serde(default, alias = "high", with = "crate::types::wire::decimal_option")]
pub high: Option<Decimal>,
#[serde(default, alias = "low", with = "crate::types::wire::decimal_option")]
pub low: Option<Decimal>,
#[serde(default, alias = "close", with = "crate::types::wire::decimal_option")]
pub close: Option<Decimal>,
#[serde(
rename = "totalOpen",
alias = "total-open",
default,
with = "crate::types::wire::decimal_option"
)]
pub total_open: Option<Decimal>,
#[serde(
rename = "totalHigh",
alias = "total-high",
default,
with = "crate::types::wire::decimal_option"
)]
pub total_high: Option<Decimal>,
#[serde(
rename = "totalLow",
alias = "total-low",
default,
with = "crate::types::wire::decimal_option"
)]
pub total_low: Option<Decimal>,
#[serde(
rename = "totalClose",
alias = "total-close",
default,
with = "crate::types::wire::decimal_option"
)]
pub total_close: Option<Decimal>,
#[serde(
rename = "pendingCashOpen",
alias = "pending-cash-open",
default,
with = "crate::types::wire::decimal_option"
)]
pub pending_cash_open: Option<Decimal>,
#[serde(
rename = "pendingCashHigh",
alias = "pending-cash-high",
default,
with = "crate::types::wire::decimal_option"
)]
pub pending_cash_high: Option<Decimal>,
#[serde(
rename = "pendingCashLow",
alias = "pending-cash-low",
default,
with = "crate::types::wire::decimal_option"
)]
pub pending_cash_low: Option<Decimal>,
#[serde(
rename = "pendingCashClose",
alias = "pending-cash-close",
default,
with = "crate::types::wire::decimal_option"
)]
pub pending_cash_close: Option<Decimal>,
#[serde(default)]
pub time: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NetLiqHistoryFilter {
range: NetLiqRange,
interval: Option<String>,
}
impl NetLiqHistoryFilter {
pub fn new() -> Self {
Self::default()
}
pub fn back(time_back: TimeBack) -> Self {
Self::new().with_range(NetLiqRange::back(time_back))
}
#[must_use]
pub fn with_range(mut self, range: NetLiqRange) -> Self {
self.range = range;
self
}
#[must_use]
pub fn with_interval(mut self, interval: impl Into<String>) -> Self {
self.interval = Some(interval.into());
self
}
pub fn range(&self) -> &NetLiqRange {
&self.range
}
pub(crate) fn to_query(&self) -> QueryBuilder {
let mut query = QueryBuilder::new();
self.range.write_into(&mut query);
query.push_opt("interval", self.interval.as_ref());
query
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_relative_range_and_a_window_can_never_be_sent_together() {
let relative = NetLiqHistoryFilter::back(TimeBack::ThreeMonths);
assert_eq!(relative.to_query().pairs(), vec![("time-back", "3m")]);
let windowed = NetLiqHistoryFilter::new().with_range(NetLiqRange::window(
"2026-01-01T00:00:00+00:00[UTC]",
"2026-02-01T00:00:00+00:00[UTC]",
));
let query = windowed.to_query();
let pairs = query.pairs();
assert!(pairs.iter().all(|(key, _)| *key != "time-back"));
assert_eq!(
pairs,
vec![
("start-time", "2026-01-01T00:00:00+00:00[UTC]"),
("end-time", "2026-02-01T00:00:00+00:00[UTC]"),
]
);
}
#[test]
fn every_documented_time_back_value_has_its_own_spelling() {
for (value, wire) in [
(TimeBack::OneDay, "1d"),
(TimeBack::OneWeek, "1w"),
(TimeBack::OneMonth, "1m"),
(TimeBack::ThreeMonths, "3m"),
(TimeBack::SixMonths, "6m"),
(TimeBack::OneYear, "1y"),
(TimeBack::All, "all"),
] {
assert_eq!(value.as_wire(), wire);
assert_eq!(value.to_string(), wire);
}
}
#[test]
fn an_unfiltered_request_sends_nothing() {
assert!(NetLiqHistoryFilter::new().to_query().pairs().is_empty());
}
#[test]
fn the_interval_is_sent_alongside_either_range() {
let filter = NetLiqHistoryFilter::back(TimeBack::All).with_interval("1d");
assert_eq!(
filter.to_query().pairs(),
vec![("time-back", "all"), ("interval", "1d")]
);
}
#[test]
fn a_bar_decodes_from_the_camel_case_the_service_documents() {
let bar: NetLiqOhlc = serde_json::from_str(
r#"{"open": 1000.5, "high": 1100.25, "low": 990.0, "close": 1050.75,
"totalOpen": 2000.5, "totalClose": 2050.75,
"pendingCashOpen": 0.0, "pendingCashClose": 25.0,
"time": "2026-08-03T00:00:00Z"}"#,
)
.expect("the bar must decode");
assert_eq!(bar.open.expect("an open").to_string(), "1000.5");
assert_eq!(
bar.total_close.expect("a total close").to_string(),
"2050.75"
);
assert_eq!(
bar.pending_cash_close.expect("pending cash").to_string(),
"25.0"
);
assert_eq!(bar.time.as_deref(), Some("2026-08-03T00:00:00Z"));
}
#[test]
fn a_bar_also_decodes_from_kebab_case() {
let bar: NetLiqOhlc = serde_json::from_str(
r#"{"open": "1000.5", "total-close": "2050.75", "pending-cash-close": "25.0"}"#,
)
.expect("the bar must decode either way");
assert_eq!(
bar.total_close.expect("a total close").to_string(),
"2050.75"
);
assert_eq!(
bar.pending_cash_close.expect("pending cash").to_string(),
"25.0"
);
}
#[test]
fn an_empty_bar_is_absent_rather_than_zero() {
let bar: NetLiqOhlc = serde_json::from_str("{}").expect("an empty bar decodes");
assert_eq!(bar.open, None);
assert_eq!(bar.close, None);
assert_eq!(bar.time, None);
}
}