994 lines
33 KiB
Rust
994 lines
33 KiB
Rust
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<PositionLot>,
|
|
}
|
|
|
|
impl Position {
|
|
pub fn new(symbol: impl Into<String>) -> 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<f64, String> {
|
|
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<f64> {
|
|
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::<f64>();
|
|
|
|
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::<Vec<_>>();
|
|
|
|
let expected_total = round_half_up_u32(old_quantity as f64 * ratio);
|
|
let scaled_total = scaled_lots.iter().map(|lot| lot.quantity).sum::<u32>();
|
|
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<String, Position>,
|
|
cash_receivables: Vec<CashReceivable>,
|
|
}
|
|
|
|
#[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<String, Position> {
|
|
&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<CashReceivable> {
|
|
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<HoldingSummary> {
|
|
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<SuccessorConversionOutcome> {
|
|
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::<Vec<_>>();
|
|
let expected_total = round_half_up_u32(old_quantity as f64 * ratio);
|
|
let scaled_total = converted_lots.iter().map(|lot| lot.quantity).sum::<u32>();
|
|
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::<u32>();
|
|
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::<String, f64>::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::<Vec<_>>();
|
|
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<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
|
|
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
|
|
}
|
|
}
|