将股票执行资金切换为定点账本

This commit is contained in:
boris
2026-08-25 14:36:15 +08:00
parent c9ddff46dd
commit 92724c6ab0
9 changed files with 640 additions and 250 deletions
+98 -36
View File
@@ -10,6 +10,7 @@ use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind,
};
use crate::fixed_point::FixedMoney;
use crate::instrument::Instrument;
use crate::portfolio::PortfolioState;
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, RiskCheckScope};
@@ -2585,7 +2586,7 @@ where
} else {
0.0
};
if buy_cash_out <= projected_cash + 1e-6 {
if Self::fixed_cash_fits(buy_cash_out, projected_cash) {
if proportion_diff < best_proportion_diff - 1e-12
|| ((proportion_diff - best_proportion_diff).abs() <= 1e-12
&& safety_value > best_safety)
@@ -3150,9 +3151,14 @@ where
if quantity == 0 {
return 0.0;
}
let gross = price * quantity as f64;
let cost = self.cost_model.calculate(date, OrderSide::Sell, gross);
gross - cost.total()
let gross = Self::fixed_gross_amount(price, quantity);
let cost = self
.cost_model
.calculate(date, OrderSide::Sell, gross.to_f64());
gross
.checked_sub(cost.fixed_total())
.expect("fixed-point sell proceeds underflow")
.to_f64()
}
fn sell_target_denial_reason(
@@ -3256,9 +3262,23 @@ where
if quantity == 0 {
return 0.0;
}
let gross = price * quantity as f64;
let cost = self.cost_model.calculate(date, OrderSide::Buy, gross);
gross + cost.total()
let gross = Self::fixed_gross_amount(price, quantity);
let cost = self
.cost_model
.calculate(date, OrderSide::Buy, gross.to_f64());
gross
.checked_add(cost.fixed_total())
.expect("fixed-point buy cash overflow")
.to_f64()
}
fn fixed_gross_amount(price: f64, quantity: u32) -> FixedMoney {
FixedMoney::from_f64(price * quantity as f64)
.expect("execution gross amount must be finite fixed-point money")
}
fn fixed_cash_fits(value: f64, limit: f64) -> bool {
FixedMoney::f64_fits_within(value, limit).unwrap_or(false)
}
fn can_afford_minimum_buy(
@@ -3283,8 +3303,10 @@ where
}
let minimum_execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity));
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity)
<= portfolio.cash() + 1e-6
Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity),
portfolio.cash(),
)
}
fn process_sell(
@@ -3722,7 +3744,8 @@ where
}
for leg in &execution_legs {
let leg_cash_before = portfolio.cash();
let gross_amount = leg.price * leg.quantity as f64;
let gross_money = Self::fixed_gross_amount(leg.price, leg.quantity);
let gross_amount = gross_money.to_f64();
let cost = self.cost_model.calculate_with_order_state(
date,
OrderSide::Sell,
@@ -3730,7 +3753,10 @@ where
Some(order_id),
commission_state,
);
let net_cash = gross_amount - cost.total();
let net_cash = gross_money
.checked_sub(cost.fixed_total())
.expect("fixed-point sell proceeds underflow")
.to_f64();
let realized_pnl = portfolio
.position_mut(symbol)
.sell_with_mark_price(leg.quantity, leg.price, leg.mark_price)
@@ -3738,7 +3764,9 @@ where
if let Some(position) = portfolio.position_mut_if_exists(symbol) {
position.record_trade_cost(cost.total());
}
portfolio.apply_cash_delta(net_cash);
portfolio
.apply_cash_delta(net_cash)
.map_err(BacktestError::Execution)?;
report.fill_events.push(FillEvent {
date,
@@ -5371,7 +5399,8 @@ where
}
for leg in &execution_legs {
let leg_cash_before = portfolio.cash();
let gross_amount = leg.price * leg.quantity as f64;
let gross_money = Self::fixed_gross_amount(leg.price, leg.quantity);
let gross_amount = gross_money.to_f64();
let cost = self.cost_model.calculate_with_order_state(
date,
OrderSide::Buy,
@@ -5379,9 +5408,14 @@ where
Some(order_id),
commission_state,
);
let cash_out = gross_amount + cost.total();
let cash_out = gross_money
.checked_add(cost.fixed_total())
.expect("fixed-point buy cash overflow")
.to_f64();
portfolio.apply_cash_delta(-cash_out);
portfolio
.apply_cash_delta(-cash_out)
.map_err(BacktestError::Execution)?;
portfolio.position_mut(symbol).buy_with_mark_price(
date,
leg.quantity,
@@ -5766,7 +5800,10 @@ where
let mut quantity =
self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size);
while quantity >= minimum {
if self.estimated_buy_cash_out(date, price, quantity) <= value_budget + 1e-6 {
if Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, price, quantity),
value_budget,
) {
return quantity;
}
quantity =
@@ -5820,7 +5857,10 @@ where
})
.filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price);
if self.estimated_buy_cash_out(date, execution_price, quantity) <= value_budget + 1e-6 {
if Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, execution_price, quantity),
value_budget,
) {
return quantity;
}
quantity =
@@ -5857,7 +5897,7 @@ where
self.round_buy_quantity(requested_qty, minimum_order_quantity, order_step_size);
while quantity > 0 {
let gross = price * quantity as f64;
if gross_limit.is_some_and(|limit| gross > limit + 1e-6) {
if gross_limit.is_some_and(|limit| !Self::fixed_cash_fits(gross, limit)) {
quantity = self.decrement_order_quantity(
quantity,
minimum_order_quantity,
@@ -5866,7 +5906,10 @@ where
continue;
}
let cost = self.cost_model.calculate(date, OrderSide::Buy, gross);
if gross + cost.total() <= cash + 1e-6 {
let cash_out = FixedMoney::checked_sum_f64([gross, cost.total()])
.expect("buy cash must be finite fixed-point money")
.to_f64();
if Self::fixed_cash_fits(cash_out, cash) {
return quantity;
}
quantity =
@@ -5886,7 +5929,9 @@ where
if filled_qty >= requested_qty {
return None;
}
if gross_limit.is_some_and(|limit| price * requested_qty as f64 > limit + 1e-6) {
if gross_limit
.is_some_and(|limit| !Self::fixed_cash_fits(price * requested_qty as f64, limit))
{
Some("value budget limit")
} else if cash_limit.is_finite() {
Some("insufficient cash after fees")
@@ -6326,7 +6371,9 @@ where
break;
}
let candidate_gross = gross_amount + quote_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
if gross_limit
.is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit))
{
budget_block_reason = Some("value budget limit");
take_qty = self.decrement_order_quantity(
take_qty,
@@ -6339,7 +6386,11 @@ where
.cost_model
.calculate(snapshot.date, OrderSide::Buy, candidate_gross)
.total();
if candidate_gross + candidate_cost <= cash + 1e-6 {
let candidate_cash =
FixedMoney::checked_sum_f64([candidate_gross, candidate_cost])
.expect("buy cash must be finite fixed-point money")
.to_f64();
if Self::fixed_cash_fits(candidate_cash, cash) {
break;
}
budget_block_reason = Some("insufficient cash after fees");
@@ -6586,6 +6637,7 @@ mod tests {
IntradayExecutionQuote, PriceField,
};
use crate::events::{OrderSide, OrderStatus};
use crate::fixed_point::FixedMoney;
use crate::instrument::Instrument;
use crate::portfolio::PortfolioState;
use crate::risk_control::FidcRiskControlConfig;
@@ -7650,7 +7702,7 @@ mod tests {
1_000,
10.0,
);
portfolio.apply_cash_delta(-10_000.0);
portfolio.apply_cash_delta(-10_000.0).unwrap();
let mut report = BrokerExecutionReport::default();
broker
@@ -7708,7 +7760,7 @@ mod tests {
10_000,
10.0,
);
portfolio.apply_cash_delta(-100_000.0);
portfolio.apply_cash_delta(-100_000.0).unwrap();
let mut report = BrokerExecutionReport::default();
broker
@@ -7766,7 +7818,7 @@ mod tests {
1_000,
10.0,
);
portfolio.apply_cash_delta(-10_000.0);
portfolio.apply_cash_delta(-10_000.0).unwrap();
let mut report = BrokerExecutionReport::default();
broker
@@ -7856,7 +7908,7 @@ mod tests {
1_000,
10.0,
);
portfolio.apply_cash_delta(-10_000.0);
portfolio.apply_cash_delta(-10_000.0).unwrap();
let mut report = BrokerExecutionReport::default();
broker
@@ -7943,7 +7995,7 @@ mod tests {
10_000,
10.0,
);
portfolio.apply_cash_delta(-100_000.0);
portfolio.apply_cash_delta(-100_000.0).unwrap();
let mut report = BrokerExecutionReport::default();
broker
@@ -9122,7 +9174,7 @@ mod tests {
.expect("valid dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio.position_mut(symbol).buy(prev_date, 72_600, 4.0);
portfolio.apply_cash_delta(-290_400.0);
portfolio.apply_cash_delta(-290_400.0).unwrap();
let mut report = BrokerExecutionReport::default();
broker
@@ -9166,13 +9218,10 @@ mod tests {
let date = chrono::NaiveDate::from_ymd_opt(2023, 5, 8).expect("valid date");
let symbol = "603101.SH";
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel {
commission_rate: 0.0003,
stamp_tax_rate_before_change: 0.0005,
stamp_tax_rate_after_change: 0.0005,
minimum_commission: 5.0,
..ChinaAShareCostModel::default()
},
ChinaAShareCostModel::default()
.with_commission_rate(0.0003)
.with_stamp_tax_rates(0.0005, 0.0005)
.with_minimum_commission(5.0),
ChinaEquityRuleHooks,
PriceField::Last,
)
@@ -9515,7 +9564,20 @@ mod tests {
let fill = report.fill_events.first().expect("fill event");
assert_eq!(fill.quantity, 17_400);
assert!(fill.gross_amount + fill.commission <= value_budget + 1e-6);
let cash_out = FixedMoney::checked_sum_f64([
fill.gross_amount,
fill.commission,
fill.stamp_tax,
fill.transfer_fee,
])
.unwrap()
.to_f64();
assert!(
BrokerSimulator::<ChinaAShareCostModel, ChinaEquityRuleHooks>::fixed_cash_fits(
cash_out,
value_budget
)
);
assert!((fill.price - 7.15428).abs() < 1e-6);
}
+125 -61
View File
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
use chrono::NaiveDate;
use crate::events::OrderSide;
use crate::fixed_point::{FixedChinaAShareCostModel, FixedMoney, FixedTradingCost};
use crate::risk_control::TradingConstraintConfig;
#[derive(Debug, Clone, Copy)]
@@ -14,7 +15,20 @@ pub struct TradingCost {
impl TradingCost {
pub fn total(self) -> f64 {
self.commission + self.stamp_tax + self.transfer_fee
self.fixed_total().to_f64()
}
pub fn fixed_total(self) -> FixedMoney {
FixedMoney::checked_sum_f64([self.commission, self.stamp_tax, self.transfer_fee])
.expect("trading costs must be finite fixed-point money")
}
fn from_fixed(value: FixedTradingCost) -> Self {
Self {
commission: value.commission.to_f64(),
stamp_tax: value.stamp_tax.to_f64(),
transfer_fee: value.transfer_fee.to_f64(),
}
}
}
@@ -35,12 +49,7 @@ pub trait CostModel {
#[derive(Debug, Clone, Copy)]
pub struct ChinaAShareCostModel {
pub commission_rate: f64,
pub stamp_tax_rate_before_change: f64,
pub stamp_tax_rate_after_change: f64,
pub stamp_tax_change_date: NaiveDate,
pub minimum_commission: f64,
pub transfer_fee_rate: f64,
fixed: FixedChinaAShareCostModel,
}
impl Default for ChinaAShareCostModel {
@@ -52,42 +61,93 @@ impl Default for ChinaAShareCostModel {
impl ChinaAShareCostModel {
pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self {
Self {
commission_rate: config.commission_rate,
stamp_tax_rate_before_change: config.stamp_tax_rate_before_change,
stamp_tax_rate_after_change: config.stamp_tax_rate_after_change,
stamp_tax_change_date: config.stamp_tax_change_date,
minimum_commission: config.minimum_commission,
transfer_fee_rate: config.transfer_fee_rate,
fixed: FixedChinaAShareCostModel {
commission_rate: Self::fixed_money(config.commission_rate, "commission rate"),
stamp_tax_rate_before_change: Self::fixed_money(
config.stamp_tax_rate_before_change,
"stamp tax rate before change",
),
stamp_tax_rate_after_change: Self::fixed_money(
config.stamp_tax_rate_after_change,
"stamp tax rate after change",
),
stamp_tax_change_date: config.stamp_tax_change_date,
minimum_commission: Self::fixed_money(
config.minimum_commission,
"minimum commission",
),
transfer_fee_rate: Self::fixed_money(config.transfer_fee_rate, "transfer fee rate"),
},
}
}
pub fn set_commission_rate(&mut self, value: f64) {
self.fixed.commission_rate = Self::fixed_money(value, "commission rate");
}
pub fn set_minimum_commission(&mut self, value: f64) {
self.fixed.minimum_commission = Self::fixed_money(value, "minimum commission");
}
pub fn set_stamp_tax_rate_before_change(&mut self, value: f64) {
self.fixed.stamp_tax_rate_before_change =
Self::fixed_money(value, "stamp tax rate before change");
}
pub fn set_stamp_tax_rate_after_change(&mut self, value: f64) {
self.fixed.stamp_tax_rate_after_change =
Self::fixed_money(value, "stamp tax rate after change");
}
pub fn set_stamp_tax_change_date(&mut self, value: NaiveDate) {
self.fixed.stamp_tax_change_date = value;
}
pub fn with_commission_rate(mut self, value: f64) -> Self {
self.set_commission_rate(value);
self
}
pub fn with_minimum_commission(mut self, value: f64) -> Self {
self.set_minimum_commission(value);
self
}
pub fn with_stamp_tax_rates(mut self, before: f64, after: f64) -> Self {
self.set_stamp_tax_rate_before_change(before);
self.set_stamp_tax_rate_after_change(after);
self
}
pub fn commission_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 {
return 0.0;
}
(gross_amount * self.commission_rate).max(self.minimum_commission)
self.fixed_model()
.commission_for(Self::fixed_money(gross_amount, "gross amount"))
.to_f64()
}
pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 {
if date < self.stamp_tax_change_date {
self.stamp_tax_rate_before_change
} else {
self.stamp_tax_rate_after_change
}
self.fixed.stamp_tax_rate_for(date).to_f64()
}
pub fn stamp_tax_for(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 || side == OrderSide::Buy {
return 0.0;
}
gross_amount * self.stamp_tax_rate_for(date)
self.fixed_model()
.stamp_tax_for(date, side, Self::fixed_money(gross_amount, "gross amount"))
.to_f64()
}
pub fn transfer_fee_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 {
return 0.0;
}
gross_amount * self.transfer_fee_rate
self.fixed_model()
.transfer_fee_for(Self::fixed_money(gross_amount, "gross amount"))
.to_f64()
}
pub fn commission_for_order_fill(
@@ -100,31 +160,29 @@ impl ChinaAShareCostModel {
return 0.0;
}
let raw_commission = gross_amount * self.commission_rate;
let Some(order_id) = order_id else {
return raw_commission.max(self.minimum_commission);
return self.commission_for(gross_amount);
};
let remaining_minimum = commission_state
.entry(order_id)
.or_insert(self.minimum_commission);
if raw_commission > *remaining_minimum {
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 {
raw_commission
} else {
raw_commission - *remaining_minimum
};
*remaining_minimum = 0.0;
charged
} else {
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 {
self.minimum_commission
} else {
0.0
};
*remaining_minimum -= raw_commission;
charged
}
.or_insert(self.fixed.minimum_commission.to_f64());
let mut fixed_remaining = Self::fixed_money(*remaining_minimum, "remaining commission");
let charged = self.fixed_model().commission_for_order_fill_remaining(
Self::fixed_money(gross_amount, "gross amount"),
&mut fixed_remaining,
);
*remaining_minimum = fixed_remaining.to_f64();
charged.to_f64()
}
fn fixed_money(value: f64, label: &str) -> FixedMoney {
FixedMoney::from_f64(value)
.unwrap_or_else(|| panic!("{label} is not representable as fixed-point money: {value}"))
}
fn fixed_model(&self) -> FixedChinaAShareCostModel {
self.fixed
}
}
@@ -138,15 +196,11 @@ impl CostModel for ChinaAShareCostModel {
};
}
let commission = self.commission_for(gross_amount);
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
let transfer_fee = self.transfer_fee_for(gross_amount);
TradingCost {
commission,
stamp_tax,
transfer_fee,
}
TradingCost::from_fixed(self.fixed_model().calculate(
date,
side,
Self::fixed_money(gross_amount, "gross amount"),
))
}
fn calculate_with_order_state(
@@ -165,15 +219,25 @@ impl CostModel for ChinaAShareCostModel {
};
}
let commission = self.commission_for_order_fill(gross_amount, order_id, commission_state);
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
let transfer_fee = self.transfer_fee_for(gross_amount);
TradingCost {
let fixed_model = self.fixed_model();
let fixed_gross = Self::fixed_money(gross_amount, "gross amount");
let commission = if let Some(order_id) = order_id {
let remaining = commission_state
.entry(order_id)
.or_insert(self.fixed.minimum_commission.to_f64());
let mut fixed_remaining = Self::fixed_money(*remaining, "remaining commission");
let commission =
fixed_model.commission_for_order_fill_remaining(fixed_gross, &mut fixed_remaining);
*remaining = fixed_remaining.to_f64();
commission
} else {
fixed_model.commission_for(fixed_gross)
};
TradingCost::from_fixed(FixedTradingCost {
commission,
stamp_tax,
transfer_fee,
}
stamp_tax: fixed_model.stamp_tax_for(date, side, fixed_gross),
transfer_fee: fixed_model.transfer_fee_for(fixed_gross),
})
}
}
@@ -182,13 +246,13 @@ mod tests {
use super::*;
#[test]
fn default_matches_configurable_trading_constraints() {
fn default_quantizes_fees_to_micro_yuan() {
let model = ChinaAShareCostModel::default();
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
assert!((model.commission_for(248_059.812) - 74.4179436).abs() < 1e-9);
assert!((model.commission_for(248_059.812) - 74.417944).abs() < 1e-12);
assert!(
(model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.8735035).abs() < 1e-9
(model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.873504).abs() < 1e-12
);
}
+6 -2
View File
@@ -3217,7 +3217,9 @@ where
});
if outcome.cash_delta.abs() > f64::EPSILON {
let cash_before = portfolio.cash();
portfolio.apply_cash_delta(outcome.cash_delta);
portfolio
.apply_cash_delta(outcome.cash_delta)
.map_err(BacktestError::Execution)?;
report.account_events.push(AccountEvent {
date,
cash_before,
@@ -3279,7 +3281,9 @@ where
if reinvest_quantity > 0 {
let reinvest_cash = reinvest_quantity as f64 * price;
let residual_cash = receivable.amount - reinvest_cash;
portfolio.apply_cash_delta(-reinvest_cash);
portfolio
.apply_cash_delta(-reinvest_cash)
.map_err(BacktestError::Execution)?;
portfolio.position_mut(&receivable.symbol).buy(
date,
reinvest_quantity,
+51 -16
View File
@@ -1,10 +1,9 @@
//! Independent fixed-point acceptance model for money and fee arithmetic.
//! Fixed-point execution primitives for money and fee arithmetic.
//!
//! The execution kernel still exposes f64 because prices and source rows are
//! represented that way today. This module is deliberately separate: it is a
//! deterministic shadow model used to prove that cash, fees, budget checks,
//! FIFO PnL, and external cash flows do not depend on binary floating-point
//! accumulation.
//! Market data and analytics remain floating point at their API boundaries.
//! The execution kernel quantizes monetary values to micro-yuan before fee,
//! budget and cash-ledger arithmetic so repeated fills and external cash flows
//! do not accumulate binary floating-point drift.
use std::collections::{BTreeMap, VecDeque};
@@ -105,6 +104,10 @@ impl FixedMoney {
self.0.checked_mul(i128::from(quantity)).map(Self)
}
pub fn checked_neg(self) -> Option<Self> {
self.0.checked_neg().map(Self)
}
pub fn checked_mul_rate(self, rate: Self) -> Option<Self> {
let product = self.0.checked_mul(rate.0)?;
let half = MONEY_SCALE / 2;
@@ -116,6 +119,20 @@ impl FixedMoney {
Some(Self(rounded))
}
pub fn checked_sum_f64(values: impl IntoIterator<Item = f64>) -> Option<Self> {
values.into_iter().try_fold(Self::ZERO, |total, value| {
total.checked_add(Self::from_f64(value)?)
})
}
pub fn f64_fits_within(value: f64, limit: f64) -> Option<bool> {
let value = Self::from_f64(value)?;
if limit == f64::INFINITY {
return Some(true);
}
Some(value <= Self::from_f64(limit)?)
}
pub fn abs(self) -> Self {
Self(self.0.abs())
}
@@ -217,6 +234,20 @@ impl FixedChinaAShareCostModel {
let remaining = commission_state
.entry(order_id)
.or_insert(self.minimum_commission);
self.commission_for_order_fill_remaining(gross_amount, remaining)
}
pub fn commission_for_order_fill_remaining(
self,
gross_amount: FixedMoney,
remaining: &mut FixedMoney,
) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
let raw = gross_amount
.checked_mul_rate(self.commission_rate)
.expect("fixed commission multiplication overflow");
if raw > *remaining {
let charged = if *remaining == self.minimum_commission {
raw
@@ -415,7 +446,7 @@ mod tests {
}
#[test]
fn fixed_cost_matches_float_cost_model_within_one_micro_yuan() {
fn runtime_cost_model_matches_fixed_execution_primitive() {
let fixed = fixed_model();
let float = ChinaAShareCostModel::default();
let dates = [
@@ -433,13 +464,7 @@ mod tests {
(actual.stamp_tax, expected.stamp_tax),
(actual.transfer_fee, expected.transfer_fee),
] {
assert!(
(actual.to_f64() - expected).abs() <= 1.0 / MONEY_SCALE_F64,
"fixed={} float={} gross={} date={date} side={side:?}",
actual.to_f64(),
expected,
gross
);
assert_eq!(actual.to_f64(), expected);
}
}
}
@@ -447,7 +472,7 @@ mod tests {
}
#[test]
fn fixed_order_commission_state_matches_float_order_split() {
fn runtime_split_commission_matches_fixed_execution_primitive() {
let fixed = fixed_model();
let float = ChinaAShareCostModel::default();
let mut fixed_state = BTreeMap::new();
@@ -464,7 +489,7 @@ mod tests {
fixed_total = fixed_total.checked_add(fixed_fee).unwrap();
float_total += float_fee;
}
assert!((fixed_total.to_f64() - float_total).abs() <= 4.0 / MONEY_SCALE_F64);
assert_eq!(fixed_total.to_f64(), float_total);
}
#[test]
@@ -493,6 +518,16 @@ mod tests {
assert!(quantity < 5_000);
}
#[test]
fn fixed_budget_comparison_rejects_one_micro_yuan_overrun() {
assert_eq!(FixedMoney::f64_fits_within(100.0, 100.0), Some(true));
assert_eq!(FixedMoney::f64_fits_within(100.000001, 100.0), Some(false));
assert_eq!(
FixedMoney::f64_fits_within(100.000001, f64::INFINITY),
Some(true)
);
}
#[test]
fn fixed_fifo_pnl_and_external_flow_are_deterministic() {
let day_one = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
+75 -33
View File
@@ -14,6 +14,7 @@ use crate::data::{
};
use crate::engine::BacktestError;
use crate::events::OrderSide;
use crate::fixed_point::FixedMoney;
use crate::numeric_expr_vm::{
self, EvalError as NumericVmEvalError, Program as NumericVmProgram,
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
@@ -1904,14 +1905,47 @@ impl PlatformExprStrategy {
(holding_days >= max_days).then_some(holding_days)
}
fn buy_commission(&self, gross_amount: f64) -> f64 {
self.cost_model().commission_for(gross_amount)
fn buy_cost(&self, gross_amount: f64) -> f64 {
let model = self.cost_model();
FixedMoney::checked_sum_f64([
model.commission_for(gross_amount),
model.transfer_fee_for(gross_amount),
])
.expect("projected buy costs must be finite fixed-point money")
.to_f64()
}
fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 {
let model = self.cost_model();
model.commission_for(gross_amount)
+ model.stamp_tax_for(date, OrderSide::Sell, gross_amount)
FixedMoney::checked_sum_f64([
model.commission_for(gross_amount),
model.stamp_tax_for(date, OrderSide::Sell, gross_amount),
model.transfer_fee_for(gross_amount),
])
.expect("projected sell costs must be finite fixed-point money")
.to_f64()
}
fn buy_cash_out(&self, gross_amount: f64) -> f64 {
FixedMoney::checked_sum_f64([gross_amount, self.buy_cost(gross_amount)])
.expect("projected buy cash must be finite fixed-point money")
.to_f64()
}
fn sell_net_cash(&self, date: NaiveDate, gross_amount: f64) -> f64 {
let gross = FixedMoney::from_f64(gross_amount)
.expect("projected sell gross must be finite fixed-point money");
gross
.checked_sub(
FixedMoney::from_f64(self.sell_cost(date, gross.to_f64()))
.expect("projected sell costs must be finite fixed-point money"),
)
.expect("projected sell proceeds underflow")
.to_f64()
}
fn fixed_cash_fits(value: f64, limit: f64) -> bool {
FixedMoney::f64_fits_within(value, limit).unwrap_or(false)
}
fn cost_model(&self) -> ChinaAShareCostModel {
@@ -1919,19 +1953,19 @@ impl PlatformExprStrategy {
self.config.risk_config.trading_constraints,
);
if let Some(value) = self.config.commission_rate {
model.commission_rate = value;
model.set_commission_rate(value);
}
if let Some(value) = self.config.minimum_commission {
model.minimum_commission = value;
model.set_minimum_commission(value);
}
if let Some(value) = self.config.stamp_tax_rate_before_change {
model.stamp_tax_rate_before_change = value;
model.set_stamp_tax_rate_before_change(value);
}
if let Some(value) = self.config.stamp_tax_rate_after_change {
model.stamp_tax_rate_after_change = value;
model.set_stamp_tax_rate_after_change(value);
}
if let Some(value) = self.config.stamp_tax_change_date {
model.stamp_tax_change_date = value;
model.set_stamp_tax_change_date(value);
}
model
}
@@ -2026,7 +2060,7 @@ impl PlatformExprStrategy {
self.round_lot_quantity(raw_quantity, minimum_order_quantity, order_step_size);
while quantity >= minimum {
let gross_amount = price * quantity as f64;
if gross_amount + self.buy_commission(gross_amount) <= value_budget + 1e-6 {
if Self::fixed_cash_fits(self.buy_cash_out(gross_amount), value_budget) {
return quantity;
}
quantity =
@@ -2609,7 +2643,9 @@ impl PlatformExprStrategy {
break;
}
let candidate_gross = gross_amount + quote_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
if gross_limit
.is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit))
{
take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
@@ -2617,7 +2653,7 @@ impl PlatformExprStrategy {
);
continue;
}
if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 {
if Self::fixed_cash_fits(self.buy_cash_out(candidate_gross), cash) {
break;
}
take_qty = self.decrement_order_quantity(
@@ -2772,12 +2808,14 @@ impl PlatformExprStrategy {
}
})?;
let gross_amount = fill.price * fill.quantity as f64;
let sell_cost = self.sell_cost(date, gross_amount);
let net_cash = self.sell_net_cash(date, gross_amount);
projected
.position_mut(symbol)
.sell(fill.quantity, fill.price)
.ok()?;
projected.apply_cash_delta(gross_amount - sell_cost);
projected
.apply_cash_delta(net_cash)
.expect("projected sell cash must fit fixed-point ledger");
*execution_state
.intraday_turnover
.entry(symbol.to_string())
@@ -2872,12 +2910,14 @@ impl PlatformExprStrategy {
execution_state,
)?;
let gross_amount = fill.price * fill.quantity as f64;
let sell_cost = self.sell_cost(date, gross_amount);
let net_cash = self.sell_net_cash(date, gross_amount);
projected
.position_mut(symbol)
.sell(fill.quantity, fill.price)
.ok()?;
projected.apply_cash_delta(gross_amount - sell_cost);
projected
.apply_cash_delta(net_cash)
.expect("projected sell cash must fit fixed-point ledger");
*execution_state
.intraday_turnover
.entry(symbol.to_string())
@@ -3339,8 +3379,8 @@ impl PlatformExprStrategy {
};
while quantity > 0 {
let gross_amount = sizing_price * quantity as f64;
if gross_limit.map_or(true, |limit| gross_amount <= limit + 1e-6)
&& gross_amount + self.buy_commission(gross_amount) <= cash_limit + 1e-6
if gross_limit.is_none_or(|limit| Self::fixed_cash_fits(gross_amount, limit))
&& Self::fixed_cash_fits(self.buy_cash_out(gross_amount), cash_limit)
{
break;
}
@@ -3411,11 +3451,13 @@ impl PlatformExprStrategy {
return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity);
};
let gross_amount = fill.price * fill.quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out > cash_limit + 1e-6 {
let cash_out = self.buy_cash_out(gross_amount);
if !Self::fixed_cash_fits(cash_out, cash_limit) {
return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity);
}
projected.apply_cash_delta(-cash_out);
projected
.apply_cash_delta(-cash_out)
.expect("projected buy cash must fit fixed-point ledger");
projected
.position_mut(symbol)
.buy(date, fill.quantity, fill.price);
@@ -13401,8 +13443,8 @@ mod tests {
cfg.minimum_commission = Some(5.0);
let strategy = PlatformExprStrategy::new(cfg);
assert!((strategy.buy_commission(100_000.0) - 30.0).abs() < 1e-9);
assert!((strategy.buy_commission(1_000.0) - 5.0).abs() < 1e-9);
assert!((strategy.buy_cost(100_000.0) - 30.0).abs() < 1e-9);
assert!((strategy.buy_cost(1_000.0) - 5.0).abs() < 1e-9);
}
#[test]
@@ -13412,7 +13454,7 @@ mod tests {
cfg.minimum_commission = Some(0.0);
let strategy = PlatformExprStrategy::new(cfg);
assert!((strategy.buy_commission(1_000.0) - 0.3).abs() < 1e-9);
assert!((strategy.buy_cost(1_000.0) - 0.3).abs() < 1e-9);
}
#[test]
@@ -18211,7 +18253,7 @@ mod tests {
portfolio
.position_mut(symbol)
.buy(d(2023, 5, 4), 30_000, 10.0);
portfolio.apply_cash_delta(-300_000.0);
portfolio.apply_cash_delta(-300_000.0).unwrap();
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
@@ -18324,7 +18366,7 @@ mod tests {
portfolio
.position_mut(symbol)
.buy(d(2023, 5, 4), 30_000, 10.0);
portfolio.apply_cash_delta(-300_000.0);
portfolio.apply_cash_delta(-300_000.0).unwrap();
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
@@ -18455,7 +18497,7 @@ mod tests {
portfolio
.position_mut(symbol)
.buy(d(2023, 5, 4), 30_000, 10.0);
portfolio.apply_cash_delta(-300_000.0);
portfolio.apply_cash_delta(-300_000.0).unwrap();
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
@@ -18582,7 +18624,7 @@ mod tests {
portfolio
.position_mut(symbol)
.buy(d(2023, 5, 4), 30_000, 10.0);
portfolio.apply_cash_delta(-300_000.0);
portfolio.apply_cash_delta(-300_000.0).unwrap();
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date,
@@ -18736,7 +18778,7 @@ mod tests {
portfolio
.position_mut("000002.SZ")
.buy(prev_date, 2_400, 10.0);
portfolio.apply_cash_delta(-54_000.0);
portfolio.apply_cash_delta(-54_000.0).unwrap();
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
@@ -18897,7 +18939,7 @@ mod tests {
.expect("dataset");
let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut(symbol).buy(prev_date, 3_000, 10.0);
portfolio.apply_cash_delta(-30_000.0);
portfolio.apply_cash_delta(-30_000.0).unwrap();
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
@@ -19053,7 +19095,7 @@ mod tests {
.expect("dataset");
let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut(symbol).buy(prev_date, 3_000, 10.0);
portfolio.apply_cash_delta(-30_000.0);
portfolio.apply_cash_delta(-30_000.0).unwrap();
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
@@ -19209,7 +19251,7 @@ mod tests {
.expect("dataset");
let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut(symbol).buy(prev_date, 3_000, 10.0);
portfolio.apply_cash_delta(-30_000.0);
portfolio.apply_cash_delta(-30_000.0).unwrap();
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
@@ -19370,7 +19412,7 @@ mod tests {
portfolio
.position_mut("000002.SZ")
.buy(prev_date, 2_400, 10.0);
portfolio.apply_cash_delta(-54_000.0);
portfolio.apply_cash_delta(-54_000.0).unwrap();
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
+206 -73
View File
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use crate::data::{DataSet, DataSetError, PriceField};
use crate::fixed_point::{FixedMoney, MONEY_SCALE};
#[derive(Debug, Clone)]
pub struct PositionLot {
@@ -429,17 +430,17 @@ fn normalized_mark_price(mark_price: f64, fallback: f64) -> f64 {
#[derive(Debug, Clone)]
pub struct PortfolioState {
initial_cash: f64,
units: f64,
cash: f64,
initial_cash: FixedMoney,
units: FixedMoney,
cash: FixedMoney,
/// Cumulative external cash flow (deposits positive, withdrawals negative).
/// Trading proceeds, dividends, fees and financing are deliberately not
/// included. The value is used by the engine to build a cash-flow-neutral
/// equity curve and is not a return measure itself.
external_cash_flow_total: f64,
cash_liabilities: f64,
external_cash_flow_total: FixedMoney,
cash_liabilities: FixedMoney,
management_fee_rate: f64,
management_fees: f64,
management_fees: FixedMoney,
positions: IndexMap<String, Position>,
cash_receivables: Vec<CashReceivable>,
pending_cash_flows: Vec<PendingCashFlow>,
@@ -466,14 +467,16 @@ pub(crate) struct SuccessorConversionOutcome {
impl PortfolioState {
pub fn new(initial_cash: f64) -> Self {
let initial_cash = Self::fixed_money(initial_cash, "initial cash")
.expect("initial cash must be finite fixed-point money");
Self {
initial_cash,
units: initial_cash,
cash: initial_cash,
external_cash_flow_total: 0.0,
cash_liabilities: 0.0,
external_cash_flow_total: FixedMoney::ZERO,
cash_liabilities: FixedMoney::ZERO,
management_fee_rate: 0.0,
management_fees: 0.0,
management_fees: FixedMoney::ZERO,
positions: IndexMap::new(),
cash_receivables: Vec::new(),
pending_cash_flows: Vec::new(),
@@ -484,27 +487,27 @@ impl PortfolioState {
pub fn starting_cash(&self) -> f64 {
// Keep the configured opening capital stable. External flows change
// `units`, not the meaning of this reporting field.
self.initial_cash
self.initial_cash.to_f64()
}
pub fn initial_cash(&self) -> f64 {
self.initial_cash
self.initial_cash.to_f64()
}
pub fn units(&self) -> f64 {
self.units
self.units.to_f64()
}
pub fn cash(&self) -> f64 {
self.cash
self.cash.to_f64()
}
pub fn external_cash_flow_total(&self) -> f64 {
self.external_cash_flow_total
self.external_cash_flow_total.to_f64()
}
pub fn cash_liabilities(&self) -> f64 {
self.cash_liabilities
self.cash_liabilities.to_f64()
}
pub fn management_fee_rate(&self) -> f64 {
@@ -512,7 +515,7 @@ impl PortfolioState {
}
pub fn management_fees(&self) -> f64 {
self.management_fees
self.management_fees.to_f64()
}
pub fn positions(&self) -> &IndexMap<String, Position> {
@@ -533,8 +536,12 @@ impl PortfolioState {
.or_insert_with(|| Position::new(symbol))
}
pub fn apply_cash_delta(&mut self, delta: f64) {
self.cash += delta;
pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> {
self.cash = self
.cash
.checked_add(Self::fixed_money(delta, "cash delta")?)
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
Ok(())
}
pub fn prune_flat_positions(&mut self) {
@@ -558,21 +565,34 @@ impl PortfolioState {
}
pub fn deposit_withdraw(&mut self, amount: f64) -> Result<(), String> {
if !amount.is_finite() {
return Err("deposit_withdraw amount must be finite".to_string());
}
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
let available_cash = self.cash - self.pending_withdrawal_total();
let amount_money = Self::fixed_money(amount, "deposit_withdraw amount")?;
let pending_withdrawal =
Self::fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
let available_cash = self
.cash
.checked_sub(pending_withdrawal)
.ok_or_else(|| "fixed-point available cash overflow".to_string())?;
let cash_after = available_cash
.checked_add(amount_money)
.ok_or_else(|| "fixed-point deposit_withdraw overflow".to_string())?;
if amount_money.raw() < 0 && cash_after.raw() < 0 {
return Err(format!(
"insufficient cash for withdrawal amount={:.2} available_cash={:.2}",
amount, available_cash
amount,
available_cash.to_f64()
));
}
let unit_net_value = self.unit_net_value();
self.cash += amount;
self.external_cash_flow_total += amount;
self.rebase_units_after_external_cash_flow(unit_net_value);
self.cash = self
.cash
.checked_add(amount_money)
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
self.external_cash_flow_total = self
.external_cash_flow_total
.checked_add(amount_money)
.ok_or_else(|| "fixed-point external cash flow overflow".to_string())?;
self.rebase_units_after_external_cash_flow(unit_net_value)?;
Ok(())
}
@@ -582,14 +602,21 @@ impl PortfolioState {
amount: f64,
reason: impl Into<String>,
) -> Result<(), String> {
if !amount.is_finite() {
return Err("deposit_withdraw amount must be finite".to_string());
}
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
let available_cash = self.cash - self.pending_withdrawal_total();
let amount_money = Self::fixed_money(amount, "deposit_withdraw amount")?;
let pending_withdrawal =
Self::fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
let available_cash = self
.cash
.checked_sub(pending_withdrawal)
.ok_or_else(|| "fixed-point available cash overflow".to_string())?;
let cash_after = available_cash
.checked_add(amount_money)
.ok_or_else(|| "fixed-point scheduled cash flow overflow".to_string())?;
if amount_money.raw() < 0 && cash_after.raw() < 0 {
return Err(format!(
"insufficient cash for scheduled withdrawal amount={:.2} available_cash={:.2}",
amount, available_cash
amount,
available_cash.to_f64()
));
}
self.pending_cash_flows.push(PendingCashFlow {
@@ -620,24 +647,33 @@ impl PortfolioState {
// negative after trades on an earlier day. Validate the complete due
// batch before mutating either cash or the pending queue so a failed
// settlement is atomic and can be diagnosed/retried safely.
let incoming = due
.iter()
.filter(|flow| flow.amount > 0.0)
.map(|flow| flow.amount)
.sum::<f64>();
let outgoing = due
.iter()
.filter(|flow| flow.amount < 0.0)
.map(|flow| flow.amount)
.sum::<f64>();
if self.cash + incoming + outgoing < -1e-6 {
let incoming = Self::sum_fixed_money(
due.iter()
.filter(|flow| flow.amount > 0.0)
.map(|flow| flow.amount),
"incoming scheduled cash flows",
)?;
let outgoing = Self::sum_fixed_money(
due.iter()
.filter(|flow| flow.amount < 0.0)
.map(|flow| flow.amount),
"outgoing scheduled cash flows",
)?;
let net_due = incoming
.checked_add(outgoing)
.ok_or_else(|| "fixed-point scheduled cash flow overflow".to_string())?;
let cash_after = self
.cash
.checked_add(net_due)
.ok_or_else(|| "fixed-point cash settlement overflow".to_string())?;
if cash_after.raw() < 0 {
self.pending_cash_flows = due.into_iter().chain(pending).collect();
self.pending_cash_flows
.sort_by_key(|flow| flow.payable_date);
return Err(format!(
"insufficient cash to settle delayed cash flows on {date}: cash={:.2} net_due={:.2}",
self.cash,
incoming + outgoing
self.cash.to_f64(),
net_due.to_f64()
));
}
@@ -649,9 +685,16 @@ impl PortfolioState {
let mut settled = Vec::with_capacity(due.len());
for flow in due {
let unit_net_value = self.unit_net_value();
self.cash += flow.amount;
self.external_cash_flow_total += flow.amount;
self.rebase_units_after_external_cash_flow(unit_net_value);
let amount = Self::fixed_money(flow.amount, "scheduled cash flow")?;
self.cash = self
.cash
.checked_add(amount)
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
self.external_cash_flow_total = self
.external_cash_flow_total
.checked_add(amount)
.ok_or_else(|| "fixed-point external cash flow overflow".to_string())?;
self.rebase_units_after_external_cash_flow(unit_net_value)?;
settled.push(flow);
}
self.pending_cash_flows = pending;
@@ -671,24 +714,38 @@ impl PortfolioState {
}
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> {
if !amount.is_finite() {
return Err("finance_repay amount must be finite".to_string());
}
if amount > 0.0 {
self.cash_liabilities += amount;
self.cash += amount;
let amount_money = Self::fixed_money(amount, "finance_repay amount")?;
if amount_money.raw() > 0 {
self.cash_liabilities = self
.cash_liabilities
.checked_add(amount_money)
.ok_or_else(|| "fixed-point cash liability overflow".to_string())?;
self.cash = self
.cash
.checked_add(amount_money)
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
return Ok(());
}
if amount < 0.0 {
let repay_amount = (-amount).min(self.cash_liabilities);
if repay_amount > self.cash + 1e-6 {
if amount_money.raw() < 0 {
let requested = amount_money
.checked_neg()
.ok_or_else(|| "fixed-point finance repayment overflow".to_string())?;
let repay_amount = requested.min(self.cash_liabilities);
if repay_amount > self.cash {
return Err(format!(
"insufficient cash for finance repay amount={:.2} cash={:.2}",
repay_amount, self.cash
repay_amount.to_f64(),
self.cash.to_f64()
));
}
self.cash_liabilities -= repay_amount;
self.cash -= repay_amount;
self.cash_liabilities = self
.cash_liabilities
.checked_sub(repay_amount)
.ok_or_else(|| "fixed-point cash liability underflow".to_string())?;
self.cash = self
.cash
.checked_sub(repay_amount)
.ok_or_else(|| "fixed-point cash underflow".to_string())?;
}
Ok(())
}
@@ -706,11 +763,18 @@ impl PortfolioState {
}
pub fn apply_management_fee(&mut self, fee: f64) -> Result<(), String> {
if !fee.is_finite() || fee < 0.0 {
let fee_money = Self::fixed_money(fee, "management fee")?;
if fee_money.raw() < 0 {
return Err("management fee must be finite and non-negative".to_string());
}
self.cash -= fee;
self.management_fees += fee;
self.cash = self
.cash
.checked_sub(fee_money)
.ok_or_else(|| "fixed-point cash underflow".to_string())?;
self.management_fees = self
.management_fees
.checked_add(fee_money)
.ok_or_else(|| "fixed-point management fee overflow".to_string())?;
Ok(())
}
@@ -719,7 +783,12 @@ impl PortfolioState {
let mut pending = Vec::new();
for receivable in self.cash_receivables.drain(..) {
if receivable.payable_date <= date {
self.cash += receivable.amount;
let amount = Self::fixed_money(receivable.amount, "cash receivable")
.expect("cash receivable must be finite fixed-point money");
self.cash = self
.cash
.checked_add(amount)
.expect("fixed-point cash overflow while settling receivable");
settled.push(receivable);
} else {
pending.push(receivable);
@@ -838,7 +907,7 @@ impl PortfolioState {
}
pub fn total_equity(&self) -> f64 {
self.cash + self.market_value() - self.cash_liabilities
self.total_equity_money().to_f64()
}
pub fn total_value(&self) -> f64 {
@@ -850,18 +919,18 @@ impl PortfolioState {
}
pub fn unit_net_value(&self) -> f64 {
if self.units.abs() < f64::EPSILON {
if self.units.raw() == 0 {
0.0
} else {
self.total_equity() / self.units
self.total_equity() / self.units.to_f64()
}
}
pub fn static_unit_net_value(&self) -> f64 {
if self.units.abs() < f64::EPSILON {
if self.units.raw() == 0 {
0.0
} else {
(self.total_equity() - self.daily_pnl()) / self.units
(self.total_equity() - self.daily_pnl()) / self.units.to_f64()
}
}
@@ -1025,6 +1094,43 @@ 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(
values: impl IntoIterator<Item = f64>,
label: &str,
) -> Result<FixedMoney, String> {
values
.into_iter()
.try_fold(FixedMoney::ZERO, |total, value| {
total
.checked_add(Self::fixed_money(value, label)?)
.ok_or_else(|| format!("fixed-point {label} overflow"))
})
}
fn market_value_money(&self) -> FixedMoney {
self.positions
.values()
.fold(FixedMoney::ZERO, |total, position| {
let value = Self::fixed_money(position.market_value(), "position market value")
.expect("position market value must be finite fixed-point money");
total
.checked_add(value)
.expect("fixed-point market value overflow")
})
}
fn total_equity_money(&self) -> FixedMoney {
self.cash
.checked_add(self.market_value_money())
.and_then(|value| value.checked_sub(self.cash_liabilities))
.expect("fixed-point total equity overflow")
}
fn refresh_dividend_receivables(&mut self) {
let mut per_symbol = BTreeMap::<String, f64>::new();
for receivable in &self.cash_receivables {
@@ -1035,10 +1141,21 @@ impl PortfolioState {
}
}
fn rebase_units_after_external_cash_flow(&mut self, unit_net_value_before: f64) {
fn rebase_units_after_external_cash_flow(
&mut self,
unit_net_value_before: f64,
) -> Result<(), String> {
if unit_net_value_before > 0.0 && unit_net_value_before.is_finite() {
self.units = self.total_equity() / unit_net_value_before;
let unit_nav = Self::fixed_money(unit_net_value_before, "unit net value")?;
let units_raw = self
.total_equity_money()
.raw()
.checked_mul(MONEY_SCALE)
.and_then(|value| value.checked_div(unit_nav.raw()))
.ok_or_else(|| "fixed-point unit rebase overflow".to_string())?;
self.units = FixedMoney::from_raw(units_raw);
}
Ok(())
}
}
@@ -1052,6 +1169,22 @@ mod tests {
};
use std::collections::BTreeMap;
#[test]
fn cash_ledger_accumulates_micro_yuan_exactly() {
let mut portfolio = PortfolioState::new(1_000_000.0);
for _ in 0..100_000 {
portfolio.apply_cash_delta(-0.000001).unwrap();
}
assert_eq!(portfolio.cash, FixedMoney::from_raw(999_999_900_000));
assert_eq!(portfolio.cash(), 999_999.9);
for _ in 0..100_000 {
portfolio.apply_cash_delta(0.000001).unwrap();
}
assert_eq!(portfolio.cash, FixedMoney::from_raw(1_000_000_000_000));
assert_eq!(portfolio.cash(), 1_000_000.0);
}
#[test]
fn positions_preserve_insertion_order() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
@@ -1682,7 +1815,7 @@ mod tests {
// A strategy cannot spend the reserved cash by scheduling a second
// withdrawal; settlement remains safe even if earlier trading reduced
// the current cash balance.
portfolio.apply_cash_delta(-3_000.0);
portfolio.apply_cash_delta(-3_000.0).unwrap();
let error = portfolio
.settle_pending_cash_flows(date)
.expect_err("settlement must reject an underfunded withdrawal batch");
+73 -24
View File
@@ -10,6 +10,7 @@ use crate::data::{
};
use crate::engine::BacktestError;
use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent};
use crate::fixed_point::FixedMoney;
use crate::futures::{FuturesAccountState, FuturesOrderIntent};
use crate::instrument::Instrument;
use crate::portfolio::PortfolioState;
@@ -1750,14 +1751,47 @@ impl OmniMicroCapStrategy {
ChinaAShareCostModel::from_trading_constraints(self.config.risk_config.trading_constraints)
}
fn buy_commission(&self, gross_amount: f64) -> f64 {
self.cost_model().commission_for(gross_amount)
fn buy_cost(&self, gross_amount: f64) -> f64 {
let model = self.cost_model();
FixedMoney::checked_sum_f64([
model.commission_for(gross_amount),
model.transfer_fee_for(gross_amount),
])
.expect("projected buy costs must be finite fixed-point money")
.to_f64()
}
fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 {
let model = self.cost_model();
model.commission_for(gross_amount)
+ model.stamp_tax_for(date, OrderSide::Sell, gross_amount)
FixedMoney::checked_sum_f64([
model.commission_for(gross_amount),
model.stamp_tax_for(date, OrderSide::Sell, gross_amount),
model.transfer_fee_for(gross_amount),
])
.expect("projected sell costs must be finite fixed-point money")
.to_f64()
}
fn buy_cash_out(&self, gross_amount: f64) -> f64 {
FixedMoney::checked_sum_f64([gross_amount, self.buy_cost(gross_amount)])
.expect("projected buy cash must be finite fixed-point money")
.to_f64()
}
fn sell_net_cash(&self, date: NaiveDate, gross_amount: f64) -> f64 {
let gross = FixedMoney::from_f64(gross_amount)
.expect("projected sell gross must be finite fixed-point money");
gross
.checked_sub(
FixedMoney::from_f64(self.sell_cost(date, gross.to_f64()))
.expect("projected sell costs must be finite fixed-point money"),
)
.expect("projected sell proceeds underflow")
.to_f64()
}
fn fixed_cash_fits(value: f64, limit: f64) -> bool {
FixedMoney::f64_fits_within(value, limit).unwrap_or(false)
}
fn round_lot_quantity(
@@ -1826,7 +1860,7 @@ impl OmniMicroCapStrategy {
let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100, 100);
while quantity > 0 {
let gross_amount = execution_price * quantity as f64;
if gross_amount + self.buy_commission(gross_amount) <= cash + 1e-6 {
if Self::fixed_cash_fits(self.buy_cash_out(gross_amount), cash) {
return quantity;
}
quantity = self.decrement_order_quantity(quantity, 100, 100);
@@ -1874,8 +1908,10 @@ impl OmniMicroCapStrategy {
);
while snapshot_requested_qty > 0 {
let gross_amount = sizing_price * snapshot_requested_qty as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 {
let cash_out = self.buy_cash_out(gross_amount);
if Self::fixed_cash_fits(cash_out, order_value)
&& Self::fixed_cash_fits(cash_out, projected.cash())
{
break;
}
snapshot_requested_qty = self.decrement_order_quantity(
@@ -1902,8 +1938,10 @@ impl OmniMicroCapStrategy {
let mut quantity = snapshot_requested_qty;
while quantity > 0 {
let gross_amount = projected_execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 {
let cash_out = self.buy_cash_out(gross_amount);
if Self::fixed_cash_fits(cash_out, order_value)
&& Self::fixed_cash_fits(cash_out, projected.cash())
{
break;
}
quantity =
@@ -1918,8 +1956,10 @@ impl OmniMicroCapStrategy {
.unwrap_or(projected_execution_price);
while quantity > 0 {
let gross_amount = execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 {
let cash_out = self.buy_cash_out(gross_amount);
if Self::fixed_cash_fits(cash_out, order_value)
&& Self::fixed_cash_fits(cash_out, projected.cash())
{
break;
}
quantity =
@@ -1934,11 +1974,15 @@ impl OmniMicroCapStrategy {
next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1),
};
let gross_amount = fill.price * fill.quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out > projected.cash() + 1e-6 || cash_out > order_value + 1e-6 {
let cash_out = self.buy_cash_out(gross_amount);
if !Self::fixed_cash_fits(cash_out, projected.cash())
|| !Self::fixed_cash_fits(cash_out, order_value)
{
return 0;
}
projected.apply_cash_delta(-cash_out);
projected
.apply_cash_delta(-cash_out)
.expect("projected buy cash must fit fixed-point ledger");
projected
.position_mut(symbol)
.buy(date, fill.quantity, fill.price);
@@ -1994,12 +2038,14 @@ impl OmniMicroCapStrategy {
+ Duration::seconds(1),
});
let gross_amount = fill.price * fill.quantity as f64;
let net_cash = gross_amount - self.sell_cost(date, gross_amount);
let net_cash = self.sell_net_cash(date, gross_amount);
projected
.position_mut(symbol)
.sell(fill.quantity, fill.price)
.ok()?;
projected.apply_cash_delta(net_cash);
projected
.apply_cash_delta(net_cash)
.expect("projected sell cash must fit fixed-point ledger");
*execution_state
.intraday_turnover
.entry(symbol.to_string())
@@ -2144,7 +2190,9 @@ impl OmniMicroCapStrategy {
);
while take_qty > 0 {
let candidate_gross = execution_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
if gross_limit
.is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit))
{
take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
@@ -2152,9 +2200,8 @@ impl OmniMicroCapStrategy {
);
continue;
}
let candidate_cash =
candidate_gross + self.buy_commission(candidate_gross);
if candidate_cash <= cash + 1e-6 {
let candidate_cash = self.buy_cash_out(candidate_gross);
if Self::fixed_cash_fits(candidate_cash, cash) {
break;
}
take_qty = self.decrement_order_quantity(
@@ -2254,7 +2301,9 @@ impl OmniMicroCapStrategy {
if let Some(cash) = cash_limit {
while take_qty > 0 {
let candidate_gross = gross_amount + quote_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
if gross_limit
.is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit))
{
take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
@@ -2262,7 +2311,7 @@ impl OmniMicroCapStrategy {
);
continue;
}
if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 {
if Self::fixed_cash_fits(self.buy_cash_out(candidate_gross), cash) {
break;
}
take_qty = self.decrement_order_quantity(
@@ -2870,8 +2919,8 @@ mod tests {
.stamp_tax_rate_after_change = 0.0005;
let strategy = OmniMicroCapStrategy::new(cfg);
assert!((strategy.buy_commission(100_000.0) - 30.0).abs() < 1e-9);
assert!((strategy.buy_commission(1_000.0) - 5.0).abs() < 1e-9);
assert!((strategy.buy_cost(100_000.0) - 30.0).abs() < 1e-9);
assert!((strategy.buy_cost(1_000.0) - 5.0).abs() < 1e-9);
assert!(
(strategy.sell_cost(NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), 100_000.0) - 80.0)
.abs()
+3 -5
View File
@@ -368,11 +368,9 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
first_date: buy_date,
},
BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel {
commission_rate: 0.0008,
minimum_commission: 0.0,
..ChinaAShareCostModel::default()
},
ChinaAShareCostModel::default()
.with_commission_rate(0.0008)
.with_minimum_commission(0.0),
ChinaEquityRuleHooks::default(),
PriceField::Open,
),