use chrono::{DateTime, FixedOffset, NaiveDate};
use pretty_simple_display::{DebugPretty, DisplaySimple};
use rust_decimal::Decimal;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use crate::api::query::{PageRequest, QueryBuilder};
use crate::types::instrument::InstrumentType;
use crate::types::order::PriceEffect;
use crate::types::wire::{redacted_account_render, wire_enum};
wire_enum! {
TransactionType {
AdministrativeTransfer => "Administrative Transfer",
MoneyMovement => "Money Movement",
ReceiveDeliver => "Receive Deliver",
Trade => "Trade",
}
}
wire_enum! {
TransactionSubType {
Acat => "ACAT",
Assignment => "Assignment",
BalanceAdjustment => "Balance Adjustment",
CashMerger => "Cash Merger",
CashSettledAssignment => "Cash Settled Assignment",
CashSettledExercise => "Cash Settled Exercise",
CreditInterest => "Credit Interest",
DebitInterest => "Debit Interest",
Deposit => "Deposit",
Dividend => "Dividend",
Exercise => "Exercise",
Expiration => "Expiration",
Fee => "Fee",
ForwardSplit => "Forward Split",
FullyPaidStockLendingIncome => "Fully Paid Stock Lending Income",
FuturesSettlement => "Futures Settlement",
MarkToMarket => "Mark to Market",
Maturity => "Maturity",
ReverseSplit => "Reverse Split",
ReverseSplitRemoval => "Reverse Split Removal",
SpecialDividend => "Special Dividend",
StockMerger => "Stock Merger",
StockMergerRemoval => "Stock Merger Removal",
SymbolChange => "Symbol Change",
Transfer => "Transfer",
Withdrawal => "Withdrawal",
}
}
wire_enum! {
TransactionAction {
Allocate => "Allocate",
Buy => "Buy",
BuyToClose => "Buy to Close",
BuyToOpen => "Buy to Open",
Sell => "Sell",
SellToClose => "Sell to Close",
SellToOpen => "Sell to Open",
}
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct Transaction {
pub id: i64,
#[serde(default)]
pub account_number: Option<crate::api::accounts::AccountNumber>,
#[serde(default)]
pub transaction_type: Option<TransactionType>,
#[serde(default)]
pub transaction_sub_type: Option<TransactionSubType>,
#[serde(default)]
pub action: Option<TransactionAction>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub symbol: Option<String>,
#[serde(default)]
pub underlying_symbol: Option<String>,
#[serde(default, deserialize_with = "crate::types::wire::tolerant_option")]
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 price: Option<Decimal>,
#[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 net_value: Option<Decimal>,
#[serde(default)]
pub net_value_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub commission: Option<Decimal>,
#[serde(default)]
pub commission_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub clearing_fees: Option<Decimal>,
#[serde(default)]
pub clearing_fees_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub regulatory_fees: Option<Decimal>,
#[serde(default)]
pub regulatory_fees_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub proprietary_index_option_fees: Option<Decimal>,
#[serde(default)]
pub proprietary_index_option_fees_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub currency_conversion_fees: Option<Decimal>,
#[serde(default)]
pub currency_conversion_fees_effect: Option<PriceEffect>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub other_charge: Option<Decimal>,
#[serde(default)]
pub other_charge_description: Option<String>,
#[serde(default)]
pub other_charge_effect: Option<PriceEffect>,
#[serde(default)]
pub is_estimated_fee: Option<bool>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub agency_price: Option<Decimal>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub principal_price: Option<Decimal>,
#[serde(default)]
pub currency: Option<String>,
#[serde(default)]
pub destination_venue: Option<String>,
#[serde(default)]
pub exchange: Option<String>,
#[serde(default)]
pub exchange_affiliation_identifier: Option<String>,
#[serde(default)]
pub exec_id: Option<String>,
#[serde(default)]
pub ext_exec_id: Option<String>,
#[serde(default)]
pub ext_exchange_order_number: Option<String>,
#[serde(default)]
pub ext_global_order_number: Option<i64>,
#[serde(default)]
pub ext_group_fill_id: Option<String>,
#[serde(default)]
pub ext_group_id: Option<String>,
#[serde(default)]
pub leg_count: Option<i64>,
#[serde(default)]
pub order_id: Option<i64>,
#[serde(default)]
pub reverses_id: Option<i64>,
#[serde(default)]
pub lots: Option<Value>,
#[serde(default, with = "crate::types::wire::date_option")]
pub transaction_date: Option<NaiveDate>,
#[serde(default, with = "crate::types::wire::date_option")]
pub cost_basis_reconciliation_date: Option<NaiveDate>,
#[serde(default, with = "crate::types::wire::datetime_option")]
pub executed_at: Option<DateTime<FixedOffset>>,
#[serde(default, with = "crate::types::wire::datetime_option")]
pub created_at: Option<DateTime<FixedOffset>>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct TotalFees {
#[serde(default, with = "crate::types::wire::decimal_option")]
pub total_fees: Option<Decimal>,
#[serde(default)]
pub total_fees_effect: Option<PriceEffect>,
}
redacted_account_render!(Transaction);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransactionTypes {
One(TransactionType),
Several(Vec<TransactionType>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TransactionSort {
#[default]
Descending,
Ascending,
}
impl TransactionSort {
pub fn as_wire(&self) -> &'static str {
match self {
Self::Descending => "Desc",
Self::Ascending => "Asc",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TransactionFilter {
page: PageRequest,
types: Option<TransactionTypes>,
sub_types: Vec<TransactionSubType>,
sort: Option<TransactionSort>,
action: Option<TransactionAction>,
instrument_type: Option<InstrumentType>,
currency: Option<String>,
symbol: Option<String>,
underlying_symbol: Option<String>,
futures_symbol: Option<String>,
partition_key: Option<String>,
start_date: Option<NaiveDate>,
end_date: Option<NaiveDate>,
start_at: Option<DateTime<FixedOffset>>,
end_at: Option<DateTime<FixedOffset>>,
}
impl TransactionFilter {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_types(mut self, types: TransactionTypes) -> Self {
self.types = Some(types);
self
}
#[must_use]
pub fn with_sub_types(mut self, sub_types: &[TransactionSubType]) -> Self {
self.sub_types.extend(sub_types.iter().cloned());
self
}
#[must_use]
pub fn with_sort(mut self, sort: TransactionSort) -> Self {
self.sort = Some(sort);
self
}
#[must_use]
pub fn with_action(mut self, action: TransactionAction) -> Self {
self.action = Some(action);
self
}
#[must_use]
pub fn with_instrument_type(mut self, instrument_type: InstrumentType) -> Self {
self.instrument_type = Some(instrument_type);
self
}
#[must_use]
pub fn with_currency(mut self, currency: impl Into<String>) -> Self {
self.currency = Some(currency.into());
self
}
#[must_use]
pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
self.symbol = Some(symbol.into());
self
}
#[must_use]
pub fn with_underlying_symbol(mut self, symbol: impl Into<String>) -> Self {
self.underlying_symbol = Some(symbol.into());
self
}
#[must_use]
pub fn with_futures_symbol(mut self, symbol: impl Into<String>) -> Self {
self.futures_symbol = Some(symbol.into());
self
}
#[must_use]
pub fn with_partition_key(mut self, key: impl Into<String>) -> Self {
self.partition_key = Some(key.into());
self
}
#[must_use]
pub fn with_dates(mut self, start: Option<NaiveDate>, end: Option<NaiveDate>) -> Self {
self.start_date = start;
self.end_date = end;
self
}
#[must_use]
pub fn with_times(
mut self,
start: Option<DateTime<FixedOffset>>,
end: Option<DateTime<FixedOffset>>,
) -> Self {
self.start_at = start;
self.end_at = end;
self
}
#[must_use]
pub fn with_page(mut self, page: PageRequest) -> Self {
self.page = page;
self
}
pub fn page(&self) -> PageRequest {
self.page
}
pub(crate) fn to_query(&self) -> QueryBuilder {
let mut query = QueryBuilder::new();
self.page.write_into(&mut query);
query.push_opt("sort", self.sort.map(|sort| sort.as_wire()));
match &self.types {
Some(TransactionTypes::One(one)) => query.push("type", one.as_wire()),
Some(TransactionTypes::Several(many)) => query.push_each(
"types[]",
many.iter().map(|kind| kind.as_wire().to_string()),
),
None => {}
}
query.push_each(
"sub-type[]",
self.sub_types.iter().map(|sub| sub.as_wire().to_string()),
);
query.push_opt(
"action",
self.action.as_ref().map(TransactionAction::as_wire),
);
query.push_opt(
"instrument-type",
self.instrument_type.as_ref().map(ToString::to_string),
);
query.push_opt("currency", self.currency.as_ref());
query.push_opt("symbol", self.symbol.as_ref());
query.push_opt("underlying-symbol", self.underlying_symbol.as_ref());
query.push_opt("futures-symbol", self.futures_symbol.as_ref());
query.push_opt("partition-key", self.partition_key.as_ref());
query.push_opt("start-date", self.start_date);
query.push_opt("end-date", self.end_date);
query.push_opt("start-at", self.start_at.map(|at| at.to_rfc3339()));
query.push_opt("end-at", self.end_at.map(|at| at.to_rfc3339()));
query
}
}
#[cfg(test)]
mod tests {
use super::*;
const LISTING: &str = include_str!("../../Doc/transactions_listing.json");
#[test]
fn a_dividend_transaction_decodes_from_the_venues_own_payload() {
let body: serde_json::Value = serde_json::from_str(LISTING).expect("valid JSON");
let items = body["data"]["items"]
.as_array()
.expect("the listing carries items");
let first: Transaction =
serde_json::from_value(items[0].clone()).expect("the row must decode");
assert_eq!(first.id, 252640963);
assert_eq!(
first.transaction_type,
Some(TransactionType::ReceiveDeliver)
);
assert_eq!(
first.transaction_sub_type,
Some(TransactionSubType::Dividend)
);
assert_eq!(first.action, Some(TransactionAction::BuyToOpen));
assert_eq!(first.quantity.expect("a quantity").to_string(), "1.68074");
assert_eq!(first.price.expect("a price").to_string(), "16.46");
assert_eq!(first.value_effect, Some(PriceEffect::None));
assert_eq!(first.is_estimated_fee, Some(true));
assert_eq!(first.commission, None);
}
#[test]
fn a_money_movement_row_without_a_quantity_still_decodes() {
let body: serde_json::Value = serde_json::from_str(LISTING).expect("valid JSON");
let items = body["data"]["items"].as_array().expect("items");
let cash: Transaction =
serde_json::from_value(items[2].clone()).expect("the row must decode");
assert_eq!(cash.transaction_type, Some(TransactionType::MoneyMovement));
assert_eq!(cash.quantity, None);
assert_eq!(cash.action, None);
assert_eq!(cash.net_value_effect, Some(PriceEffect::Credit));
}
#[test]
fn an_unrecognised_kind_survives_verbatim() {
let row: Transaction = serde_json::from_str(
r#"{"id": 1, "transaction-type": "Quantum Entanglement",
"transaction-sub-type": "Spooky Action"}"#,
)
.expect("the row must still decode");
assert_eq!(
row.transaction_type,
Some(TransactionType::Unknown("Quantum Entanglement".to_string()))
);
assert!(!row.transaction_sub_type.expect("a sub-type").is_known());
}
#[test]
fn one_kind_and_several_kinds_can_never_be_sent_together() {
let one =
TransactionFilter::new().with_types(TransactionTypes::One(TransactionType::Trade));
assert_eq!(one.to_query().pairs(), vec![("type", "Trade")]);
let several = TransactionFilter::new().with_types(TransactionTypes::Several(vec![
TransactionType::Trade,
TransactionType::MoneyMovement,
]));
assert_eq!(
several.to_query().pairs(),
vec![("types[]", "Trade"), ("types[]", "Money Movement")]
);
}
#[test]
fn sub_types_are_repeated_keys_in_the_venues_spelling() {
let filter = TransactionFilter::new().with_sub_types(&[
TransactionSubType::Dividend,
TransactionSubType::CashSettledAssignment,
]);
assert_eq!(
filter.to_query().pairs(),
vec![
("sub-type[]", "Dividend"),
("sub-type[]", "Cash Settled Assignment"),
]
);
}
#[test]
fn an_unfiltered_listing_sends_nothing() {
assert!(TransactionFilter::new().to_query().pairs().is_empty());
}
#[test]
fn every_documented_filter_is_reachable() {
let day = |y, m, d| NaiveDate::from_ymd_opt(y, m, d).expect("a real date");
let filter = TransactionFilter::new()
.with_page(PageRequest::first().with_per_page(50))
.with_sort(TransactionSort::Ascending)
.with_types(TransactionTypes::One(TransactionType::Trade))
.with_sub_types(&[TransactionSubType::Fee])
.with_action(TransactionAction::SellToClose)
.with_instrument_type(InstrumentType::EquityOption)
.with_currency("USD")
.with_symbol("AAPL")
.with_underlying_symbol("AAPL")
.with_futures_symbol("/ESU9")
.with_partition_key("main")
.with_dates(Some(day(2026, 1, 1)), Some(day(2026, 1, 31)));
let query = filter.to_query();
let pairs = query.pairs();
assert_eq!(pairs[0], ("page-offset", "0"));
assert_eq!(pairs[1], ("per-page", "50"));
assert!(pairs.contains(&("sort", "Asc")));
assert!(pairs.contains(&("type", "Trade")));
assert!(pairs.contains(&("sub-type[]", "Fee")));
assert!(pairs.contains(&("action", "Sell to Close")));
assert!(pairs.contains(&("instrument-type", "Equity Option")));
assert!(pairs.contains(&("currency", "USD")));
assert!(pairs.contains(&("futures-symbol", "/ESU9")));
assert!(pairs.contains(&("partition-key", "main")));
assert!(pairs.contains(&("start-date", "2026-01-01")));
assert!(pairs.contains(&("end-date", "2026-01-31")));
}
#[test]
fn total_fees_carries_its_direction() {
let fees: TotalFees =
serde_json::from_str(r#"{"total-fees": "100.0", "total-fees-effect": "Debit"}"#)
.expect("the payload from the venue's guide must decode");
assert_eq!(fees.total_fees.expect("a total").to_string(), "100.0");
assert_eq!(fees.total_fees_effect, Some(PriceEffect::Debit));
}
#[test]
fn a_transaction_renders_without_its_account_number() {
const ACCOUNT: &str = "SENTINEL-5WX12345";
let row: Transaction = serde_json::from_str(&format!(
r#"{{"id": 12345, "account-number": "{ACCOUNT}", "symbol": "AAPL",
"transaction-type": "Trade", "value": "1234.56"}}"#
))
.expect("the row must decode");
let rendered = format!("{row:?} {row} {}", format_args!("{row:#?}"));
assert!(
!rendered.contains(ACCOUNT),
"the account number reached a rendering: {rendered}"
);
assert!(rendered.contains("{account}"), "{rendered}");
assert!(rendered.contains("AAPL"), "{rendered}");
assert!(rendered.contains("12345"), "{rendered}");
let written = serde_json::to_string(&row).expect("the row must serialize");
assert!(written.contains(ACCOUNT), "serialization lost the number");
}
#[test]
fn an_unmodelled_instrument_type_does_not_lose_the_transaction() {
let row: Transaction = serde_json::from_str(
r#"{"id": 99, "instrument-type": "Prediction Market",
"symbol": "XYZ", "value": "10.00"}"#,
)
.expect("the row must survive a value this crate does not model");
assert_eq!(row.id, 99);
assert_eq!(row.symbol.as_deref(), Some("XYZ"));
assert!(row.instrument_type.is_none());
let known: Transaction =
serde_json::from_str(r#"{"id": 1, "instrument-type": "Equity Option"}"#)
.expect("a modelled value must decode");
assert_eq!(known.instrument_type, Some(InstrumentType::EquityOption));
}
}