use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Resolution {
Second,
Minute,
#[serde(rename = "MINUTE_2")]
Minute2,
#[serde(rename = "MINUTE_3")]
Minute3,
#[serde(rename = "MINUTE_5")]
Minute5,
#[serde(rename = "MINUTE_10")]
Minute10,
#[serde(rename = "MINUTE_15")]
Minute15,
#[serde(rename = "MINUTE_30")]
Minute30,
Hour,
#[serde(rename = "HOUR_2")]
Hour2,
#[serde(rename = "HOUR_3")]
Hour3,
#[serde(rename = "HOUR_4")]
Hour4,
Day,
Week,
Month,
}
impl std::fmt::Display for Resolution {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = serde_json::to_value(self)
.ok()
.and_then(|v| v.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| format!("{self:?}").to_ascii_uppercase());
f.write_str(&s)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct HistoricalPricesRequest {
pub resolution: Option<Resolution>,
pub from: Option<NaiveDateTime>,
pub to: Option<NaiveDateTime>,
pub max: Option<u32>,
pub page_size: Option<u32>,
pub page_number: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PriceCandle {
pub bid: Option<f64>,
pub ask: Option<f64>,
pub last_traded: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PricePoint {
pub snapshot_time: String,
#[serde(rename = "snapshotTimeUTC")]
pub snapshot_time_utc: NaiveDateTime,
pub open_price: PriceCandle,
pub close_price: PriceCandle,
pub high_price: PriceCandle,
pub low_price: PriceCandle,
pub last_traded_volume: Option<u64>,
}
impl<'de> Deserialize<'de> for PricePoint {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
const V2_FMT: &str = "%Y/%m/%d %H:%M:%S";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
snapshot_time: String,
#[serde(rename = "snapshotTimeUTC", default)]
snapshot_time_utc: Option<NaiveDateTime>,
open_price: PriceCandle,
close_price: PriceCandle,
high_price: PriceCandle,
low_price: PriceCandle,
#[serde(default)]
last_traded_volume: Option<u64>,
}
let raw = Raw::deserialize(deserializer)?;
let snapshot_time_utc = match raw.snapshot_time_utc {
Some(t) => t,
None => NaiveDateTime::parse_from_str(&raw.snapshot_time, V2_FMT)
.map_err(serde::de::Error::custom)?,
};
Ok(PricePoint {
snapshot_time: raw.snapshot_time,
snapshot_time_utc,
open_price: raw.open_price,
close_price: raw.close_price,
high_price: raw.high_price,
low_price: raw.low_price,
last_traded_volume: raw.last_traded_volume,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageData {
pub page_number: u32,
pub page_size: u32,
pub total_pages: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PriceAllowance {
pub remaining_allowance: u32,
pub total_allowance: u32,
pub allowance_expiry: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PricesMetadata {
pub page_data: PageData,
pub allowance: PriceAllowance,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoricalPrices {
pub instrument_type: String,
pub prices: Vec<PricePoint>,
pub metadata: PricesMetadata,
}
#[cfg(feature = "polars")]
impl crate::dataframe::IntoDataFrame for HistoricalPrices {
fn to_dataframe(&self) -> crate::Result<polars::prelude::DataFrame> {
use polars::prelude::*;
let snapshot_time: Vec<&str> = self
.prices
.iter()
.map(|p| p.snapshot_time.as_str())
.collect();
let snapshot_time_utc: Vec<NaiveDateTime> =
self.prices.iter().map(|p| p.snapshot_time_utc).collect();
let open_bid: Vec<Option<f64>> = self.prices.iter().map(|p| p.open_price.bid).collect();
let open_ask: Vec<Option<f64>> = self.prices.iter().map(|p| p.open_price.ask).collect();
let high_bid: Vec<Option<f64>> = self.prices.iter().map(|p| p.high_price.bid).collect();
let high_ask: Vec<Option<f64>> = self.prices.iter().map(|p| p.high_price.ask).collect();
let low_bid: Vec<Option<f64>> = self.prices.iter().map(|p| p.low_price.bid).collect();
let low_ask: Vec<Option<f64>> = self.prices.iter().map(|p| p.low_price.ask).collect();
let close_bid: Vec<Option<f64>> = self.prices.iter().map(|p| p.close_price.bid).collect();
let close_ask: Vec<Option<f64>> = self.prices.iter().map(|p| p.close_price.ask).collect();
let last_traded_volume: Vec<Option<u64>> =
self.prices.iter().map(|p| p.last_traded_volume).collect();
let snapshot_time_utc_series = Series::new("snapshot_time_utc".into(), snapshot_time_utc);
DataFrame::new(vec![
Column::new("snapshot_time".into(), snapshot_time),
snapshot_time_utc_series.into(),
Column::new("open_bid".into(), open_bid),
Column::new("open_ask".into(), open_ask),
Column::new("high_bid".into(), high_bid),
Column::new("high_ask".into(), high_ask),
Column::new("low_bid".into(), low_bid),
Column::new("low_ask".into(), low_ask),
Column::new("close_bid".into(), close_bid),
Column::new("close_ask".into(), close_ask),
Column::new("last_traded_volume".into(), last_traded_volume),
])
.map_err(|e| crate::Error::Config(format!("polars conversion failed: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolution_serialises_numbered_with_underscore() {
let cases = [
(Resolution::Second, "SECOND"),
(Resolution::Minute, "MINUTE"),
(Resolution::Minute2, "MINUTE_2"),
(Resolution::Minute5, "MINUTE_5"),
(Resolution::Minute10, "MINUTE_10"),
(Resolution::Minute15, "MINUTE_15"),
(Resolution::Minute30, "MINUTE_30"),
(Resolution::Hour, "HOUR"),
(Resolution::Hour2, "HOUR_2"),
(Resolution::Hour4, "HOUR_4"),
(Resolution::Day, "DAY"),
];
for (res, wire) in cases {
assert_eq!(
serde_json::to_value(res).unwrap().as_str().unwrap(),
wire,
"serde wire for {res:?}"
);
assert_eq!(res.to_string(), wire, "Display for {res:?}");
}
}
#[test]
fn resolution_roundtrips_from_ig_wire() {
let r: Resolution = serde_json::from_str("\"MINUTE_15\"").unwrap();
assert_eq!(r, Resolution::Minute15);
}
fn price_json(with_utc: bool) -> String {
let utc = if with_utc {
r#""snapshotTimeUTC":"2024-06-03T14:00:00","#
} else {
""
};
format!(
r#"{{"snapshotTime":"2024/06/03 14:00:00",{utc}
"openPrice":{{"bid":1.0,"ask":1.1,"lastTraded":null}},
"closePrice":{{"bid":1.2,"ask":1.3,"lastTraded":null}},
"highPrice":{{"bid":1.4,"ask":1.5,"lastTraded":null}},
"lowPrice":{{"bid":0.9,"ask":1.0,"lastTraded":null}},
"lastTradedVolume":42}}"#
)
}
#[test]
fn price_point_uses_snapshot_time_utc_when_present() {
let p: PricePoint = serde_json::from_str(&price_json(true)).unwrap();
assert_eq!(
p.snapshot_time_utc,
NaiveDateTime::parse_from_str("2024-06-03T14:00:00", "%Y-%m-%dT%H:%M:%S").unwrap()
);
}
#[test]
fn price_point_falls_back_to_snapshot_time_when_utc_absent() {
let p: PricePoint = serde_json::from_str(&price_json(false)).unwrap();
assert_eq!(
p.snapshot_time_utc,
NaiveDateTime::parse_from_str("2024/06/03 14:00:00", "%Y/%m/%d %H:%M:%S").unwrap()
);
assert_eq!(p.snapshot_time, "2024/06/03 14:00:00");
assert_eq!(p.last_traded_volume, Some(42));
}
}