将股票持仓盈亏切换为定点批次账本
This commit is contained in:
@@ -10069,12 +10069,12 @@ impl PlatformExprStrategy {
|
|||||||
equity: market_value,
|
equity: market_value,
|
||||||
value_percent,
|
value_percent,
|
||||||
unrealized_pnl: position.unrealized_pnl(),
|
unrealized_pnl: position.unrealized_pnl(),
|
||||||
realized_pnl: position.realized_pnl,
|
realized_pnl: position.realized_pnl(),
|
||||||
pnl: position.pnl(),
|
pnl: position.pnl(),
|
||||||
day_trade_quantity_delta: position.day_trade_quantity_delta() as i64,
|
day_trade_quantity_delta: position.day_trade_quantity_delta() as i64,
|
||||||
trading_pnl: position.trading_pnl,
|
trading_pnl: position.trading_pnl,
|
||||||
position_pnl: position.position_pnl,
|
position_pnl: position.position_pnl,
|
||||||
dividend_receivable: position.dividend_receivable,
|
dividend_receivable: position.dividend_receivable(),
|
||||||
};
|
};
|
||||||
let stop_hit = if self.config.stop_loss_expr.trim().is_empty() {
|
let stop_hit = if self.config.stop_loss_expr.trim().is_empty() {
|
||||||
false
|
false
|
||||||
|
|||||||
+319
-101
@@ -6,35 +6,78 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
use crate::data::{DataSet, DataSetError, PriceField};
|
use crate::data::{DataSet, DataSetError, PriceField};
|
||||||
use crate::fixed_point::{FixedMoney, MONEY_SCALE};
|
use crate::fixed_point::{FixedMoney, MONEY_SCALE};
|
||||||
|
|
||||||
|
fn fixed_money(value: f64, label: &str) -> Result<FixedMoney, String> {
|
||||||
|
FixedMoney::from_f64(value)
|
||||||
|
.ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed_money_or_panic(value: f64, label: &str) -> FixedMoney {
|
||||||
|
fixed_money(value, label).unwrap_or_else(|error| panic!("{error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allocate_fixed_value(
|
||||||
|
total: FixedMoney,
|
||||||
|
allocated_quantity: u32,
|
||||||
|
total_quantity: u32,
|
||||||
|
) -> Result<FixedMoney, String> {
|
||||||
|
if allocated_quantity > total_quantity || total_quantity == 0 {
|
||||||
|
return Err("invalid fixed-point lot allocation quantity".to_string());
|
||||||
|
}
|
||||||
|
if allocated_quantity == total_quantity {
|
||||||
|
return Ok(total);
|
||||||
|
}
|
||||||
|
let product = total
|
||||||
|
.raw()
|
||||||
|
.checked_mul(i128::from(allocated_quantity))
|
||||||
|
.ok_or_else(|| "fixed-point lot allocation overflow".to_string())?;
|
||||||
|
let divisor = i128::from(total_quantity);
|
||||||
|
let quotient = product / divisor;
|
||||||
|
let remainder = product % divisor;
|
||||||
|
let rounded = if remainder
|
||||||
|
.checked_abs()
|
||||||
|
.and_then(|value| value.checked_mul(2))
|
||||||
|
.is_some_and(|value| value >= divisor)
|
||||||
|
{
|
||||||
|
quotient
|
||||||
|
.checked_add(product.signum())
|
||||||
|
.ok_or_else(|| "fixed-point lot allocation overflow".to_string())?
|
||||||
|
} else {
|
||||||
|
quotient
|
||||||
|
};
|
||||||
|
Ok(FixedMoney::from_raw(rounded))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PositionLot {
|
pub struct PositionLot {
|
||||||
pub acquired_date: NaiveDate,
|
pub acquired_date: NaiveDate,
|
||||||
pub quantity: u32,
|
pub quantity: u32,
|
||||||
pub entry_price: f64,
|
// Total values keep partial-lot allocation exact without a rounded per-share mirror.
|
||||||
pub price: f64,
|
entry_value: FixedMoney,
|
||||||
|
cost_basis: FixedMoney,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Position {
|
pub struct Position {
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub quantity: u32,
|
pub quantity: u32,
|
||||||
|
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
|
||||||
pub average_cost: f64,
|
pub average_cost: f64,
|
||||||
pub last_price: f64,
|
pub last_price: f64,
|
||||||
pub realized_pnl: f64,
|
realized_pnl: FixedMoney,
|
||||||
realized_entry_pnl: f64,
|
realized_entry_pnl: FixedMoney,
|
||||||
pub trading_pnl: f64,
|
pub trading_pnl: f64,
|
||||||
pub position_pnl: f64,
|
pub position_pnl: f64,
|
||||||
pub dividend_receivable: f64,
|
dividend_receivable: FixedMoney,
|
||||||
day_start_quantity: u32,
|
day_start_quantity: u32,
|
||||||
day_start_price: f64,
|
day_start_price: f64,
|
||||||
day_split_ratio: f64,
|
day_split_ratio: f64,
|
||||||
day_dividend_cash: f64,
|
day_dividend_cash: FixedMoney,
|
||||||
day_trade_quantity_delta: i32,
|
day_trade_quantity_delta: i32,
|
||||||
day_trade_cost: f64,
|
day_trade_cost: FixedMoney,
|
||||||
day_buy_quantity: u32,
|
day_buy_quantity: u32,
|
||||||
day_sell_quantity: u32,
|
day_sell_quantity: u32,
|
||||||
day_buy_value: f64,
|
day_buy_value: FixedMoney,
|
||||||
day_sell_value: f64,
|
day_sell_value: FixedMoney,
|
||||||
lots: Vec<PositionLot>,
|
lots: Vec<PositionLot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,21 +88,21 @@ impl Position {
|
|||||||
quantity: 0,
|
quantity: 0,
|
||||||
average_cost: 0.0,
|
average_cost: 0.0,
|
||||||
last_price: 0.0,
|
last_price: 0.0,
|
||||||
realized_pnl: 0.0,
|
realized_pnl: FixedMoney::ZERO,
|
||||||
realized_entry_pnl: 0.0,
|
realized_entry_pnl: FixedMoney::ZERO,
|
||||||
trading_pnl: 0.0,
|
trading_pnl: 0.0,
|
||||||
position_pnl: 0.0,
|
position_pnl: 0.0,
|
||||||
dividend_receivable: 0.0,
|
dividend_receivable: FixedMoney::ZERO,
|
||||||
day_start_quantity: 0,
|
day_start_quantity: 0,
|
||||||
day_start_price: 0.0,
|
day_start_price: 0.0,
|
||||||
day_split_ratio: 1.0,
|
day_split_ratio: 1.0,
|
||||||
day_dividend_cash: 0.0,
|
day_dividend_cash: FixedMoney::ZERO,
|
||||||
day_trade_quantity_delta: 0,
|
day_trade_quantity_delta: 0,
|
||||||
day_trade_cost: 0.0,
|
day_trade_cost: FixedMoney::ZERO,
|
||||||
day_buy_quantity: 0,
|
day_buy_quantity: 0,
|
||||||
day_sell_quantity: 0,
|
day_sell_quantity: 0,
|
||||||
day_buy_value: 0.0,
|
day_buy_value: FixedMoney::ZERO,
|
||||||
day_sell_value: 0.0,
|
day_sell_value: FixedMoney::ZERO,
|
||||||
lots: Vec::new(),
|
lots: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,17 +128,24 @@ impl Position {
|
|||||||
|
|
||||||
let previous_quantity = self.quantity;
|
let previous_quantity = self.quantity;
|
||||||
let previous_average_cost = self.average_cost;
|
let previous_average_cost = self.average_cost;
|
||||||
|
let gross_amount = fixed_money_or_panic(
|
||||||
|
execution_price * quantity as f64,
|
||||||
|
"position buy gross amount",
|
||||||
|
);
|
||||||
self.lots.push(PositionLot {
|
self.lots.push(PositionLot {
|
||||||
acquired_date: date,
|
acquired_date: date,
|
||||||
quantity,
|
quantity,
|
||||||
entry_price: execution_price,
|
entry_value: gross_amount,
|
||||||
price: execution_price,
|
cost_basis: gross_amount,
|
||||||
});
|
});
|
||||||
self.quantity += quantity;
|
self.quantity += quantity;
|
||||||
self.last_price = normalized_mark_price(mark_price, execution_price);
|
self.last_price = normalized_mark_price(mark_price, execution_price);
|
||||||
self.day_trade_quantity_delta += quantity as i32;
|
self.day_trade_quantity_delta += quantity as i32;
|
||||||
self.day_buy_quantity += quantity;
|
self.day_buy_quantity += quantity;
|
||||||
self.day_buy_value += execution_price * quantity as f64;
|
self.day_buy_value = self
|
||||||
|
.day_buy_value
|
||||||
|
.checked_add(gross_amount)
|
||||||
|
.expect("fixed-point day buy value overflow");
|
||||||
if previous_quantity > 0
|
if previous_quantity > 0
|
||||||
&& previous_average_cost.is_finite()
|
&& previous_average_cost.is_finite()
|
||||||
&& previous_average_cost > 0.0
|
&& previous_average_cost > 0.0
|
||||||
@@ -128,9 +178,14 @@ impl Position {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let total_proceeds = fixed_money(
|
||||||
|
execution_price * quantity as f64,
|
||||||
|
"position sell gross amount",
|
||||||
|
)?;
|
||||||
let mut remaining = quantity;
|
let mut remaining = quantity;
|
||||||
let mut realized = 0.0;
|
let mut remaining_proceeds = total_proceeds;
|
||||||
let mut realized_entry = 0.0;
|
let mut realized = FixedMoney::ZERO;
|
||||||
|
let mut realized_entry = FixedMoney::ZERO;
|
||||||
let average_cost_before_sell = self.average_cost;
|
let average_cost_before_sell = self.average_cost;
|
||||||
|
|
||||||
while remaining > 0 {
|
while remaining > 0 {
|
||||||
@@ -138,9 +193,38 @@ impl Position {
|
|||||||
return Err(format!("position {} has no lots to sell", self.symbol));
|
return Err(format!("position {} has no lots to sell", self.symbol));
|
||||||
};
|
};
|
||||||
|
|
||||||
let lot_sell = remaining.min(first_lot.quantity);
|
let lot_quantity_before = first_lot.quantity;
|
||||||
realized += (execution_price - first_lot.price) * lot_sell as f64;
|
let lot_sell = remaining.min(lot_quantity_before);
|
||||||
realized_entry += (execution_price - first_lot.entry_price) * lot_sell as f64;
|
let lot_proceeds = allocate_fixed_value(remaining_proceeds, lot_sell, remaining)?;
|
||||||
|
let lot_cost =
|
||||||
|
allocate_fixed_value(first_lot.cost_basis, lot_sell, lot_quantity_before)?;
|
||||||
|
let lot_entry =
|
||||||
|
allocate_fixed_value(first_lot.entry_value, lot_sell, lot_quantity_before)?;
|
||||||
|
realized = realized
|
||||||
|
.checked_add(
|
||||||
|
lot_proceeds
|
||||||
|
.checked_sub(lot_cost)
|
||||||
|
.ok_or_else(|| "fixed-point realized PnL overflow".to_string())?,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| "fixed-point realized PnL overflow".to_string())?;
|
||||||
|
realized_entry = realized_entry
|
||||||
|
.checked_add(
|
||||||
|
lot_proceeds
|
||||||
|
.checked_sub(lot_entry)
|
||||||
|
.ok_or_else(|| "fixed-point realized entry PnL overflow".to_string())?,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| "fixed-point realized entry PnL overflow".to_string())?;
|
||||||
|
first_lot.cost_basis = first_lot
|
||||||
|
.cost_basis
|
||||||
|
.checked_sub(lot_cost)
|
||||||
|
.ok_or_else(|| "fixed-point lot cost underflow".to_string())?;
|
||||||
|
first_lot.entry_value = first_lot
|
||||||
|
.entry_value
|
||||||
|
.checked_sub(lot_entry)
|
||||||
|
.ok_or_else(|| "fixed-point lot entry underflow".to_string())?;
|
||||||
|
remaining_proceeds = remaining_proceeds
|
||||||
|
.checked_sub(lot_proceeds)
|
||||||
|
.ok_or_else(|| "fixed-point sell proceeds underflow".to_string())?;
|
||||||
first_lot.quantity -= lot_sell;
|
first_lot.quantity -= lot_sell;
|
||||||
remaining -= lot_sell;
|
remaining -= lot_sell;
|
||||||
|
|
||||||
@@ -151,11 +235,20 @@ impl Position {
|
|||||||
|
|
||||||
self.quantity -= quantity;
|
self.quantity -= quantity;
|
||||||
self.last_price = normalized_mark_price(mark_price, execution_price);
|
self.last_price = normalized_mark_price(mark_price, execution_price);
|
||||||
self.realized_pnl += realized;
|
self.realized_pnl = self
|
||||||
self.realized_entry_pnl += realized_entry;
|
.realized_pnl
|
||||||
|
.checked_add(realized)
|
||||||
|
.ok_or_else(|| "fixed-point realized PnL overflow".to_string())?;
|
||||||
|
self.realized_entry_pnl = self
|
||||||
|
.realized_entry_pnl
|
||||||
|
.checked_add(realized_entry)
|
||||||
|
.ok_or_else(|| "fixed-point realized entry PnL overflow".to_string())?;
|
||||||
self.day_trade_quantity_delta -= quantity as i32;
|
self.day_trade_quantity_delta -= quantity as i32;
|
||||||
self.day_sell_quantity += quantity;
|
self.day_sell_quantity += quantity;
|
||||||
self.day_sell_value += execution_price * quantity as f64;
|
self.day_sell_value = self
|
||||||
|
.day_sell_value
|
||||||
|
.checked_add(total_proceeds)
|
||||||
|
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
|
||||||
if self.quantity == 0 {
|
if self.quantity == 0 {
|
||||||
self.recalculate_average_cost();
|
self.recalculate_average_cost();
|
||||||
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
|
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
|
||||||
@@ -164,7 +257,7 @@ impl Position {
|
|||||||
self.recalculate_average_cost();
|
self.recalculate_average_cost();
|
||||||
}
|
}
|
||||||
self.refresh_day_pnl();
|
self.refresh_day_pnl();
|
||||||
Ok(realized)
|
Ok(realized.to_f64())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sellable_qty(&self, date: NaiveDate) -> u32 {
|
pub fn sellable_qty(&self, date: NaiveDate) -> u32 {
|
||||||
@@ -180,22 +273,49 @@ impl Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn unrealized_pnl(&self) -> f64 {
|
pub fn unrealized_pnl(&self) -> f64 {
|
||||||
(self.last_price - self.average_cost) * self.quantity as f64
|
if self.quantity == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
fixed_money_or_panic(
|
||||||
|
self.last_price * self.quantity as f64,
|
||||||
|
"position marked value",
|
||||||
|
)
|
||||||
|
.checked_sub(self.total_cost_basis())
|
||||||
|
.expect("fixed-point unrealized PnL overflow")
|
||||||
|
.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unrealized_entry_pnl(&self) -> f64 {
|
pub fn unrealized_entry_pnl(&self) -> f64 {
|
||||||
let Some(avg_price) = self.average_entry_price() else {
|
if self.quantity == 0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
};
|
}
|
||||||
(self.last_price - avg_price) * self.quantity as f64
|
fixed_money_or_panic(
|
||||||
|
self.last_price * self.quantity as f64,
|
||||||
|
"position marked value",
|
||||||
|
)
|
||||||
|
.checked_sub(self.total_entry_value())
|
||||||
|
.expect("fixed-point unrealized entry PnL overflow")
|
||||||
|
.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pnl(&self) -> f64 {
|
pub fn pnl(&self) -> f64 {
|
||||||
self.realized_pnl + self.unrealized_pnl()
|
self.realized_pnl.to_f64() + self.unrealized_pnl()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn entry_pnl(&self) -> f64 {
|
pub fn entry_pnl(&self) -> f64 {
|
||||||
self.realized_entry_pnl + self.unrealized_entry_pnl()
|
self.realized_entry_pnl.to_f64() + self.unrealized_entry_pnl()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn realized_pnl(&self) -> f64 {
|
||||||
|
self.realized_pnl.to_f64()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn realized_entry_pnl(&self) -> f64 {
|
||||||
|
self.realized_entry_pnl.to_f64()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dividend_receivable(&self) -> f64 {
|
||||||
|
self.dividend_receivable.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn day_start_quantity(&self) -> u32 {
|
pub fn day_start_quantity(&self) -> u32 {
|
||||||
@@ -215,18 +335,18 @@ impl Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn bought_value(&self) -> f64 {
|
pub fn bought_value(&self) -> f64 {
|
||||||
self.day_buy_value
|
self.day_buy_value.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sold_value(&self) -> f64 {
|
pub fn sold_value(&self) -> f64 {
|
||||||
self.day_sell_value
|
self.day_sell_value.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn buy_avg_price(&self) -> f64 {
|
pub fn buy_avg_price(&self) -> f64 {
|
||||||
if self.day_buy_quantity == 0 {
|
if self.day_buy_quantity == 0 {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
self.day_buy_value / self.day_buy_quantity as f64
|
self.day_buy_value.to_f64() / self.day_buy_quantity as f64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,31 +354,34 @@ impl Position {
|
|||||||
if self.day_sell_quantity == 0 {
|
if self.day_sell_quantity == 0 {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
self.day_sell_value / self.day_sell_quantity as f64
|
self.day_sell_value.to_f64() / self.day_sell_quantity as f64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn transaction_cost(&self) -> f64 {
|
pub fn transaction_cost(&self) -> f64 {
|
||||||
self.day_trade_cost
|
self.day_trade_cost.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn begin_trading_day(&mut self) {
|
pub fn begin_trading_day(&mut self) {
|
||||||
self.day_start_quantity = self.quantity;
|
self.day_start_quantity = self.quantity;
|
||||||
self.day_start_price = self.last_price;
|
self.day_start_price = self.last_price;
|
||||||
self.day_split_ratio = 1.0;
|
self.day_split_ratio = 1.0;
|
||||||
self.day_dividend_cash = 0.0;
|
self.day_dividend_cash = FixedMoney::ZERO;
|
||||||
self.day_trade_quantity_delta = 0;
|
self.day_trade_quantity_delta = 0;
|
||||||
self.day_trade_cost = 0.0;
|
self.day_trade_cost = FixedMoney::ZERO;
|
||||||
self.day_buy_quantity = 0;
|
self.day_buy_quantity = 0;
|
||||||
self.day_sell_quantity = 0;
|
self.day_sell_quantity = 0;
|
||||||
self.day_buy_value = 0.0;
|
self.day_buy_value = FixedMoney::ZERO;
|
||||||
self.day_sell_value = 0.0;
|
self.day_sell_value = FixedMoney::ZERO;
|
||||||
self.refresh_day_pnl();
|
self.refresh_day_pnl();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_trade_cost(&mut self, value: f64) {
|
pub fn record_trade_cost(&mut self, value: f64) {
|
||||||
if value.is_finite() {
|
if value.is_finite() {
|
||||||
self.day_trade_cost += value.max(0.0);
|
self.day_trade_cost = self
|
||||||
|
.day_trade_cost
|
||||||
|
.checked_add(fixed_money_or_panic(value.max(0.0), "position trade cost"))
|
||||||
|
.expect("fixed-point day trade cost overflow");
|
||||||
self.refresh_day_pnl();
|
self.refresh_day_pnl();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,27 +390,33 @@ impl Position {
|
|||||||
if quantity == 0 || !value.is_finite() {
|
if quantity == 0 || !value.is_finite() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cost = value.max(0.0);
|
let cost = fixed_money_or_panic(value.max(0.0), "position buy trade cost");
|
||||||
if cost <= 0.0 {
|
if cost.raw() <= 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(lot) = self.lots.last_mut() {
|
if let Some(lot) = self.lots.last_mut() {
|
||||||
lot.price += cost / quantity as f64;
|
lot.cost_basis = lot
|
||||||
|
.cost_basis
|
||||||
|
.checked_add(cost)
|
||||||
|
.expect("fixed-point lot cost overflow");
|
||||||
if self.quantity > 0 && self.average_cost.is_finite() && self.average_cost > 0.0 {
|
if self.quantity > 0 && self.average_cost.is_finite() && self.average_cost > 0.0 {
|
||||||
self.average_cost += cost / self.quantity as f64;
|
self.average_cost += cost.to_f64() / self.quantity as f64;
|
||||||
} else {
|
} else {
|
||||||
self.recalculate_average_cost();
|
self.recalculate_average_cost();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.day_trade_cost += cost;
|
self.day_trade_cost = self
|
||||||
|
.day_trade_cost
|
||||||
|
.checked_add(cost)
|
||||||
|
.expect("fixed-point day trade cost overflow");
|
||||||
self.refresh_day_pnl();
|
self.refresh_day_pnl();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_dividend_receivable(&mut self, value: f64) {
|
pub fn set_dividend_receivable(&mut self, value: f64) {
|
||||||
self.dividend_receivable = if value.is_finite() {
|
self.dividend_receivable = if value.is_finite() {
|
||||||
value.max(0.0)
|
fixed_money_or_panic(value.max(0.0), "position dividend receivable")
|
||||||
} else {
|
} else {
|
||||||
0.0
|
FixedMoney::ZERO
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,12 +435,7 @@ impl Position {
|
|||||||
if self.quantity == 0 {
|
if self.quantity == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let total = self
|
Some(self.total_entry_value().to_f64() / self.quantity as f64)
|
||||||
.lots
|
|
||||||
.iter()
|
|
||||||
.map(|lot| lot.entry_price * lot.quantity as f64)
|
|
||||||
.sum::<f64>();
|
|
||||||
Some(total / self.quantity as f64)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recalculate_average_cost(&mut self) {
|
fn recalculate_average_cost(&mut self) {
|
||||||
@@ -320,13 +444,23 @@ impl Position {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_cost = self
|
self.average_cost = self.total_cost_basis().to_f64() / self.quantity as f64;
|
||||||
.lots
|
}
|
||||||
.iter()
|
|
||||||
.map(|lot| lot.price * lot.quantity as f64)
|
|
||||||
.sum::<f64>();
|
|
||||||
|
|
||||||
self.average_cost = total_cost / self.quantity as f64;
|
fn total_entry_value(&self) -> FixedMoney {
|
||||||
|
self.lots.iter().fold(FixedMoney::ZERO, |total, lot| {
|
||||||
|
total
|
||||||
|
.checked_add(lot.entry_value)
|
||||||
|
.expect("fixed-point position entry value overflow")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_cost_basis(&self) -> FixedMoney {
|
||||||
|
self.lots.iter().fold(FixedMoney::ZERO, |total, lot| {
|
||||||
|
total
|
||||||
|
.checked_add(lot.cost_basis)
|
||||||
|
.expect("fixed-point position cost basis overflow")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 {
|
pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 {
|
||||||
@@ -346,20 +480,36 @@ impl Position {
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut cash_delta = FixedMoney::ZERO;
|
||||||
for lot in &mut self.lots {
|
for lot in &mut self.lots {
|
||||||
lot.entry_price -= dividend_per_share;
|
let lot_dividend = fixed_money_or_panic(
|
||||||
|
dividend_per_share * lot.quantity as f64,
|
||||||
|
"position cash dividend",
|
||||||
|
);
|
||||||
|
lot.entry_value = lot
|
||||||
|
.entry_value
|
||||||
|
.checked_sub(lot_dividend)
|
||||||
|
.expect("fixed-point lot entry dividend adjustment overflow");
|
||||||
if adjust_cost_basis {
|
if adjust_cost_basis {
|
||||||
lot.price -= dividend_per_share;
|
lot.cost_basis = lot
|
||||||
|
.cost_basis
|
||||||
|
.checked_sub(lot_dividend)
|
||||||
|
.expect("fixed-point lot cost dividend adjustment overflow");
|
||||||
}
|
}
|
||||||
|
cash_delta = cash_delta
|
||||||
|
.checked_add(lot_dividend)
|
||||||
|
.expect("fixed-point cash dividend overflow");
|
||||||
}
|
}
|
||||||
if adjust_cost_basis {
|
if adjust_cost_basis {
|
||||||
self.average_cost -= dividend_per_share;
|
self.average_cost -= dividend_per_share;
|
||||||
}
|
}
|
||||||
self.last_price -= dividend_per_share;
|
self.last_price -= dividend_per_share;
|
||||||
let cash_delta = self.quantity as f64 * dividend_per_share;
|
self.day_dividend_cash = self
|
||||||
self.day_dividend_cash += cash_delta;
|
.day_dividend_cash
|
||||||
|
.checked_add(cash_delta)
|
||||||
|
.expect("fixed-point day dividend cash overflow");
|
||||||
self.refresh_day_pnl();
|
self.refresh_day_pnl();
|
||||||
cash_delta
|
cash_delta.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 {
|
pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 {
|
||||||
@@ -374,8 +524,8 @@ impl Position {
|
|||||||
.map(|lot| PositionLot {
|
.map(|lot| PositionLot {
|
||||||
acquired_date: lot.acquired_date,
|
acquired_date: lot.acquired_date,
|
||||||
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
||||||
entry_price: lot.entry_price / ratio,
|
entry_value: lot.entry_value,
|
||||||
price: lot.price / ratio,
|
cost_basis: lot.cost_basis,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
@@ -410,13 +560,38 @@ impl Position {
|
|||||||
self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 {
|
self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
adjusted_old_quantity
|
fixed_money_or_panic(
|
||||||
* (self.last_price - (self.day_start_price / self.day_split_ratio))
|
adjusted_old_quantity
|
||||||
+ self.day_dividend_cash
|
* (self.last_price - (self.day_start_price / self.day_split_ratio)),
|
||||||
|
"position daily mark PnL",
|
||||||
|
)
|
||||||
|
.checked_add(self.day_dividend_cash)
|
||||||
|
.expect("fixed-point position daily PnL overflow")
|
||||||
|
.to_f64()
|
||||||
};
|
};
|
||||||
self.trading_pnl = (self.day_buy_quantity as f64 * self.last_price - self.day_buy_value)
|
let buy_mark = if self.day_buy_quantity == 0 {
|
||||||
+ (self.day_sell_value - self.day_sell_quantity as f64 * self.last_price)
|
FixedMoney::ZERO
|
||||||
- self.day_trade_cost;
|
} else {
|
||||||
|
fixed_money_or_panic(
|
||||||
|
self.day_buy_quantity as f64 * self.last_price,
|
||||||
|
"position day buy mark value",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let sell_mark = if self.day_sell_quantity == 0 {
|
||||||
|
FixedMoney::ZERO
|
||||||
|
} else {
|
||||||
|
fixed_money_or_panic(
|
||||||
|
self.day_sell_quantity as f64 * self.last_price,
|
||||||
|
"position day sell mark value",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.trading_pnl = buy_mark
|
||||||
|
.checked_sub(self.day_buy_value)
|
||||||
|
.and_then(|value| value.checked_add(self.day_sell_value))
|
||||||
|
.and_then(|value| value.checked_sub(sell_mark))
|
||||||
|
.and_then(|value| value.checked_sub(self.day_trade_cost))
|
||||||
|
.expect("fixed-point position trading PnL overflow")
|
||||||
|
.to_f64();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +642,7 @@ pub(crate) struct SuccessorConversionOutcome {
|
|||||||
|
|
||||||
impl PortfolioState {
|
impl PortfolioState {
|
||||||
pub fn new(initial_cash: f64) -> Self {
|
pub fn new(initial_cash: f64) -> Self {
|
||||||
let initial_cash = Self::fixed_money(initial_cash, "initial cash")
|
let initial_cash = fixed_money(initial_cash, "initial cash")
|
||||||
.expect("initial cash must be finite fixed-point money");
|
.expect("initial cash must be finite fixed-point money");
|
||||||
Self {
|
Self {
|
||||||
initial_cash,
|
initial_cash,
|
||||||
@@ -539,7 +714,7 @@ impl PortfolioState {
|
|||||||
pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> {
|
pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> {
|
||||||
self.cash = self
|
self.cash = self
|
||||||
.cash
|
.cash
|
||||||
.checked_add(Self::fixed_money(delta, "cash delta")?)
|
.checked_add(fixed_money(delta, "cash delta")?)
|
||||||
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
|
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -565,9 +740,9 @@ impl PortfolioState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn deposit_withdraw(&mut self, amount: f64) -> Result<(), String> {
|
pub fn deposit_withdraw(&mut self, amount: f64) -> Result<(), String> {
|
||||||
let amount_money = Self::fixed_money(amount, "deposit_withdraw amount")?;
|
let amount_money = fixed_money(amount, "deposit_withdraw amount")?;
|
||||||
let pending_withdrawal =
|
let pending_withdrawal =
|
||||||
Self::fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
|
fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
|
||||||
let available_cash = self
|
let available_cash = self
|
||||||
.cash
|
.cash
|
||||||
.checked_sub(pending_withdrawal)
|
.checked_sub(pending_withdrawal)
|
||||||
@@ -602,9 +777,9 @@ impl PortfolioState {
|
|||||||
amount: f64,
|
amount: f64,
|
||||||
reason: impl Into<String>,
|
reason: impl Into<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let amount_money = Self::fixed_money(amount, "deposit_withdraw amount")?;
|
let amount_money = fixed_money(amount, "deposit_withdraw amount")?;
|
||||||
let pending_withdrawal =
|
let pending_withdrawal =
|
||||||
Self::fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
|
fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
|
||||||
let available_cash = self
|
let available_cash = self
|
||||||
.cash
|
.cash
|
||||||
.checked_sub(pending_withdrawal)
|
.checked_sub(pending_withdrawal)
|
||||||
@@ -685,7 +860,7 @@ impl PortfolioState {
|
|||||||
let mut settled = Vec::with_capacity(due.len());
|
let mut settled = Vec::with_capacity(due.len());
|
||||||
for flow in due {
|
for flow in due {
|
||||||
let unit_net_value = self.unit_net_value();
|
let unit_net_value = self.unit_net_value();
|
||||||
let amount = Self::fixed_money(flow.amount, "scheduled cash flow")?;
|
let amount = fixed_money(flow.amount, "scheduled cash flow")?;
|
||||||
self.cash = self
|
self.cash = self
|
||||||
.cash
|
.cash
|
||||||
.checked_add(amount)
|
.checked_add(amount)
|
||||||
@@ -714,7 +889,7 @@ impl PortfolioState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> {
|
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> {
|
||||||
let amount_money = Self::fixed_money(amount, "finance_repay amount")?;
|
let amount_money = fixed_money(amount, "finance_repay amount")?;
|
||||||
if amount_money.raw() > 0 {
|
if amount_money.raw() > 0 {
|
||||||
self.cash_liabilities = self
|
self.cash_liabilities = self
|
||||||
.cash_liabilities
|
.cash_liabilities
|
||||||
@@ -763,7 +938,7 @@ impl PortfolioState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_management_fee(&mut self, fee: f64) -> Result<(), String> {
|
pub fn apply_management_fee(&mut self, fee: f64) -> Result<(), String> {
|
||||||
let fee_money = Self::fixed_money(fee, "management fee")?;
|
let fee_money = fixed_money(fee, "management fee")?;
|
||||||
if fee_money.raw() < 0 {
|
if fee_money.raw() < 0 {
|
||||||
return Err("management fee must be finite and non-negative".to_string());
|
return Err("management fee must be finite and non-negative".to_string());
|
||||||
}
|
}
|
||||||
@@ -969,11 +1144,11 @@ impl PortfolioState {
|
|||||||
0.0
|
0.0
|
||||||
},
|
},
|
||||||
unrealized_pnl: position.unrealized_entry_pnl(),
|
unrealized_pnl: position.unrealized_entry_pnl(),
|
||||||
realized_pnl: position.realized_entry_pnl,
|
realized_pnl: position.realized_entry_pnl(),
|
||||||
pnl: position.entry_pnl(),
|
pnl: position.entry_pnl(),
|
||||||
trading_pnl: position.trading_pnl,
|
trading_pnl: position.trading_pnl,
|
||||||
position_pnl: position.position_pnl,
|
position_pnl: position.position_pnl,
|
||||||
dividend_receivable: position.dividend_receivable,
|
dividend_receivable: position.dividend_receivable(),
|
||||||
old_quantity: position.day_start_quantity(),
|
old_quantity: position.day_start_quantity(),
|
||||||
bought_quantity: position.bought_quantity(),
|
bought_quantity: position.bought_quantity(),
|
||||||
sold_quantity: position.sold_quantity(),
|
sold_quantity: position.sold_quantity(),
|
||||||
@@ -1015,8 +1190,8 @@ impl PortfolioState {
|
|||||||
.map(|lot| PositionLot {
|
.map(|lot| PositionLot {
|
||||||
acquired_date: lot.acquired_date,
|
acquired_date: lot.acquired_date,
|
||||||
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
||||||
entry_price: lot.entry_price / ratio,
|
entry_value: lot.entry_value,
|
||||||
price: lot.price / ratio,
|
cost_basis: lot.cost_basis,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let expected_total = round_half_up_u32(old_quantity as f64 * ratio);
|
let expected_total = round_half_up_u32(old_quantity as f64 * ratio);
|
||||||
@@ -1046,8 +1221,14 @@ impl PortfolioState {
|
|||||||
let successor_average_cost_before = successor.average_cost;
|
let successor_average_cost_before = successor.average_cost;
|
||||||
successor.lots.extend(converted_lots);
|
successor.lots.extend(converted_lots);
|
||||||
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
||||||
successor.realized_pnl += realized_pnl;
|
successor.realized_pnl = successor
|
||||||
successor.realized_entry_pnl += realized_entry_pnl;
|
.realized_pnl
|
||||||
|
.checked_add(realized_pnl)
|
||||||
|
.expect("fixed-point successor realized PnL overflow");
|
||||||
|
successor.realized_entry_pnl = successor
|
||||||
|
.realized_entry_pnl
|
||||||
|
.checked_add(realized_entry_pnl)
|
||||||
|
.expect("fixed-point successor realized entry PnL overflow");
|
||||||
if converted_last_price > 0.0 {
|
if converted_last_price > 0.0 {
|
||||||
successor.last_price = converted_last_price;
|
successor.last_price = converted_last_price;
|
||||||
}
|
}
|
||||||
@@ -1092,11 +1273,6 @@ impl PortfolioState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fixed_money(value: f64, label: &str) -> Result<FixedMoney, String> {
|
|
||||||
FixedMoney::from_f64(value)
|
|
||||||
.ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sum_fixed_money(
|
fn sum_fixed_money(
|
||||||
values: impl IntoIterator<Item = f64>,
|
values: impl IntoIterator<Item = f64>,
|
||||||
label: &str,
|
label: &str,
|
||||||
@@ -1105,7 +1281,7 @@ impl PortfolioState {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.try_fold(FixedMoney::ZERO, |total, value| {
|
.try_fold(FixedMoney::ZERO, |total, value| {
|
||||||
total
|
total
|
||||||
.checked_add(Self::fixed_money(value, label)?)
|
.checked_add(fixed_money(value, label)?)
|
||||||
.ok_or_else(|| format!("fixed-point {label} overflow"))
|
.ok_or_else(|| format!("fixed-point {label} overflow"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1125,8 +1301,8 @@ impl PortfolioState {
|
|||||||
unit_net_value_before: f64,
|
unit_net_value_before: f64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if unit_net_value_before > 0.0 && unit_net_value_before.is_finite() {
|
if unit_net_value_before > 0.0 && unit_net_value_before.is_finite() {
|
||||||
let unit_nav = Self::fixed_money(unit_net_value_before, "unit net value")?;
|
let unit_nav = fixed_money(unit_net_value_before, "unit net value")?;
|
||||||
let total_equity = Self::fixed_money(self.total_equity(), "total equity")?;
|
let total_equity = fixed_money(self.total_equity(), "total equity")?;
|
||||||
let units_raw = total_equity
|
let units_raw = total_equity
|
||||||
.raw()
|
.raw()
|
||||||
.checked_mul(MONEY_SCALE)
|
.checked_mul(MONEY_SCALE)
|
||||||
@@ -1164,6 +1340,48 @@ mod tests {
|
|||||||
assert_eq!(portfolio.cash(), 1_000_000.0);
|
assert_eq!(portfolio.cash(), 1_000_000.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_lot_allocation_rounds_nearest_and_conserves_total() {
|
||||||
|
let total = FixedMoney::from_raw(5);
|
||||||
|
let allocated = allocate_fixed_value(total, 1, 2).expect("positive allocation");
|
||||||
|
let remainder = total.checked_sub(allocated).expect("positive remainder");
|
||||||
|
assert_eq!(allocated.raw(), 3);
|
||||||
|
assert_eq!(remainder.raw(), 2);
|
||||||
|
assert_eq!(allocated.checked_add(remainder), Some(total));
|
||||||
|
|
||||||
|
let negative_total = FixedMoney::from_raw(-5);
|
||||||
|
let negative_allocated =
|
||||||
|
allocate_fixed_value(negative_total, 1, 2).expect("negative allocation");
|
||||||
|
let negative_remainder = negative_total
|
||||||
|
.checked_sub(negative_allocated)
|
||||||
|
.expect("negative remainder");
|
||||||
|
assert_eq!(negative_allocated.raw(), -3);
|
||||||
|
assert_eq!(negative_remainder.raw(), -2);
|
||||||
|
assert_eq!(
|
||||||
|
negative_allocated.checked_add(negative_remainder),
|
||||||
|
Some(negative_total)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fifo_fixed_pnl_conserves_value_while_alv_average_cost_stays_stable() {
|
||||||
|
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||||
|
let mut position = Position::new("000001.SZ");
|
||||||
|
position.buy(date, 100, 10.0);
|
||||||
|
position.record_buy_trade_cost(100, 5.0);
|
||||||
|
position.buy(date, 100, 5.0);
|
||||||
|
position.record_buy_trade_cost(100, 5.0);
|
||||||
|
|
||||||
|
let realized = position.sell(100, 6.0).expect("partial FIFO sell");
|
||||||
|
|
||||||
|
assert_eq!(position.quantity, 100);
|
||||||
|
assert!((position.average_cost - 7.55).abs() < 1e-12);
|
||||||
|
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||||
|
assert!((realized + 405.0).abs() < 1e-12);
|
||||||
|
assert!((position.unrealized_pnl() - 95.0).abs() < 1e-12);
|
||||||
|
assert!((position.pnl() + 310.0).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn positions_preserve_insertion_order() {
|
fn positions_preserve_insertion_order() {
|
||||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||||
@@ -1471,7 +1689,7 @@ mod tests {
|
|||||||
.expect("close");
|
.expect("close");
|
||||||
|
|
||||||
let position = portfolio.position("000001.SZ").expect("position");
|
let position = portfolio.position("000001.SZ").expect("position");
|
||||||
assert!((position.dividend_receivable - 25.0).abs() < 1e-6);
|
assert!((position.dividend_receivable() - 25.0).abs() < 1e-6);
|
||||||
assert!((position.position_pnl - 70.0).abs() < 1e-6);
|
assert!((position.position_pnl - 70.0).abs() < 1e-6);
|
||||||
assert!((position.trading_pnl + 5.0).abs() < 1e-6);
|
assert!((position.trading_pnl + 5.0).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
@@ -1702,7 +1920,7 @@ mod tests {
|
|||||||
assert!((position.sell_avg_price() - 12.0).abs() < 1e-6);
|
assert!((position.sell_avg_price() - 12.0).abs() < 1e-6);
|
||||||
assert!((position.transaction_cost() - 3.0).abs() < 1e-6);
|
assert!((position.transaction_cost() - 3.0).abs() < 1e-6);
|
||||||
assert!((realized - 80.0).abs() < 1e-6);
|
assert!((realized - 80.0).abs() < 1e-6);
|
||||||
assert!((position.realized_pnl - 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.position_pnl - 200.0).abs() < 1e-6);
|
||||||
assert!((position.trading_pnl - 47.0).abs() < 1e-6);
|
assert!((position.trading_pnl - 47.0).abs() < 1e-6);
|
||||||
assert!((position.pnl() - (80.0 + position.unrealized_pnl())).abs() < 1e-6);
|
assert!((position.pnl() - (80.0 + position.unrealized_pnl())).abs() < 1e-6);
|
||||||
|
|||||||
Reference in New Issue
Block a user