use chrono::NaiveDate; use indexmap::IndexMap; use serde::Serialize; use std::collections::BTreeMap; use crate::data::{DataSet, DataSetError, PriceField}; #[derive(Debug, Clone)] pub struct PositionLot { pub acquired_date: NaiveDate, pub quantity: u32, pub price: f64, } #[derive(Debug, Clone)] pub struct Position { pub symbol: String, pub quantity: u32, pub average_cost: f64, pub last_price: f64, pub realized_pnl: f64, pub trading_pnl: f64, pub position_pnl: f64, pub dividend_receivable: f64, day_start_quantity: u32, day_start_price: f64, day_split_ratio: f64, day_dividend_cash: f64, day_trade_quantity_delta: i32, day_trade_cost: f64, day_buy_quantity: u32, day_sell_quantity: u32, day_buy_value: f64, day_sell_value: f64, lots: Vec, } impl Position { pub fn new(symbol: impl Into) -> Self { Self { symbol: symbol.into(), quantity: 0, average_cost: 0.0, last_price: 0.0, realized_pnl: 0.0, trading_pnl: 0.0, position_pnl: 0.0, dividend_receivable: 0.0, day_start_quantity: 0, day_start_price: 0.0, day_split_ratio: 1.0, day_dividend_cash: 0.0, day_trade_quantity_delta: 0, day_trade_cost: 0.0, day_buy_quantity: 0, day_sell_quantity: 0, day_buy_value: 0.0, day_sell_value: 0.0, lots: Vec::new(), } } pub fn is_flat(&self) -> bool { self.quantity == 0 } pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) { if quantity == 0 { return; } self.lots.push(PositionLot { acquired_date: date, quantity, price, }); self.quantity += quantity; self.last_price = price; self.day_trade_quantity_delta += quantity as i32; self.day_buy_quantity += quantity; self.day_buy_value += price * quantity as f64; self.recalculate_average_cost(); self.refresh_day_pnl(); } pub fn sell(&mut self, quantity: u32, price: f64) -> Result { if quantity > self.quantity { return Err(format!( "sell quantity {} exceeds current quantity {} for {}", quantity, self.quantity, self.symbol )); } let mut remaining = quantity; let mut realized = 0.0; while remaining > 0 { let Some(first_lot) = self.lots.first_mut() else { return Err(format!("position {} has no lots to sell", self.symbol)); }; let lot_sell = remaining.min(first_lot.quantity); realized += (price - first_lot.price) * lot_sell as f64; first_lot.quantity -= lot_sell; remaining -= lot_sell; if first_lot.quantity == 0 { self.lots.remove(0); } } self.quantity -= quantity; self.last_price = price; self.realized_pnl += realized; self.day_trade_quantity_delta -= quantity as i32; self.day_sell_quantity += quantity; self.day_sell_value += price * quantity as f64; self.recalculate_average_cost(); self.refresh_day_pnl(); Ok(realized) } pub fn sellable_qty(&self, date: NaiveDate) -> u32 { self.lots .iter() .filter(|lot| lot.acquired_date < date) .map(|lot| lot.quantity) .sum() } pub fn market_value(&self) -> f64 { self.quantity as f64 * self.last_price } pub fn unrealized_pnl(&self) -> f64 { (self.last_price - self.average_cost) * self.quantity as f64 } pub fn pnl(&self) -> f64 { self.realized_pnl + self.unrealized_pnl() } pub fn day_start_quantity(&self) -> u32 { self.day_start_quantity } pub fn day_trade_quantity_delta(&self) -> i32 { self.day_trade_quantity_delta } pub fn bought_quantity(&self) -> u32 { self.day_buy_quantity } pub fn sold_quantity(&self) -> u32 { self.day_sell_quantity } pub fn bought_value(&self) -> f64 { self.day_buy_value } pub fn sold_value(&self) -> f64 { self.day_sell_value } pub fn buy_avg_price(&self) -> f64 { if self.day_buy_quantity == 0 { 0.0 } else { self.day_buy_value / self.day_buy_quantity as f64 } } pub fn sell_avg_price(&self) -> f64 { if self.day_sell_quantity == 0 { 0.0 } else { self.day_sell_value / self.day_sell_quantity as f64 } } pub fn transaction_cost(&self) -> f64 { self.day_trade_cost } pub fn begin_trading_day(&mut self) { self.day_start_quantity = self.quantity; self.day_start_price = self.last_price; self.day_split_ratio = 1.0; self.day_dividend_cash = 0.0; self.day_trade_quantity_delta = 0; self.day_trade_cost = 0.0; self.day_buy_quantity = 0; self.day_sell_quantity = 0; self.day_buy_value = 0.0; self.day_sell_value = 0.0; self.refresh_day_pnl(); } pub fn record_trade_cost(&mut self, value: f64) { if value.is_finite() { self.day_trade_cost += value.max(0.0); self.refresh_day_pnl(); } } pub fn set_dividend_receivable(&mut self, value: f64) { self.dividend_receivable = if value.is_finite() { value.max(0.0) } else { 0.0 }; } pub fn holding_return(&self, price: f64) -> Option { if self.quantity == 0 || self.average_cost <= 0.0 { None } else { Some((price / self.average_cost) - 1.0) } } fn recalculate_average_cost(&mut self) { if self.quantity == 0 { self.average_cost = 0.0; return; } let total_cost = self .lots .iter() .map(|lot| lot.price * lot.quantity as f64) .sum::(); self.average_cost = total_cost / self.quantity as f64; } pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 { if self.quantity == 0 || !dividend_per_share.is_finite() || dividend_per_share == 0.0 { return 0.0; } for lot in &mut self.lots { lot.price -= dividend_per_share; } self.average_cost -= dividend_per_share; self.last_price -= dividend_per_share; let cash_delta = self.quantity as f64 * dividend_per_share; self.day_dividend_cash += cash_delta; self.refresh_day_pnl(); cash_delta } pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 { if self.quantity == 0 || !ratio.is_finite() || ratio <= 0.0 || (ratio - 1.0).abs() < 1e-9 { return 0; } let old_quantity = self.quantity; let mut scaled_lots = self .lots .iter() .map(|lot| PositionLot { acquired_date: lot.acquired_date, quantity: round_half_up_u32(lot.quantity as f64 * ratio), price: lot.price / ratio, }) .collect::>(); let expected_total = round_half_up_u32(old_quantity as f64 * ratio); let scaled_total = scaled_lots.iter().map(|lot| lot.quantity).sum::(); if let Some(last_lot) = scaled_lots.last_mut() { if scaled_total < expected_total { last_lot.quantity += expected_total - scaled_total; } else if scaled_total > expected_total { last_lot.quantity = last_lot .quantity .saturating_sub(scaled_total - expected_total); } } scaled_lots.retain(|lot| lot.quantity > 0); self.lots = scaled_lots; self.quantity = self.lots.iter().map(|lot| lot.quantity).sum(); self.last_price /= ratio; self.recalculate_average_cost(); self.day_split_ratio *= ratio; self.refresh_day_pnl(); self.quantity as i32 - old_quantity as i32 } fn refresh_day_pnl(&mut self) { let adjusted_old_quantity = self.day_start_quantity as f64 * self.day_split_ratio; self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 { 0.0 } else { adjusted_old_quantity * (self.last_price - (self.day_start_price / self.day_split_ratio)) + self.day_dividend_cash }; self.trading_pnl = (self.day_buy_quantity as f64 * self.last_price - self.day_buy_value) + (self.day_sell_value - self.day_sell_quantity as f64 * self.last_price) - self.day_trade_cost; } } #[derive(Debug, Clone)] pub struct PortfolioState { initial_cash: f64, cash: f64, positions: IndexMap, cash_receivables: Vec, } #[derive(Debug, Clone)] pub(crate) struct SuccessorConversionOutcome { pub old_symbol: String, pub new_symbol: String, pub old_quantity: u32, pub new_quantity_delta: i32, pub new_quantity_after: u32, pub new_average_cost_after: f64, pub cash_delta: f64, } impl PortfolioState { pub fn new(initial_cash: f64) -> Self { Self { initial_cash, cash: initial_cash, positions: IndexMap::new(), cash_receivables: Vec::new(), } } pub fn starting_cash(&self) -> f64 { self.initial_cash } pub fn units(&self) -> f64 { self.initial_cash } pub fn cash(&self) -> f64 { self.cash } pub fn positions(&self) -> &IndexMap { &self.positions } pub fn position(&self, symbol: &str) -> Option<&Position> { self.positions.get(symbol) } pub fn position_mut_if_exists(&mut self, symbol: &str) -> Option<&mut Position> { self.positions.get_mut(symbol) } pub fn position_mut(&mut self, symbol: &str) -> &mut Position { self.positions .entry(symbol.to_string()) .or_insert_with(|| Position::new(symbol)) } pub fn apply_cash_delta(&mut self, delta: f64) { self.cash += delta; } pub fn prune_flat_positions(&mut self) { self.positions.retain(|_, position| !position.is_flat()); } pub fn add_cash_receivable(&mut self, receivable: CashReceivable) { self.cash_receivables.push(receivable); self.refresh_dividend_receivables(); } pub fn settle_cash_receivables(&mut self, date: NaiveDate) -> Vec { let mut settled = Vec::new(); let mut pending = Vec::new(); for receivable in self.cash_receivables.drain(..) { if receivable.payable_date <= date { self.cash += receivable.amount; settled.push(receivable); } else { pending.push(receivable); } } self.cash_receivables = pending; self.refresh_dividend_receivables(); settled } pub fn cash_receivables(&self) -> &[CashReceivable] { &self.cash_receivables } pub fn begin_trading_day(&mut self) { for position in self.positions.values_mut() { position.begin_trading_day(); } self.refresh_dividend_receivables(); } pub fn update_prices( &mut self, date: NaiveDate, data: &DataSet, field: PriceField, ) -> Result<(), DataSetError> { for position in self.positions.values_mut() { let price = data.price(date, &position.symbol, field).ok_or_else(|| { DataSetError::MissingSnapshot { kind: match field { PriceField::DayOpen => "day open price", PriceField::Open => "open price", PriceField::Close => "close price", PriceField::Last => "last price", }, date, symbol: position.symbol.clone(), } })?; position.last_price = price; position.refresh_day_pnl(); } Ok(()) } pub fn market_value(&self) -> f64 { self.positions.values().map(Position::market_value).sum() } pub fn transaction_cost(&self) -> f64 { self.positions .values() .map(Position::transaction_cost) .sum() } pub fn trading_pnl(&self) -> f64 { self.positions .values() .map(|position| position.trading_pnl) .sum() } pub fn position_pnl(&self) -> f64 { self.positions .values() .map(|position| position.position_pnl) .sum() } pub fn daily_pnl(&self) -> f64 { self.trading_pnl() + self.position_pnl() } pub fn total_equity(&self) -> f64 { self.cash + self.market_value() } pub fn total_value(&self) -> f64 { self.total_equity() } pub fn portfolio_value(&self) -> f64 { self.total_equity() } pub fn unit_net_value(&self) -> f64 { if self.initial_cash.abs() < f64::EPSILON { 0.0 } else { self.total_equity() / self.initial_cash } } pub fn static_unit_net_value(&self) -> f64 { if self.initial_cash.abs() < f64::EPSILON { 0.0 } else { (self.total_equity() - self.daily_pnl()) / self.initial_cash } } pub fn daily_returns(&self) -> f64 { let previous_value = self.total_equity() - self.daily_pnl(); if previous_value.abs() < f64::EPSILON { 0.0 } else { self.daily_pnl() / previous_value } } pub fn total_returns(&self) -> f64 { self.unit_net_value() - 1.0 } pub fn holdings_summary(&self, date: NaiveDate) -> Vec { let total_equity = self.total_equity(); self.positions .values() .filter(|position| position.quantity > 0) .map(|position| HoldingSummary { date, symbol: position.symbol.clone(), quantity: position.quantity, average_cost: position.average_cost, last_price: position.last_price, market_value: position.market_value(), value_percent: if total_equity > 0.0 { position.market_value() / total_equity } else { 0.0 }, unrealized_pnl: position.unrealized_pnl(), realized_pnl: position.realized_pnl, pnl: position.pnl(), trading_pnl: position.trading_pnl, position_pnl: position.position_pnl, dividend_receivable: position.dividend_receivable, old_quantity: position.day_start_quantity(), bought_quantity: position.bought_quantity(), sold_quantity: position.sold_quantity(), buy_avg_price: position.buy_avg_price(), sell_avg_price: position.sell_avg_price(), bought_value: position.bought_value(), sold_value: position.sold_value(), transaction_cost: position.transaction_cost(), day_trade_quantity_delta: position.day_trade_quantity_delta(), }) .collect() } pub(crate) fn apply_successor_conversion( &mut self, old_symbol: &str, new_symbol: &str, ratio: f64, cash_per_old_share: f64, ) -> Option { if !ratio.is_finite() || ratio <= 0.0 { return None; } let old_symbol_owned = old_symbol.to_string(); let old_position = self.positions.shift_remove(old_symbol)?; if old_position.quantity == 0 { return None; } let old_quantity = old_position.quantity; let last_price = old_position.last_price; let realized_pnl = old_position.realized_pnl; let mut converted_lots = old_position .lots .into_iter() .map(|lot| PositionLot { acquired_date: lot.acquired_date, quantity: round_half_up_u32(lot.quantity as f64 * ratio), price: lot.price / ratio, }) .collect::>(); let expected_total = round_half_up_u32(old_quantity as f64 * ratio); let scaled_total = converted_lots.iter().map(|lot| lot.quantity).sum::(); if let Some(last_lot) = converted_lots.last_mut() { if scaled_total < expected_total { last_lot.quantity += expected_total - scaled_total; } else if scaled_total > expected_total { last_lot.quantity = last_lot .quantity .saturating_sub(scaled_total - expected_total); } } converted_lots.retain(|lot| lot.quantity > 0); let converted_quantity = converted_lots.iter().map(|lot| lot.quantity).sum::(); let converted_last_price = if last_price > 0.0 { last_price / ratio } else { 0.0 }; let successor = self .positions .entry(new_symbol.to_string()) .or_insert_with(|| Position::new(new_symbol)); successor.lots.extend(converted_lots); successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum(); successor.realized_pnl += realized_pnl; if converted_last_price > 0.0 { successor.last_price = converted_last_price; } successor.recalculate_average_cost(); successor.refresh_day_pnl(); Some(SuccessorConversionOutcome { old_symbol: old_symbol_owned, new_symbol: new_symbol.to_string(), old_quantity, new_quantity_delta: converted_quantity as i32, new_quantity_after: successor.quantity, new_average_cost_after: successor.average_cost, cash_delta: if cash_per_old_share.is_finite() { old_quantity as f64 * cash_per_old_share } else { 0.0 }, }) } fn refresh_dividend_receivables(&mut self) { let mut per_symbol = BTreeMap::::new(); for receivable in &self.cash_receivables { *per_symbol.entry(receivable.symbol.clone()).or_insert(0.0) += receivable.amount; } for (symbol, position) in &mut self.positions { position.set_dividend_receivable(per_symbol.get(symbol).copied().unwrap_or(0.0)); } } } #[cfg(test)] mod tests { use super::*; use crate::Instrument; use crate::data::{ BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, PriceField, }; use std::collections::BTreeMap; #[test] fn positions_preserve_insertion_order() { let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let mut portfolio = PortfolioState::new(10_000.0); portfolio.position_mut("603657.SH").buy(date, 100, 10.0); portfolio.position_mut("001266.SZ").buy(date, 100, 10.0); portfolio.position_mut("601798.SH").buy(date, 100, 10.0); let symbols = portfolio.positions().keys().cloned().collect::>(); assert_eq!( symbols, vec![ "603657.SH".to_string(), "001266.SZ".to_string(), "601798.SH".to_string() ] ); } #[test] fn portfolio_tracks_dividend_receivable_and_day_pnl() { let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(); let mut portfolio = PortfolioState::new(10_000.0); portfolio .position_mut("000001.SZ") .buy(prev_date, 100, 10.0); portfolio .update_prices( prev_date, &DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "Test".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }], vec![ DailyMarketSnapshot { date: prev_date, symbol: "000001.SZ".to_string(), timestamp: None, day_open: 10.0, open: 10.0, high: 10.0, low: 10.0, close: 10.0, last_price: 10.0, bid1: 9.99, ask1: 10.01, prev_close: 9.8, volume: 1000, tick_volume: 1000, bid1_volume: 1000, ask1_volume: 1000, trading_phase: None, paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }, DailyMarketSnapshot { date, symbol: "000001.SZ".to_string(), timestamp: None, day_open: 10.5, open: 10.5, high: 10.5, low: 10.5, close: 10.5, last_price: 10.5, bid1: 10.49, ask1: 10.51, prev_close: 10.0, volume: 1000, tick_volume: 1000, bid1_volume: 1000, ask1_volume: 1000, trading_phase: None, paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }, ], vec![DailyFactorSnapshot { date, symbol: "000001.SZ".to_string(), market_cap_bn: 50.0, free_float_cap_bn: 45.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }], vec![CandidateEligibility { date, symbol: "000001.SZ".to_string(), is_st: false, is_new_listing: false, is_paused: false, allow_buy: true, allow_sell: true, is_kcb: false, is_one_yuan: false, }], vec![BenchmarkSnapshot { date, benchmark: "000852.SH".to_string(), open: 1000.0, close: 1000.0, prev_close: 999.0, volume: 1000, }], ) .expect("dataset"), PriceField::Close, ) .expect("prev close"); portfolio.begin_trading_day(); portfolio.add_cash_receivable(CashReceivable { symbol: "000001.SZ".to_string(), ex_date: prev_date, payable_date: date.succ_opt().unwrap(), amount: 25.0, reason: "cash_dividend".to_string(), }); portfolio .position_mut_if_exists("000001.SZ") .expect("position") .apply_cash_dividend(0.2); portfolio .position_mut_if_exists("000001.SZ") .expect("position") .record_trade_cost(5.0); portfolio .update_prices( date, &DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "Test".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }], vec![DailyMarketSnapshot { date, symbol: "000001.SZ".to_string(), timestamp: None, day_open: 10.5, open: 10.5, high: 10.5, low: 10.5, close: 10.5, last_price: 10.5, bid1: 10.49, ask1: 10.51, prev_close: 10.0, volume: 1000, tick_volume: 1000, bid1_volume: 1000, ask1_volume: 1000, trading_phase: None, paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }], vec![DailyFactorSnapshot { date, symbol: "000001.SZ".to_string(), market_cap_bn: 50.0, free_float_cap_bn: 45.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }], vec![CandidateEligibility { date, symbol: "000001.SZ".to_string(), is_st: false, is_new_listing: false, is_paused: false, allow_buy: true, allow_sell: true, is_kcb: false, is_one_yuan: false, }], vec![BenchmarkSnapshot { date, benchmark: "000852.SH".to_string(), open: 1000.0, close: 1000.0, prev_close: 999.0, volume: 1000, }], ) .expect("dataset"), PriceField::Close, ) .expect("close"); let position = portfolio.position("000001.SZ").expect("position"); assert!((position.dividend_receivable - 25.0).abs() < 1e-6); assert!((position.position_pnl - 70.0).abs() < 1e-6); assert!((position.trading_pnl + 5.0).abs() < 1e-6); } #[test] fn position_tracks_day_lifecycle_fields() { let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(); let mut portfolio = PortfolioState::new(10_000.0); portfolio .position_mut("000001.SZ") .buy(prev_date, 100, 10.0); portfolio.begin_trading_day(); portfolio.position_mut("000001.SZ").buy(date, 50, 11.0); let realized = portfolio .position_mut("000001.SZ") .sell(40, 12.0) .expect("sell"); portfolio .position_mut_if_exists("000001.SZ") .expect("position") .record_trade_cost(3.0); let position = portfolio.position("000001.SZ").expect("position"); assert_eq!(position.day_start_quantity(), 100); assert_eq!(position.bought_quantity(), 50); assert_eq!(position.sold_quantity(), 40); assert_eq!(position.day_trade_quantity_delta(), 10); assert!((position.bought_value() - 550.0).abs() < 1e-6); assert!((position.sold_value() - 480.0).abs() < 1e-6); assert!((position.buy_avg_price() - 11.0).abs() < 1e-6); assert!((position.sell_avg_price() - 12.0).abs() < 1e-6); assert!((position.transaction_cost() - 3.0).abs() < 1e-6); assert!((realized - 80.0).abs() < 1e-6); assert!((position.realized_pnl - 80.0).abs() < 1e-6); assert!((position.position_pnl - 200.0).abs() < 1e-6); assert!((position.trading_pnl - 47.0).abs() < 1e-6); assert!((position.pnl() - (80.0 + position.unrealized_pnl())).abs() < 1e-6); let summary = portfolio.holdings_summary(date); assert_eq!(summary[0].old_quantity, 100); assert_eq!(summary[0].bought_quantity, 50); assert_eq!(summary[0].sold_quantity, 40); assert!((summary[0].buy_avg_price - 11.0).abs() < 1e-6); assert!((summary[0].sell_avg_price - 12.0).abs() < 1e-6); assert!((summary[0].transaction_cost - 3.0).abs() < 1e-6); assert!(summary[0].value_percent > 0.0); } #[test] fn portfolio_exposes_rqalpha_style_account_metrics() { let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(); let mut portfolio = PortfolioState::new(10_000.0); portfolio .position_mut("000001.SZ") .buy(prev_date, 100, 10.0); portfolio.begin_trading_day(); portfolio.position_mut("000001.SZ").buy(date, 50, 11.0); portfolio .position_mut("000001.SZ") .sell(40, 12.0) .expect("sell"); portfolio.position_mut("000001.SZ").record_trade_cost(3.0); assert!((portfolio.starting_cash() - 10_000.0).abs() < 1e-6); assert!((portfolio.units() - 10_000.0).abs() < 1e-6); assert!((portfolio.transaction_cost() - 3.0).abs() < 1e-6); assert!((portfolio.trading_pnl() - 47.0).abs() < 1e-6); assert!((portfolio.position_pnl() - 200.0).abs() < 1e-6); assert!((portfolio.daily_pnl() - 247.0).abs() < 1e-6); assert!((portfolio.total_value() - portfolio.total_equity()).abs() < 1e-6); assert!((portfolio.portfolio_value() - portfolio.total_equity()).abs() < 1e-6); assert!((portfolio.unit_net_value() - portfolio.total_equity() / 10_000.0).abs() < 1e-6); assert!( (portfolio.static_unit_net_value() - (portfolio.total_equity() - portfolio.daily_pnl()) / 10_000.0) .abs() < 1e-6 ); assert!( (portfolio.daily_returns() - portfolio.daily_pnl() / (portfolio.total_equity() - portfolio.daily_pnl())) .abs() < 1e-6 ); assert!((portfolio.total_returns() - (portfolio.unit_net_value() - 1.0)).abs() < 1e-6); assert_eq!(portfolio.cash_receivables().len(), 0); } } #[derive(Debug, Clone, Serialize)] pub struct HoldingSummary { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, pub quantity: u32, pub average_cost: f64, pub last_price: f64, pub market_value: f64, pub value_percent: f64, pub unrealized_pnl: f64, pub realized_pnl: f64, pub pnl: f64, pub trading_pnl: f64, pub position_pnl: f64, pub dividend_receivable: f64, pub old_quantity: u32, pub bought_quantity: u32, pub sold_quantity: u32, pub buy_avg_price: f64, pub sell_avg_price: f64, pub bought_value: f64, pub sold_value: f64, pub transaction_cost: f64, pub day_trade_quantity_delta: i32, } #[derive(Debug, Clone)] pub struct CashReceivable { pub symbol: String, pub ex_date: NaiveDate, pub payable_date: NaiveDate, pub amount: f64, pub reason: String, } mod date_format { use chrono::NaiveDate; use serde::Serializer; const FORMAT: &str = "%Y-%m-%d"; pub fn serialize(date: &NaiveDate, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&date.format(FORMAT).to_string()) } } fn round_half_up_u32(value: f64) -> u32 { if !value.is_finite() || value <= 0.0 { 0 } else { value.round() as u32 } }