use chrono::DateTime;
use serde::{Deserialize, Serialize};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn order_side_buy_str() {
assert_eq!(OrderSide::Buy.as_str(), "buy");
}
#[test]
fn order_side_sell_str() {
assert_eq!(OrderSide::Sell.as_str(), "sell");
}
#[test]
fn order_type_market_str() {
assert_eq!(OrderType::Market.as_str(), "market");
}
#[test]
fn order_type_limit_str() {
assert_eq!(OrderType::Limit.as_str(), "limit");
}
#[test]
fn time_in_force_day_str() {
assert_eq!(TimeInForce::Day.as_str(), "day");
}
#[test]
fn time_in_force_gtc_str() {
assert_eq!(TimeInForce::Gtc.as_str(), "gtc");
}
#[test]
fn account_info_deserializes() {
let json = r#"{
"status": "ACTIVE",
"equity": "100000",
"buying_power": "200000",
"cash": "100000",
"long_market_value": "0",
"daytrade_count": 0,
"pattern_day_trader": false,
"currency": "USD"
}"#;
let acc: AccountInfo = serde_json::from_str(json).unwrap();
assert_eq!(acc.status, "ACTIVE");
assert_eq!(acc.equity, "100000");
assert_eq!(acc.buying_power, "200000");
assert_eq!(acc.cash, "100000");
assert_eq!(acc.daytrade_count, 0);
assert!(!acc.pattern_day_trader);
assert_eq!(acc.currency, "USD");
assert!(acc.portfolio_value.is_none());
assert_eq!(acc.last_equity, "");
assert_eq!(acc.account_number, "");
}
#[test]
fn account_info_deserializes_pl_and_account_number() {
let json = r#"{
"status": "ACTIVE",
"equity": "125432.18",
"last_equity": "124588.96",
"buying_power": "48210.00",
"cash": "48210.00",
"long_market_value": "77222.18",
"daytrade_count": 1,
"pattern_day_trader": false,
"currency": "USD",
"account_number": "PA1234567"
}"#;
let acc: AccountInfo = serde_json::from_str(json).unwrap();
assert_eq!(acc.last_equity, "124588.96");
assert_eq!(acc.account_number, "PA1234567");
let equity: f64 = acc.equity.parse().unwrap();
let last: f64 = acc.last_equity.parse().unwrap();
let day_pl = equity - last;
assert!(
(day_pl - 843.22).abs() < 0.01,
"Day P&L should be ~843.22, got {day_pl}"
);
}
#[test]
fn order_notional_qty_null() {
let json = r#"{
"id": "abc",
"symbol": "AAPL",
"side": "buy",
"qty": null,
"notional": "500",
"order_type": "market",
"status": "accepted",
"filled_qty": "0",
"time_in_force": "day"
}"#;
let order: Order = serde_json::from_str(json).unwrap();
assert!(order.qty.is_none());
assert_eq!(order.notional.as_deref(), Some("500"));
assert!(order.limit_price.is_none());
assert!(order.filled_avg_price.is_none());
}
#[test]
fn order_filled_avg_price_deserializes() {
let json = r#"{
"id": "xyz",
"symbol": "TSLA",
"side": "buy",
"qty": "5",
"order_type": "market",
"status": "filled",
"filled_qty": "5",
"filled_avg_price": "312.45",
"time_in_force": "day"
}"#;
let order: Order = serde_json::from_str(json).unwrap();
assert_eq!(order.filled_avg_price.as_deref(), Some("312.45"));
assert_eq!(order.filled_qty, "5");
}
#[test]
fn watchlist_empty_assets_default() {
let json = r#"{"id": "wl1", "name": "Test"}"#;
let wl: Watchlist = serde_json::from_str(json).unwrap();
assert!(wl.assets.is_empty());
}
#[test]
fn order_request_serializes_type_field() {
let req = OrderRequest {
symbol: "AAPL".into(),
qty: Some("10".into()),
notional: None,
side: "buy".into(),
order_type: "limit".into(),
time_in_force: "day".into(),
limit_price: Some("185.00".into()),
};
let json = serde_json::to_string(&req).unwrap();
assert!(
json.contains("\"type\""),
"body should use 'type' key: {json}"
);
assert!(
!json.contains("\"order_type\""),
"body must not use 'order_type': {json}"
);
}
#[test]
fn order_request_omits_none_fields() {
let req = OrderRequest {
symbol: "TSLA".into(),
qty: None,
notional: Some("1000".into()),
side: "buy".into(),
order_type: "market".into(),
time_in_force: "day".into(),
limit_price: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("\"qty\""), "qty should be omitted: {json}");
assert!(
!json.contains("\"limit_price\""),
"limit_price should be omitted: {json}"
);
assert!(
json.contains("\"notional\""),
"notional should be present: {json}"
);
}
fn make_clock(
is_open: bool,
timestamp: &str,
next_open: &str,
next_close: &str,
) -> MarketClock {
MarketClock {
is_open,
timestamp: timestamp.into(),
next_open: next_open.into(),
next_close: next_close.into(),
}
}
#[test]
fn market_state_open() {
let clock = make_clock(
true,
"2026-05-12T15:00:00Z",
"2026-05-13T13:30:00Z",
"2026-05-12T20:00:00Z",
);
assert_eq!(clock.market_state(), MarketState::Open);
}
#[test]
fn market_state_pre_market() {
let next_open = "2026-05-13T13:30:00Z"; let now = "2026-05-13T11:30:00Z"; let clock = make_clock(false, now, next_open, "2026-05-13T20:00:00Z");
assert_eq!(clock.market_state(), MarketState::PreMarket);
}
#[test]
fn market_state_pre_market_boundary() {
let next_open = "2026-05-13T13:30:00Z";
let now = "2026-05-13T09:30:00Z"; let clock = make_clock(false, now, next_open, "2026-05-13T20:00:00Z");
assert_eq!(clock.market_state(), MarketState::PreMarket);
}
#[test]
fn market_state_after_hours() {
let next_open = "2026-05-13T13:30:00Z"; let now = "2026-05-12T21:00:00Z"; let clock = make_clock(false, now, next_open, "2026-05-13T20:00:00Z");
assert_eq!(clock.market_state(), MarketState::AfterHours);
}
#[test]
fn market_state_closed_weekend() {
let next_open = "2026-05-18T13:30:00Z"; let now = "2026-05-16T00:00:00Z"; let clock = make_clock(false, now, next_open, "2026-05-18T20:00:00Z");
assert_eq!(clock.market_state(), MarketState::Closed);
}
#[test]
fn market_state_closed_overnight() {
let next_open = "2026-05-13T13:30:00Z";
let now = "2026-05-12T15:30:00Z"; let clock = make_clock(false, now, next_open, "2026-05-13T20:00:00Z");
assert_eq!(clock.market_state(), MarketState::Closed);
}
#[test]
fn market_state_invalid_timestamp_falls_back_to_closed() {
let clock = make_clock(false, "not-a-date", "also-bad", "2026-05-13T20:00:00Z");
assert_eq!(clock.market_state(), MarketState::Closed);
}
#[test]
fn market_state_as_str_values() {
assert_eq!(MarketState::Open.as_str(), "OPEN");
assert_eq!(MarketState::PreMarket.as_str(), "PRE-MARKET");
assert_eq!(MarketState::AfterHours.as_str(), "AFTER-HOURS");
assert_eq!(MarketState::Closed.as_str(), "CLOSED");
}
#[test]
fn snapshot_deserializes_full() {
let json = r#"{
"latestTrade": { "p": 176.0 },
"latestQuote": { "ap": 176.1, "bp": 175.9 },
"dailyBar": { "c": 175.5, "v": 1234567.0 },
"prevDailyBar": { "c": 170.0, "v": 987654.0 }
}"#;
let snap: Snapshot = serde_json::from_str(json).unwrap();
let lt = snap.latest_trade.expect("latestTrade expected");
assert!((lt.p - 176.0).abs() < 0.01);
let lq = snap.latest_quote.expect("latestQuote expected");
assert_eq!(lq.ap, Some(176.1));
assert_eq!(lq.bp, Some(175.9));
let daily = snap.daily_bar.expect("dailyBar expected");
assert!((daily.c - 175.5).abs() < 0.01);
assert!((daily.v - 1_234_567.0).abs() < 1.0);
let prev = snap.prev_daily_bar.expect("prevDailyBar expected");
assert!((prev.c - 170.0).abs() < 0.01);
}
#[test]
fn snapshot_deserializes_missing_bars() {
let json = r#"{}"#;
let snap: Snapshot = serde_json::from_str(json).unwrap();
assert!(snap.latest_trade.is_none());
assert!(snap.latest_quote.is_none());
assert!(snap.daily_bar.is_none());
assert!(snap.prev_daily_bar.is_none());
}
#[test]
fn snapshot_deserializes_trade_only_no_quote() {
let json = r#"{ "latestTrade": { "p": 150.25 } }"#;
let snap: Snapshot = serde_json::from_str(json).unwrap();
let lt = snap.latest_trade.expect("latestTrade expected");
assert!((lt.p - 150.25).abs() < 0.001);
assert!(snap.latest_quote.is_none());
}
#[test]
fn snapshot_map_deserializes() {
use std::collections::HashMap;
let json = r#"{
"AAPL": {
"latestTrade": { "p": 201.0 },
"latestQuote": { "ap": 201.1, "bp": 200.9 },
"dailyBar": { "c": 200.0, "v": 5000000.0 },
"prevDailyBar": { "c": 195.0, "v": 4500000.0 }
},
"TSLA": {}
}"#;
let map: HashMap<String, Snapshot> = serde_json::from_str(json).unwrap();
assert_eq!(map.len(), 2);
assert!(map["AAPL"].latest_trade.is_some());
assert!(map["AAPL"].daily_bar.is_some());
assert!(map["TSLA"].latest_trade.is_none());
assert!(map["TSLA"].daily_bar.is_none());
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct AccountInfo {
pub status: String,
pub equity: String,
#[serde(default)]
pub last_equity: String,
pub buying_power: String,
pub cash: String,
pub long_market_value: String,
pub daytrade_count: u32,
pub pattern_day_trader: bool,
pub currency: String,
#[serde(default)]
pub portfolio_value: Option<String>,
#[serde(default)]
pub account_number: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Position {
pub symbol: String,
pub qty: String,
pub avg_entry_price: String,
pub current_price: String,
pub market_value: String,
pub unrealized_pl: String,
pub unrealized_plpc: String,
pub side: String,
#[serde(default)]
pub asset_class: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Order {
pub id: String,
pub symbol: String,
pub side: String,
#[serde(default)]
pub qty: Option<String>,
#[serde(default)]
pub notional: Option<String>,
pub order_type: String,
#[serde(default)]
pub limit_price: Option<String>,
pub status: String,
#[serde(default)]
pub submitted_at: Option<String>,
#[serde(default)]
pub filled_at: Option<String>,
pub filled_qty: String,
#[serde(default)]
pub filled_avg_price: Option<String>,
pub time_in_force: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum OrderSide {
Buy,
Sell,
}
impl OrderSide {
pub fn as_str(&self) -> &'static str {
match self {
OrderSide::Buy => "buy",
OrderSide::Sell => "sell",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum OrderType {
Market,
Limit,
}
impl OrderType {
pub fn as_str(&self) -> &'static str {
match self {
OrderType::Market => "market",
OrderType::Limit => "limit",
}
}
}
#[derive(Debug, Clone)]
pub enum TimeInForce {
Day,
Gtc,
}
impl TimeInForce {
pub fn as_str(&self) -> &'static str {
match self {
TimeInForce::Day => "day",
TimeInForce::Gtc => "gtc",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct OrderRequest {
pub symbol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub qty: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notional: Option<String>,
pub side: String,
#[serde(rename = "type")]
pub order_type: String,
pub time_in_force: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit_price: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct MarketClock {
pub is_open: bool,
pub next_open: String,
pub next_close: String,
pub timestamp: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarketState {
Open,
PreMarket,
AfterHours,
Closed,
}
impl MarketState {
pub fn as_str(&self) -> &'static str {
match self {
MarketState::Open => "OPEN",
MarketState::PreMarket => "PRE-MARKET",
MarketState::AfterHours => "AFTER-HOURS",
MarketState::Closed => "CLOSED",
}
}
}
impl MarketClock {
pub fn market_state(&self) -> MarketState {
if self.is_open {
return MarketState::Open;
}
let now = DateTime::parse_from_rfc3339(&self.timestamp).ok();
let next_open = DateTime::parse_from_rfc3339(&self.next_open).ok();
match (now, next_open) {
(Some(now), Some(open)) => {
let secs = (open - now).num_seconds();
if secs <= 0 {
MarketState::Closed
} else if secs <= 4 * 3600 {
MarketState::PreMarket
} else if secs <= 20 * 3600 {
MarketState::AfterHours
} else {
MarketState::Closed
}
}
_ => MarketState::Closed,
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Quote {
pub symbol: String,
#[serde(default)]
pub ap: Option<f64>,
#[serde(default)]
pub bp: Option<f64>,
#[serde(default)]
pub as_: Option<u64>,
#[serde(default)]
pub bs: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WatchlistSummary {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Watchlist {
pub id: String,
pub name: String,
#[serde(default)]
pub assets: Vec<Asset>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Asset {
pub id: String,
pub symbol: String,
pub name: String,
pub exchange: String,
#[serde(rename = "class")]
pub asset_class: String,
pub tradable: bool,
pub shortable: bool,
pub fractionable: bool,
#[serde(default)]
pub easy_to_borrow: bool,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SnapshotBar {
#[serde(default)]
pub o: f64,
#[serde(default)]
pub h: f64,
#[serde(default)]
pub l: f64,
pub c: f64,
pub v: f64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MinuteBar {
pub c: f64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BarsResponse {
pub bars: Vec<MinuteBar>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SnapshotTrade {
pub p: f64,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SnapshotQuote {
#[serde(default)]
pub ap: Option<f64>,
#[serde(default)]
pub bp: Option<f64>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Snapshot {
#[serde(rename = "latestTrade", default)]
pub latest_trade: Option<SnapshotTrade>,
#[serde(rename = "latestQuote", default)]
pub latest_quote: Option<SnapshotQuote>,
#[serde(rename = "dailyBar", default)]
pub daily_bar: Option<SnapshotBar>,
#[serde(rename = "prevDailyBar", default)]
pub prev_daily_bar: Option<SnapshotBar>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PortfolioHistory {
pub equity: Vec<Option<f64>>,
}