use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{Error, Result};
use crate::types::wire::wire_enum;
wire_enum! {
pub enum LocateStatus {
Active => "active",
Expired => "expired",
Rejected => "rejected",
}
}
wire_enum! {
pub enum LocateQuoteError {
SymbolNotFound => "symbol_not_found",
EasyToBorrow => "easy_to_borrow",
ThresholdSecurity => "threshold_security",
CorporateAction => "corporate_action",
QuoteUnavailable => "quote_unavailable",
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Locate {
pub id: Uuid,
pub symbol: String,
pub status: LocateStatus,
pub requested_qty: i64,
#[serde(default)]
pub located_qty: Option<i64>,
#[serde(default, with = "crate::types::option_decimal")]
pub located_price: Option<Decimal>,
#[serde(default, with = "crate::types::option_decimal")]
pub limit_price: Option<Decimal>,
#[serde(default, with = "crate::types::option_decimal")]
pub total_fee: Option<Decimal>,
#[serde(default)]
pub all_or_none: bool,
#[serde(default)]
pub rejection_reason: Option<String>,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LocatesPage {
#[serde(
default,
deserialize_with = "crate::types::serde_util::null_as_default"
)]
pub locates: Vec<Locate>,
#[serde(default)]
pub next_page_token: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LocateQuote {
pub symbol: String,
pub available_qty: i64,
#[serde(default, with = "crate::types::option_decimal")]
pub price: Option<Decimal>,
pub quoted_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LocateQuoteFailure {
pub symbol: String,
pub code: LocateQuoteError,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LocateQuotes {
#[serde(
default,
deserialize_with = "crate::types::serde_util::null_as_default"
)]
pub quotes: Vec<LocateQuote>,
#[serde(
default,
deserialize_with = "crate::types::serde_util::null_as_default"
)]
pub errors: Vec<LocateQuoteFailure>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GetLocatesRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<LocateStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub symbol: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_token: Option<String>,
}
impl GetLocatesRequest {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn status(mut self, status: LocateStatus) -> Self {
self.status = Some(status);
self
}
#[must_use]
pub fn symbol(mut self, symbol: impl Into<String>) -> Self {
self.symbol = Some(symbol.into());
self
}
pub fn between(mut self, start: NaiveDate, end: NaiveDate) -> crate::Result<Self> {
if end < start {
return Err(crate::Error::InvalidRequest(format!(
"end ({end}) is before start ({start})"
)));
}
self.start = Some(start);
self.end = Some(end);
Ok(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GetLocateQuotesRequest {
#[serde(serialize_with = "crate::types::comma_separated_required")]
pub symbols: Vec<String>,
}
impl GetLocateQuotesRequest {
#[must_use]
pub fn new(symbols: Vec<String>) -> Self {
Self { symbols }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CreateLocateRequest {
pub symbol: String,
pub qty: i64,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::types::option_decimal"
)]
pub limit_price: Option<Decimal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub all_or_none: Option<bool>,
}
impl CreateLocateRequest {
#[must_use]
pub fn new(symbol: impl Into<String>, qty: i64) -> Self {
Self {
symbol: symbol.into(),
qty,
limit_price: None,
all_or_none: None,
}
}
#[must_use]
pub fn limit_price(mut self, limit_price: Decimal) -> Self {
self.limit_price = Some(limit_price);
self
}
#[must_use]
pub fn all_or_none(mut self, all_or_none: bool) -> Self {
self.all_or_none = Some(all_or_none);
self
}
pub fn validate(&self) -> Result<()> {
if self.qty <= 0 {
return Err(Error::InvalidRequest(
"qty must be greater than zero".to_owned(),
));
}
if self
.limit_price
.is_some_and(|price| price.is_sign_negative())
{
return Err(Error::InvalidRequest(
"limit_price cannot be negative".to_owned(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_symbols_render_as_one_parameter() {
let request = GetLocateQuotesRequest::new(vec!["TSLA".to_owned(), "GME".to_owned()]);
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["symbols"], "TSLA,GME");
}
#[test]
fn a_backwards_window_is_refused() {
let start: NaiveDate = "2026-01-10".parse().unwrap();
let end: NaiveDate = "2026-01-01".parse().unwrap();
assert!(GetLocatesRequest::new().between(start, end).is_err());
assert!(GetLocatesRequest::new().between(end, start).is_ok());
}
#[test]
fn a_degenerate_locate_is_refused_before_it_is_sent() {
assert!(CreateLocateRequest::new("TSLA", 0).validate().is_err());
assert!(CreateLocateRequest::new("TSLA", -1).validate().is_err());
assert!(CreateLocateRequest::new("TSLA", 100).validate().is_ok());
}
#[test]
fn a_rejected_locate_omits_every_priced_field() {
let locate: Locate = serde_json::from_value(serde_json::json!({
"id": "550e8400-e29b-41d4-a716-446655440000",
"symbol": "TSLA",
"status": "rejected",
"requested_qty": 100,
"rejection_reason": "inventory_unavailable",
"all_or_none": false,
"created_at": "2026-01-02T15:04:05Z",
}))
.unwrap();
assert_eq!(locate.status, LocateStatus::Rejected);
assert_eq!(locate.located_qty, None);
assert_eq!(locate.located_price, None);
assert_eq!(locate.total_fee, None);
assert_eq!(locate.expires_at, None);
}
}