use std::collections::HashMap;
use chrono::{DateTime, FixedOffset, NaiveDate};
use pretty_simple_display::{DebugPretty, DisplaySimple};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::api::accounts::AccountNumber;
use crate::types::instrument::InstrumentType;
use crate::types::order::{Action, OrderType, PriceEffect, TimeInForce};
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginRequirementsReport {
#[serde(default)]
pub account_number: Option<AccountNumber>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub margin_calculation_type: Option<String>,
#[serde(default)]
pub option_level: Option<String>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub margin_requirement: Option<Decimal>,
#[serde(default)]
pub margin_requirement_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub maintenance_requirement: Option<Decimal>,
#[serde(default)]
pub maintenance_requirement_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub margin_equity: Option<Decimal>,
#[serde(default)]
pub margin_equity_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub option_buying_power: Option<Decimal>,
#[serde(default)]
pub option_buying_power_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub reg_t_margin_requirement: Option<Decimal>,
#[serde(default)]
pub reg_t_margin_requirement_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub reg_t_option_buying_power: Option<Decimal>,
#[serde(default)]
pub reg_t_option_buying_power_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub maintenance_excess: Option<Decimal>,
#[serde(default)]
pub maintenance_excess_effect: Option<PriceEffect>,
#[serde(default)]
pub groups: Vec<MarginGroup>,
#[serde(default)]
pub last_state_timestamp: Option<i64>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginGroup {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub underlying_symbol: Option<String>,
#[serde(default)]
pub underlying_type: Option<String>,
#[serde(default)]
pub margin_calculation_type: Option<String>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub margin_requirement: Option<Decimal>,
#[serde(default)]
pub margin_requirement_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub maintenance_requirement: Option<Decimal>,
#[serde(default)]
pub maintenance_requirement_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub buying_power: Option<Decimal>,
#[serde(default)]
pub buying_power_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub price_increase_percent: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub price_decrease_percent: Option<Decimal>,
#[serde(default)]
pub groups: Vec<MarginStrategyGroup>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginStrategyGroup {
#[serde(default)]
pub description: Option<String>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub margin_requirement: Option<Decimal>,
#[serde(default)]
pub margin_requirement_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub maintenance_requirement: Option<Decimal>,
#[serde(default)]
pub maintenance_requirement_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub buying_power: Option<Decimal>,
#[serde(default)]
pub buying_power_effect: Option<PriceEffect>,
#[serde(default)]
pub includes_working_order: Option<bool>,
#[serde(default)]
pub position_entries: Vec<MarginPositionEntry>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginPositionEntry {
#[serde(default)]
pub instrument_symbol: Option<String>,
#[serde(default)]
pub instrument_type: Option<InstrumentType>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub quantity: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub close_price: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option_nan")]
pub fixing_price: Option<Decimal>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginEstimate {
#[serde(default)]
pub base_results: Option<MarginImpact>,
#[serde(default)]
pub new_order_results: Option<MarginImpact>,
#[serde(default)]
pub last_results: Option<MarginImpact>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub change_in_margin_requirement: Option<Decimal>,
#[serde(default)]
pub change_in_margin_requirement_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub change_in_buying_power: Option<Decimal>,
#[serde(default)]
pub change_in_buying_power_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub current_buying_power: Option<Decimal>,
#[serde(default)]
pub current_buying_power_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub new_buying_power: Option<Decimal>,
#[serde(default)]
pub new_buying_power_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub isolated_order_margin_requirement: Option<Decimal>,
#[serde(default)]
pub isolated_order_margin_requirement_effect: Option<PriceEffect>,
#[serde(default)]
pub is_spread: Option<bool>,
#[serde(default)]
pub orders: Vec<MarginDryRunOrder>,
#[serde(default)]
pub order_ids: Vec<String>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginImpact {
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub entity_count: Option<i64>,
#[serde(default)]
pub underlying_symbol: Option<String>,
#[serde(default)]
pub underlying_type: Option<String>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub underlying_price: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub margin_requirement: Option<Decimal>,
#[serde(default)]
pub margin_requirement_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub maintenance_requirement: Option<Decimal>,
#[serde(default)]
pub maintenance_requirement_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub buying_power_impact: Option<Decimal>,
#[serde(default)]
pub buying_power_impact_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub base_margin_requirement: Option<Decimal>,
#[serde(default)]
pub base_margin_requirement_effect: Option<PriceEffect>,
#[serde(default)]
pub margin_calculation_type: Option<String>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub cash_adjustment: Option<Decimal>,
#[serde(default)]
pub cash_adjustment_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub working_margin: Option<Decimal>,
#[serde(default)]
pub working_margin_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub position_margin: Option<Decimal>,
#[serde(default)]
pub position_margin_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub position_equity_adjustment: Option<Decimal>,
#[serde(default)]
pub position_equity_adjustment_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub cash_settlement_value: Option<Decimal>,
#[serde(default)]
pub cash_settlement_value_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub long_equity_value: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub short_equity_value: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub long_derivative_value: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub short_derivative_value: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub long_cryptocurrency_value: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub short_cryptocurrency_value: Option<Decimal>,
#[serde(default)]
pub group_results: Vec<MarginGroupResult>,
#[serde(default)]
pub order_ids: Vec<String>,
#[serde(default, with = "crate::types::wire::datetime_option")]
pub calculated_at: Option<DateTime<FixedOffset>>,
#[serde(default, with = "crate::types::wire::decimal_map")]
pub marks: Option<HashMap<String, Decimal>>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginGroupResult {
#[serde(default)]
pub group: Option<MarginStrategy>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub margin: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub maintenance_requirement: Option<Decimal>,
#[serde(default)]
pub maintenance_requirement_effect: Option<PriceEffect>,
#[serde(default)]
pub net_liq_result: Option<NetLiquidatingValues>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginStrategy {
#[serde(default)]
pub margin_strategy: Option<String>,
#[serde(default)]
pub underlying_entry: Option<MarginPositionEntry>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct NetLiquidatingValues {
#[serde(default, with = "crate::types::wire::decimal_option")]
pub long_equity_value: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub short_equity_value: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub long_derivative_value: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub short_derivative_value: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub long_cryptocurrency_value: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub short_cryptocurrency_value: Option<Decimal>,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginDryRunOrder {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub account_number: Option<AccountNumber>,
#[serde(default)]
pub time_in_force: Option<String>,
#[serde(default)]
pub order_type: Option<String>,
#[serde(default)]
pub underlying_symbol: Option<String>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub price: Option<Decimal>,
#[serde(default)]
pub price_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub value: Option<Decimal>,
#[serde(default)]
pub value_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub size: Option<Decimal>,
#[serde(default)]
pub cancellable: Option<bool>,
#[serde(default)]
pub editable: Option<bool>,
#[serde(default)]
pub legs: Vec<MarginDryRunLeg>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginDryRunLeg {
#[serde(default)]
pub symbol: Option<String>,
#[serde(default)]
pub instrument_type: Option<InstrumentType>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub quantity: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub remaining_quantity: Option<Decimal>,
#[serde(default)]
pub action: Option<String>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct EffectiveMarginRequirement {
#[serde(default)]
pub underlying_symbol: Option<String>,
#[serde(default)]
pub clearing_identifier: Option<String>,
#[serde(default)]
pub is_deleted: Option<bool>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub long_equity_initial: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub long_equity_maintenance: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub short_equity_initial: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub short_equity_maintenance: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub naked_option_floor: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub naked_option_minimum: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub naked_option_standard: Option<Decimal>,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct PositionLimit {
#[serde(default)]
pub id: Option<i64>,
#[serde(default)]
pub account_number: Option<AccountNumber>,
#[serde(default)]
pub equity_order_size: Option<i64>,
#[serde(default)]
pub equity_position_size: Option<i64>,
#[serde(default)]
pub equity_option_order_size: Option<i64>,
#[serde(default)]
pub equity_option_position_size: Option<i64>,
#[serde(default)]
pub future_order_size: Option<i64>,
#[serde(default)]
pub future_position_size: Option<i64>,
#[serde(default)]
pub future_option_order_size: Option<i64>,
#[serde(default)]
pub future_option_position_size: Option<i64>,
#[serde(default)]
pub event_contract_notional_order_limit: Option<i64>,
#[serde(default)]
pub event_contract_total_notional_position_value: Option<i64>,
#[serde(default)]
pub underlying_opening_order_limit: Option<i64>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginConfiguration {
#[serde(default, with = "crate::types::wire::decimal_option")]
pub risk_free_rate: Option<Decimal>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct SpanRow {
#[serde(default)]
pub exchange: Option<String>,
#[serde(default, with = "crate::types::wire::date_option")]
pub file_date: Option<NaiveDate>,
#[serde(default)]
pub row_index: Option<i64>,
#[serde(default)]
pub row_data: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpanExchange {
Cme,
Cfe,
}
impl SpanExchange {
pub fn as_wire(&self) -> &'static str {
match self {
SpanExchange::Cme => "CME",
SpanExchange::Cfe => "CFE",
}
}
}
impl std::fmt::Display for SpanExchange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_wire())
}
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct MarginOrderRequest {
pub account_number: AccountNumber,
pub underlying_symbol: String,
pub underlying_instrument_type: InstrumentType,
pub time_in_force: TimeInForce,
pub order_type: OrderType,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, with = "crate::types::wire::decimal_option")]
pub price: Option<Decimal>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub price_effect: Option<PriceEffect>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, with = "crate::types::wire::date_option")]
pub gtc_date: Option<NaiveDate>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, with = "crate::types::wire::decimal_option")]
pub stop_trigger: Option<Decimal>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub replaces_order_id: Option<String>,
pub legs: Vec<MarginOrderLeg>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub struct MarginOrderLeg {
pub symbol: String,
pub instrument_type: InstrumentType,
#[serde(with = "crate::types::wire::decimal")]
pub quantity: Decimal,
pub action: Action,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, with = "crate::types::wire::decimal_option")]
pub remaining_quantity: Option<Decimal>,
}
pub const MAX_MARGIN_LEGS: usize = 4;
impl MarginOrderRequest {
pub fn new(
account_number: impl Into<AccountNumber>,
underlying_symbol: impl Into<String>,
underlying_instrument_type: InstrumentType,
order_type: OrderType,
time_in_force: TimeInForce,
legs: Vec<MarginOrderLeg>,
) -> Self {
Self {
account_number: account_number.into(),
underlying_symbol: underlying_symbol.into(),
underlying_instrument_type,
time_in_force,
order_type,
price: None,
price_effect: None,
gtc_date: None,
stop_trigger: None,
replaces_order_id: None,
legs,
}
}
#[must_use]
pub fn with_price(mut self, price: Decimal, effect: PriceEffect) -> Self {
self.price = Some(price);
self.price_effect = Some(effect);
self
}
#[must_use]
pub fn with_gtc_date(mut self, gtc_date: NaiveDate) -> Self {
self.gtc_date = Some(gtc_date);
self
}
#[must_use]
pub fn with_stop_trigger(mut self, stop_trigger: Decimal) -> Self {
self.stop_trigger = Some(stop_trigger);
self
}
#[must_use]
pub fn replacing(mut self, order_id: impl Into<String>) -> Self {
self.replaces_order_id = Some(order_id.into());
self
}
pub(crate) fn validate(&self, account_number: &str) -> crate::TastyResult<()> {
if self.account_number.0 != account_number {
return Err(crate::TastyTradeError::Precondition(
"the margin request names a different account from the one it is being \
sent to; build the request from this account"
.to_string(),
));
}
if self.underlying_symbol.trim().is_empty() {
return Err(crate::TastyTradeError::Precondition(
"a margin estimate needs an underlying symbol, and this one is blank".to_string(),
));
}
if self.legs.is_empty() || self.legs.len() > MAX_MARGIN_LEGS {
return Err(crate::TastyTradeError::Precondition(format!(
"a margin estimate takes one to {MAX_MARGIN_LEGS} legs, and this one has {}",
self.legs.len()
)));
}
for (index, leg) in self.legs.iter().enumerate() {
if leg.symbol.trim().is_empty() {
return Err(crate::TastyTradeError::Precondition(format!(
"leg {index} has a blank symbol"
)));
}
if self.legs[..index].contains(leg) {
return Err(crate::TastyTradeError::Precondition(format!(
"leg {index} duplicates an earlier one; the venue takes unique legs, and \
a repeated leg doubles the requirement it reports"
)));
}
if let Some(remaining) = leg.remaining_quantity
&& (remaining <= Decimal::ZERO || remaining > leg.quantity)
{
return Err(crate::TastyTradeError::Precondition(format!(
"leg {index} says {remaining} of {} is still working, which is not a \
part of it; a replacement reserves margin for what is left",
leg.quantity
)));
}
}
self.validate_price()?;
Ok(())
}
fn validate_price(&self) -> crate::TastyResult<()> {
let (needs_price, needs_trigger) = match self.order_type {
OrderType::Limit | OrderType::MarketableLimit => (true, false),
OrderType::StopLimit => (true, true),
OrderType::Stop => (false, true),
OrderType::NotionalMarket => (true, false),
OrderType::Market => (false, false),
};
if let Some(price) = self.price {
if needs_price && price <= Decimal::ZERO {
return Err(crate::TastyTradeError::Precondition(format!(
"a {:?} order needs a price greater than zero, got {price}",
self.order_type
)));
}
if !needs_price && price != Decimal::ZERO {
return Err(crate::TastyTradeError::Precondition(format!(
"a {:?} order carries no price, so price must be zero, got {price}",
self.order_type
)));
}
} else if needs_price {
return Err(crate::TastyTradeError::Precondition(format!(
"a {:?} order needs a price, and this estimate has none",
self.order_type
)));
}
match (needs_trigger, self.stop_trigger) {
(true, None) => {
return Err(crate::TastyTradeError::Precondition(format!(
"a {:?} order is defined by its trigger, and this estimate has none",
self.order_type
)));
}
(true, Some(trigger)) if trigger <= Decimal::ZERO => {
return Err(crate::TastyTradeError::Precondition(format!(
"a stop trigger of {trigger} either never fires or fires immediately"
)));
}
(false, Some(trigger)) => {
return Err(crate::TastyTradeError::Precondition(format!(
"a {:?} order has no trigger, and this estimate sets one ({trigger})",
self.order_type
)));
}
_ => {}
}
if matches!(self.time_in_force, TimeInForce::Gtd) && self.gtc_date.is_none() {
return Err(crate::TastyTradeError::Precondition(
"a GTD order expires on a day, and this estimate does not say which".to_string(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::prelude::FromPrimitive;
const REQUIREMENTS: &str = include_str!("../../Doc/margin_requirements.json");
const DRY_RUN: &str = include_str!("../../Doc/margin_dry_run.json");
fn leg(symbol: &str) -> MarginOrderLeg {
MarginOrderLeg {
symbol: symbol.to_string(),
instrument_type: InstrumentType::Equity,
quantity: Decimal::from(1),
action: Action::BuyToOpen,
remaining_quantity: None,
}
}
fn request(legs: Vec<MarginOrderLeg>) -> MarginOrderRequest {
MarginOrderRequest::new(
"SENTINEL-5WX00042",
"AAPL",
InstrumentType::Equity,
OrderType::Limit,
TimeInForce::Day,
legs,
)
.with_price(Decimal::from(100), PriceEffect::Debit)
}
#[test]
fn the_requirements_report_decodes_with_its_nesting_intact() {
let body: serde_json::Value = serde_json::from_str(REQUIREMENTS).expect("valid JSON");
let report: MarginRequirementsReport =
serde_json::from_value(body["data"].clone()).expect("the report must decode");
assert_eq!(report.description.as_deref(), Some("Total"));
assert_eq!(
report
.margin_requirement
.expect("a requirement")
.to_string(),
"4337.831401355",
"every digit the venue sent must survive"
);
assert_eq!(report.groups.len(), 2);
let apple = &report.groups[0];
assert_eq!(apple.underlying_symbol.as_deref(), Some("AAPL"));
assert!(!apple.groups.is_empty());
let strategy = &apple.groups[0];
assert_eq!(strategy.description.as_deref(), Some("LONG_UNDERLYING"));
assert_eq!(strategy.position_entries.len(), 1);
assert_eq!(
strategy.position_entries[0].instrument_symbol.as_deref(),
Some("AAPL")
);
}
#[test]
fn a_nan_fixing_price_decodes_as_absent_rather_than_failing() {
let body: serde_json::Value = serde_json::from_str(REQUIREMENTS).expect("valid JSON");
let report: MarginRequirementsReport =
serde_json::from_value(body["data"].clone()).expect("the report must decode");
let entry = &report.groups[0].groups[0].position_entries[0];
assert_eq!(entry.fixing_price, None);
assert_eq!(entry.close_price.expect("a close price").to_string(), "0.0");
}
#[test]
fn only_nan_is_forgiven() {
let ok: MarginPositionEntry =
serde_json::from_str(r#"{"fixing-price": "NaN"}"#).expect("NaN is absent");
assert_eq!(ok.fixing_price, None);
assert!(
serde_json::from_str::<MarginPositionEntry>(r#"{"fixing-price": "banana"}"#).is_err(),
"garbage must not silently become an absent price"
);
}
#[test]
fn the_dry_run_result_decodes_including_the_marks() {
let body: serde_json::Value = serde_json::from_str(DRY_RUN).expect("valid JSON");
let estimate: MarginEstimate =
serde_json::from_value(body["data"].clone()).expect("the estimate must decode");
let new = estimate
.new_order_results
.as_ref()
.expect("a new-order result");
assert_eq!(
new.buying_power_impact.expect("an impact").to_string(),
"22094.668598645"
);
let marks = new.marks.as_ref().expect("marks");
assert_eq!(marks.get("AAPL").expect("a mark").to_string(), "196.03");
assert_eq!(estimate.is_spread, Some(false));
assert_eq!(estimate.orders.len(), 1);
assert_eq!(estimate.orders[0].id.as_deref(), Some("dry-run-0"));
assert_eq!(estimate.orders[0].legs.len(), 1);
}
#[test]
fn a_request_for_another_account_is_refused_locally() {
let error = request(vec![leg("AAPL")])
.validate("SOMEONE-ELSE")
.expect_err("the body and the path must name the same account");
assert!(matches!(error, crate::TastyTradeError::Precondition(_)));
assert!(!error.is_retryable());
}
#[test]
fn zero_and_five_legs_are_both_refused() {
for legs in [
vec![],
vec![leg("A"), leg("B"), leg("C"), leg("D"), leg("E")],
] {
let error = request(legs)
.validate("SENTINEL-5WX00042")
.expect_err("the venue takes one to four legs");
assert!(format!("{error}").contains("one to 4 legs"), "{error}");
}
}
#[test]
fn duplicate_legs_are_refused() {
let error = request(vec![leg("AAPL"), leg("AAPL")])
.validate("SENTINEL-5WX00042")
.expect_err("duplicate legs must be refused");
assert!(format!("{error}").contains("duplicates"), "{error}");
}
#[test]
fn a_blank_symbol_is_refused() {
assert!(
request(vec![leg(" ")])
.validate("SENTINEL-5WX00042")
.is_err()
);
let mut blank_underlying = request(vec![leg("AAPL")]);
blank_underlying.underlying_symbol = " ".to_string();
assert!(blank_underlying.validate("SENTINEL-5WX00042").is_err());
}
#[test]
fn one_to_four_distinct_legs_are_accepted() {
for count in 1..=MAX_MARGIN_LEGS {
let legs: Vec<_> = (0..count).map(|i| leg(&format!("SYM{i}"))).collect();
assert!(request(legs).validate("SENTINEL-5WX00042").is_ok());
}
}
#[test]
fn the_request_serialises_the_fields_the_venue_requires() {
let body = serde_json::to_value(request(vec![leg("AAPL")]).with_price(
Decimal::from_f64(186.99).expect("a price"),
PriceEffect::Debit,
))
.expect("the request serialises");
assert_eq!(body["account-number"], "SENTINEL-5WX00042");
assert_eq!(body["underlying-symbol"], "AAPL");
assert_eq!(body["underlying-instrument-type"], "Equity");
assert_eq!(body["legs"][0]["action"], "Buy to Open");
assert_eq!(body["price-effect"], "Debit");
}
#[test]
fn an_absent_price_is_omitted_from_the_body() {
let body = serde_json::to_value(MarginOrderRequest::new(
"SENTINEL-5WX00042",
"AAPL",
InstrumentType::Equity,
OrderType::Market,
TimeInForce::Day,
vec![leg("AAPL")],
))
.expect("serialises");
assert!(body.get("price").is_none(), "{body}");
assert!(body.get("price-effect").is_none(), "{body}");
assert!(body.get("stop-trigger").is_none(), "{body}");
assert!(body.get("gtc-date").is_none(), "{body}");
assert!(body.get("replaces-order-id").is_none(), "{body}");
assert!(
body["legs"][0].get("remaining-quantity").is_none(),
"{body}"
);
}
#[test]
fn a_dated_stop_or_replacement_can_be_described_and_is_checked() {
let day = NaiveDate::from_ymd_opt(2026, 12, 18).expect("a real day");
let base = || {
MarginOrderRequest::new(
"SENTINEL-5WX00042",
"AAPL",
InstrumentType::Equity,
OrderType::Stop,
TimeInForce::Gtd,
vec![leg("AAPL")],
)
};
let complete = base()
.with_stop_trigger(Decimal::from(180))
.with_gtc_date(day)
.replacing("order-991");
complete
.validate("SENTINEL-5WX00042")
.expect("a fully described stop must be accepted");
let body = serde_json::to_value(&complete).expect("must serialize");
assert_eq!(body["stop-trigger"], serde_json::json!(180));
assert_eq!(body["gtc-date"], serde_json::json!("2026-12-18"));
assert_eq!(body["replaces-order-id"], serde_json::json!("order-991"));
assert!(
base()
.with_gtc_date(day)
.validate("SENTINEL-5WX00042")
.is_err()
);
assert!(
base()
.with_stop_trigger(Decimal::from(180))
.validate("SENTINEL-5WX00042")
.is_err()
);
assert!(
request(vec![leg("AAPL")])
.with_stop_trigger(Decimal::from(180))
.validate("SENTINEL-5WX00042")
.is_err()
);
}
#[test]
fn a_remaining_quantity_has_to_be_part_of_the_leg() {
let with_remaining = |remaining: Decimal| {
let mut only = leg("AAPL");
only.quantity = Decimal::from(10);
only.remaining_quantity = Some(remaining);
request(vec![only])
};
with_remaining(Decimal::from(4))
.validate("SENTINEL-5WX00042")
.expect("four of ten is a part of it");
assert!(
with_remaining(Decimal::from(11))
.validate("SENTINEL-5WX00042")
.is_err()
);
assert!(
with_remaining(Decimal::ZERO)
.validate("SENTINEL-5WX00042")
.is_err()
);
}
#[test]
fn the_margin_records_render_without_the_account_number() {
const ACCOUNT: &str = "SENTINEL-5WX00042";
let body: serde_json::Value = serde_json::from_str(REQUIREMENTS).expect("valid JSON");
let mut report: MarginRequirementsReport =
serde_json::from_value(body["data"].clone()).expect("the report must decode");
report.account_number = Some(AccountNumber(ACCOUNT.to_string()));
let outgoing = request(vec![leg("AAPL")]);
let rendered = format!("{report:?} {report} {outgoing:?} {outgoing}");
assert!(!rendered.contains(ACCOUNT), "rendered: {rendered}");
assert!(rendered.contains("{account}"), "{rendered}");
assert!(rendered.contains("AAPL"), "{rendered}");
let written = serde_json::to_string(&outgoing).expect("must serialize");
assert!(written.contains(ACCOUNT));
}
#[test]
fn the_span_exchange_only_spells_what_the_contract_admits() {
assert_eq!(SpanExchange::Cme.as_wire(), "CME");
assert_eq!(SpanExchange::Cfe.as_wire(), "CFE");
assert_eq!(SpanExchange::Cme.to_string(), "CME");
}
}
crate::types::wire::redacted_account_render!(MarginRequirementsReport);
crate::types::wire::redacted_account_render!(MarginDryRunOrder);
crate::types::wire::redacted_account_render!(PositionLimit);
crate::types::wire::redacted_account_render!(MarginOrderRequest);