use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use rust_decimal::serde::float_option as decimal_opt;
use serde::Deserialize;
use crate::client::SchwabClient;
use crate::error::Result;
use crate::macros::string_enum;
#[derive(Debug)]
pub struct PriceHistory<'a> {
client: &'a SchwabClient,
}
impl<'a> PriceHistory<'a> {
pub(crate) fn new(client: &'a SchwabClient) -> Self {
Self { client }
}
pub fn get(&self, symbol: impl Into<String>) -> GetPriceHistoryBuilder<'a> {
GetPriceHistoryBuilder {
client: self.client,
symbol: symbol.into(),
period_type: None,
period: None,
frequency_type: None,
frequency: None,
start_date: None,
end_date: None,
need_extended_hours_data: None,
need_previous_close: None,
}
}
}
#[derive(Debug)]
#[must_use = "call .send() to execute the request"]
pub struct GetPriceHistoryBuilder<'a> {
client: &'a SchwabClient,
symbol: String,
period_type: Option<PeriodType>,
period: Option<i32>,
frequency_type: Option<FrequencyType>,
frequency: Option<i32>,
start_date: Option<i64>,
end_date: Option<i64>,
need_extended_hours_data: Option<bool>,
need_previous_close: Option<bool>,
}
impl<'a> GetPriceHistoryBuilder<'a> {
pub fn period_type(mut self, value: PeriodType) -> Self {
self.period_type = Some(value);
self
}
pub fn period(mut self, value: i32) -> Self {
self.period = Some(value);
self
}
pub fn frequency_type(mut self, value: FrequencyType) -> Self {
self.frequency_type = Some(value);
self
}
pub fn frequency(mut self, value: i32) -> Self {
self.frequency = Some(value);
self
}
pub fn start_date(mut self, value: DateTime<Utc>) -> Self {
self.start_date = Some(value.timestamp_millis());
self
}
pub fn end_date(mut self, value: DateTime<Utc>) -> Self {
self.end_date = Some(value.timestamp_millis());
self
}
pub fn start_date_millis(mut self, ms: i64) -> Self {
self.start_date = Some(ms);
self
}
pub fn end_date_millis(mut self, ms: i64) -> Self {
self.end_date = Some(ms);
self
}
pub fn need_extended_hours_data(mut self, value: bool) -> Self {
self.need_extended_hours_data = Some(value);
self
}
pub fn need_previous_close(mut self, value: bool) -> Self {
self.need_previous_close = Some(value);
self
}
pub async fn send(self) -> Result<CandleList> {
let mut request = self
.client
.market_data_http()
.get("/pricehistory")
.query(&[("symbol", self.symbol.as_str())]);
if let Some(pt) = &self.period_type {
let s = pt.to_string();
request = request.query(&[("periodType", s.as_str())]);
}
if let Some(p) = self.period {
let s = p.to_string();
request = request.query(&[("period", s.as_str())]);
}
if let Some(ft) = &self.frequency_type {
let s = ft.to_string();
request = request.query(&[("frequencyType", s.as_str())]);
}
if let Some(f) = self.frequency {
let s = f.to_string();
request = request.query(&[("frequency", s.as_str())]);
}
if let Some(sd) = self.start_date {
let s = sd.to_string();
request = request.query(&[("startDate", s.as_str())]);
}
if let Some(ed) = self.end_date {
let s = ed.to_string();
request = request.query(&[("endDate", s.as_str())]);
}
if let Some(b) = self.need_extended_hours_data {
let s = if b { "true" } else { "false" };
request = request.query(&[("needExtendedHoursData", s)]);
}
if let Some(b) = self.need_previous_close {
let s = if b { "true" } else { "false" };
request = request.query(&[("needPreviousClose", s)]);
}
request.send_json().await
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct CandleList {
#[serde(default)]
pub candles: Vec<Candle>,
#[serde(default)]
pub empty: bool,
#[serde(default, with = "decimal_opt", rename = "previousClose")]
pub previous_close: Option<Decimal>,
#[serde(default, rename = "previousCloseDate")]
pub previous_close_date: Option<i64>,
#[serde(default, rename = "previousCloseDateISO8601")]
pub previous_close_date_iso8601: Option<String>,
#[serde(default)]
pub symbol: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Candle {
#[serde(default, with = "decimal_opt")]
pub open: Option<Decimal>,
#[serde(default, with = "decimal_opt")]
pub high: Option<Decimal>,
#[serde(default, with = "decimal_opt")]
pub low: Option<Decimal>,
#[serde(default, with = "decimal_opt")]
pub close: Option<Decimal>,
#[serde(default)]
pub datetime: Option<i64>,
#[serde(default, rename = "datetimeISO8601")]
pub datetime_iso8601: Option<String>,
#[serde(default)]
pub volume: Option<i64>,
}
string_enum! {
PeriodType {
Day = "day",
Month = "month",
Year = "year",
Ytd = "ytd",
}
}
string_enum! {
FrequencyType {
Minute = "minute",
Daily = "daily",
Weekly = "weekly",
Monthly = "monthly",
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn candle_list_with_minute_bars_parses() {
let json = r#"{
"symbol": "AAPL",
"empty": false,
"previousClose": 145.32,
"previousCloseDate": 1710374400000,
"previousCloseDateISO8601": "2024-03-14",
"candles": [
{
"datetime": 1710423000000,
"datetimeISO8601": "2024-03-14",
"open": 145.30,
"high": 145.50,
"low": 145.10,
"close": 145.45,
"volume": 12345
},
{
"datetime": 1710423060000,
"open": 145.45,
"high": 145.55,
"low": 145.30,
"close": 145.40,
"volume": 9876
}
]
}"#;
let resp: CandleList = serde_json::from_str(json).unwrap();
assert_eq!(resp.symbol.as_deref(), Some("AAPL"));
assert!(!resp.empty);
assert_eq!(resp.previous_close, Some(dec!(145.32)));
assert_eq!(resp.previous_close_date, Some(1710374400000));
assert_eq!(resp.candles.len(), 2);
let c0 = &resp.candles[0];
assert_eq!(c0.open, Some(dec!(145.30)));
assert_eq!(c0.high, Some(dec!(145.50)));
assert_eq!(c0.low, Some(dec!(145.10)));
assert_eq!(c0.close, Some(dec!(145.45)));
assert_eq!(c0.volume, Some(12345));
assert_eq!(c0.datetime, Some(1710423000000));
assert_eq!(c0.datetime_iso8601.as_deref(), Some("2024-03-14"));
let c1 = &resp.candles[1];
assert_eq!(c1.datetime, Some(1710423060000));
assert_eq!(c1.datetime_iso8601, None);
}
#[test]
fn empty_candle_list_parses() {
let json = r#"{
"symbol": "AAPL",
"empty": true,
"candles": []
}"#;
let resp: CandleList = serde_json::from_str(json).unwrap();
assert!(resp.empty);
assert!(resp.candles.is_empty());
assert_eq!(resp.previous_close, None);
}
#[test]
fn period_type_round_trips_known_variants() {
for raw in ["day", "month", "year", "ytd"] {
let json = format!(r#""{raw}""#);
let parsed: PeriodType = serde_json::from_str(&json).unwrap();
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
}
#[test]
fn frequency_type_round_trips_known_variants() {
for raw in ["minute", "daily", "weekly", "monthly"] {
let json = format!(r#""{raw}""#);
let parsed: FrequencyType = serde_json::from_str(&json).unwrap();
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
}
#[test]
fn unknown_period_type_preserves_raw_string() {
let parsed: PeriodType = serde_json::from_str(r#""quarter""#).unwrap();
assert!(matches!(parsed, PeriodType::Unknown(ref s) if s == "quarter"));
}
}