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 Movers<'a> {
client: &'a SchwabClient,
}
impl<'a> Movers<'a> {
pub(crate) fn new(client: &'a SchwabClient) -> Self {
Self { client }
}
pub fn get(&self, index: MoverIndex) -> GetMoversBuilder<'a> {
GetMoversBuilder {
client: self.client,
index,
sort: None,
frequency: None,
}
}
}
#[derive(Debug)]
#[must_use = "call .send() to execute the request"]
pub struct GetMoversBuilder<'a> {
client: &'a SchwabClient,
index: MoverIndex,
sort: Option<MoverSort>,
frequency: Option<i32>,
}
impl<'a> GetMoversBuilder<'a> {
pub fn sort(mut self, sort: MoverSort) -> Self {
self.sort = Some(sort);
self
}
pub fn frequency(mut self, minutes: i32) -> Self {
self.frequency = Some(minutes);
self
}
pub async fn send(self) -> Result<MoversResponse> {
let path = format!("/movers/{}", self.index);
let mut request = self.client.market_data_http().get(&path);
if let Some(sort) = &self.sort {
let s = sort.to_string();
request = request.query(&[("sort", s.as_str())]);
}
if let Some(freq) = self.frequency {
let s = freq.to_string();
request = request.query(&[("frequency", s.as_str())]);
}
request.send_json().await
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct MoversResponse {
#[serde(default)]
pub screeners: Vec<Screener>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Screener {
#[serde(default, with = "decimal_opt")]
pub change: Option<Decimal>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub direction: Option<MoverDirection>,
#[serde(default, with = "decimal_opt")]
pub last: Option<Decimal>,
#[serde(default)]
pub symbol: Option<String>,
#[serde(rename = "totalVolume", default)]
pub total_volume: Option<i64>,
}
string_enum! {
MoverIndex {
Dji = "$DJI",
Compx = "$COMPX",
Spx = "$SPX",
Nyse = "NYSE",
Nasdaq = "NASDAQ",
Otcbb = "OTCBB",
IndexAll = "INDEX_ALL",
EquityAll = "EQUITY_ALL",
OptionAll = "OPTION_ALL",
OptionPut = "OPTION_PUT",
OptionCall = "OPTION_CALL",
}
}
string_enum! {
MoverSort {
Volume = "VOLUME",
Trades = "TRADES",
PercentChangeUp = "PERCENT_CHANGE_UP",
PercentChangeDown = "PERCENT_CHANGE_DOWN",
}
}
string_enum! {
MoverDirection {
Up = "up",
Down = "down",
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn movers_response_parses() {
let json = r#"{
"screeners": [
{
"symbol": "AAPL",
"description": "Apple Inc.",
"direction": "up",
"change": 0.0314,
"last": 145.32,
"totalVolume": 50000000
},
{
"symbol": "TSLA",
"description": "Tesla Inc.",
"direction": "down",
"change": -0.0212,
"last": 240.15,
"totalVolume": 80000000
}
]
}"#;
let resp: MoversResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.screeners.len(), 2);
let aapl = &resp.screeners[0];
assert_eq!(aapl.symbol.as_deref(), Some("AAPL"));
assert_eq!(aapl.direction, Some(MoverDirection::Up));
assert_eq!(aapl.change, Some(dec!(0.0314)));
assert_eq!(aapl.last, Some(dec!(145.32)));
assert_eq!(aapl.total_volume, Some(50000000));
let tsla = &resp.screeners[1];
assert_eq!(tsla.direction, Some(MoverDirection::Down));
assert_eq!(tsla.change, Some(dec!(-0.0212)));
}
#[test]
fn empty_movers_response_parses() {
let resp: MoversResponse = serde_json::from_str(r#"{"screeners": []}"#).unwrap();
assert!(resp.screeners.is_empty());
}
#[test]
fn movers_response_with_missing_screeners_defaults_empty() {
let resp: MoversResponse = serde_json::from_str("{}").unwrap();
assert!(resp.screeners.is_empty());
}
#[test]
fn mover_index_round_trips_dollar_prefixed_variants() {
for raw in ["$DJI", "$COMPX", "$SPX", "NYSE", "OPTION_CALL"] {
let json = format!(r#""{raw}""#);
let parsed: MoverIndex = serde_json::from_str(&json).unwrap();
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
}
#[test]
fn mover_index_display_keeps_dollar_prefix() {
assert_eq!(MoverIndex::Dji.to_string(), "$DJI");
assert_eq!(MoverIndex::Spx.to_string(), "$SPX");
}
#[test]
fn mover_sort_round_trips_known_variants() {
for raw in [
"VOLUME",
"TRADES",
"PERCENT_CHANGE_UP",
"PERCENT_CHANGE_DOWN",
] {
let json = format!(r#""{raw}""#);
let parsed: MoverSort = serde_json::from_str(&json).unwrap();
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
}
#[test]
fn unknown_mover_direction_preserves_raw_string() {
let parsed: MoverDirection = serde_json::from_str(r#""sideways""#).unwrap();
assert!(matches!(parsed, MoverDirection::Unknown(ref s) if s == "sideways"));
}
}