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

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, AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind, ProcessEventKind,
}; };
use crate::fixed_point::FixedMoney;
use crate::instrument::Instrument; use crate::instrument::Instrument;
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, RiskCheckScope}; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, RiskCheckScope};
@@ -2585,7 +2586,7 @@ where
} else { } else {
0.0 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 if proportion_diff < best_proportion_diff - 1e-12
|| ((proportion_diff - best_proportion_diff).abs() <= 1e-12 || ((proportion_diff - best_proportion_diff).abs() <= 1e-12
&& safety_value > best_safety) && safety_value > best_safety)
@@ -3150,9 +3151,14 @@ where
if quantity == 0 { if quantity == 0 {
return 0.0; return 0.0;
} }
let gross = price * quantity as f64; let gross = Self::fixed_gross_amount(price, quantity);
let cost = self.cost_model.calculate(date, OrderSide::Sell, gross); let cost = self
gross - cost.total() .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( fn sell_target_denial_reason(
@@ -3256,9 +3262,23 @@ where
if quantity == 0 { if quantity == 0 {
return 0.0; return 0.0;
} }
let gross = price * quantity as f64; let gross = Self::fixed_gross_amount(price, quantity);
let cost = self.cost_model.calculate(date, OrderSide::Buy, gross); let cost = self
gross + cost.total() .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( fn can_afford_minimum_buy(
@@ -3283,8 +3303,10 @@ where
} }
let minimum_execution_price = let minimum_execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity)); self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity));
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity) Self::fixed_cash_fits(
<= portfolio.cash() + 1e-6 self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity),
portfolio.cash(),
)
} }
fn process_sell( fn process_sell(
@@ -3722,7 +3744,8 @@ where
} }
for leg in &execution_legs { for leg in &execution_legs {
let leg_cash_before = portfolio.cash(); 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( let cost = self.cost_model.calculate_with_order_state(
date, date,
OrderSide::Sell, OrderSide::Sell,
@@ -3730,7 +3753,10 @@ where
Some(order_id), Some(order_id),
commission_state, 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 let realized_pnl = portfolio
.position_mut(symbol) .position_mut(symbol)
.sell_with_mark_price(leg.quantity, leg.price, leg.mark_price) .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) { if let Some(position) = portfolio.position_mut_if_exists(symbol) {
position.record_trade_cost(cost.total()); 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 { report.fill_events.push(FillEvent {
date, date,
@@ -5371,7 +5399,8 @@ where
} }
for leg in &execution_legs { for leg in &execution_legs {
let leg_cash_before = portfolio.cash(); 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( let cost = self.cost_model.calculate_with_order_state(
date, date,
OrderSide::Buy, OrderSide::Buy,
@@ -5379,9 +5408,14 @@ where
Some(order_id), Some(order_id),
commission_state, 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( portfolio.position_mut(symbol).buy_with_mark_price(
date, date,
leg.quantity, leg.quantity,
@@ -5766,7 +5800,10 @@ where
let mut quantity = let mut quantity =
self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size); self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size);
while quantity >= minimum { 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; return quantity;
} }
quantity = quantity =
@@ -5820,7 +5857,10 @@ where
}) })
.filter(|price| price.is_finite() && *price > 0.0) .filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price); .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; return quantity;
} }
quantity = quantity =
@@ -5857,7 +5897,7 @@ where
self.round_buy_quantity(requested_qty, minimum_order_quantity, order_step_size); self.round_buy_quantity(requested_qty, minimum_order_quantity, order_step_size);
while quantity > 0 { while quantity > 0 {
let gross = price * quantity as f64; 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 = self.decrement_order_quantity(
quantity, quantity,
minimum_order_quantity, minimum_order_quantity,
@@ -5866,7 +5906,10 @@ where
continue; continue;
} }
let cost = self.cost_model.calculate(date, OrderSide::Buy, gross); 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; return quantity;
} }
quantity = quantity =
@@ -5886,7 +5929,9 @@ where
if filled_qty >= requested_qty { if filled_qty >= requested_qty {
return None; 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") Some("value budget limit")
} else if cash_limit.is_finite() { } else if cash_limit.is_finite() {
Some("insufficient cash after fees") Some("insufficient cash after fees")
@@ -6326,7 +6371,9 @@ where
break; break;
} }
let candidate_gross = gross_amount + quote_price * take_qty as f64; 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"); budget_block_reason = Some("value budget limit");
take_qty = self.decrement_order_quantity( take_qty = self.decrement_order_quantity(
take_qty, take_qty,
@@ -6339,7 +6386,11 @@ where
.cost_model .cost_model
.calculate(snapshot.date, OrderSide::Buy, candidate_gross) .calculate(snapshot.date, OrderSide::Buy, candidate_gross)
.total(); .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; break;
} }
budget_block_reason = Some("insufficient cash after fees"); budget_block_reason = Some("insufficient cash after fees");
@@ -6586,6 +6637,7 @@ mod tests {
IntradayExecutionQuote, PriceField, IntradayExecutionQuote, PriceField,
}; };
use crate::events::{OrderSide, OrderStatus}; use crate::events::{OrderSide, OrderStatus};
use crate::fixed_point::FixedMoney;
use crate::instrument::Instrument; use crate::instrument::Instrument;
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
use crate::risk_control::FidcRiskControlConfig; use crate::risk_control::FidcRiskControlConfig;
@@ -7650,7 +7702,7 @@ mod tests {
1_000, 1_000,
10.0, 10.0,
); );
portfolio.apply_cash_delta(-10_000.0); portfolio.apply_cash_delta(-10_000.0).unwrap();
let mut report = BrokerExecutionReport::default(); let mut report = BrokerExecutionReport::default();
broker broker
@@ -7708,7 +7760,7 @@ mod tests {
10_000, 10_000,
10.0, 10.0,
); );
portfolio.apply_cash_delta(-100_000.0); portfolio.apply_cash_delta(-100_000.0).unwrap();
let mut report = BrokerExecutionReport::default(); let mut report = BrokerExecutionReport::default();
broker broker
@@ -7766,7 +7818,7 @@ mod tests {
1_000, 1_000,
10.0, 10.0,
); );
portfolio.apply_cash_delta(-10_000.0); portfolio.apply_cash_delta(-10_000.0).unwrap();
let mut report = BrokerExecutionReport::default(); let mut report = BrokerExecutionReport::default();
broker broker
@@ -7856,7 +7908,7 @@ mod tests {
1_000, 1_000,
10.0, 10.0,
); );
portfolio.apply_cash_delta(-10_000.0); portfolio.apply_cash_delta(-10_000.0).unwrap();
let mut report = BrokerExecutionReport::default(); let mut report = BrokerExecutionReport::default();
broker broker
@@ -7943,7 +7995,7 @@ mod tests {
10_000, 10_000,
10.0, 10.0,
); );
portfolio.apply_cash_delta(-100_000.0); portfolio.apply_cash_delta(-100_000.0).unwrap();
let mut report = BrokerExecutionReport::default(); let mut report = BrokerExecutionReport::default();
broker broker
@@ -9122,7 +9174,7 @@ mod tests {
.expect("valid dataset"); .expect("valid dataset");
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio.position_mut(symbol).buy(prev_date, 72_600, 4.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(); let mut report = BrokerExecutionReport::default();
broker broker
@@ -9166,13 +9218,10 @@ mod tests {
let date = chrono::NaiveDate::from_ymd_opt(2023, 5, 8).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2023, 5, 8).expect("valid date");
let symbol = "603101.SH"; let symbol = "603101.SH";
let broker = BrokerSimulator::new_with_execution_price( let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel { ChinaAShareCostModel::default()
commission_rate: 0.0003, .with_commission_rate(0.0003)
stamp_tax_rate_before_change: 0.0005, .with_stamp_tax_rates(0.0005, 0.0005)
stamp_tax_rate_after_change: 0.0005, .with_minimum_commission(5.0),
minimum_commission: 5.0,
..ChinaAShareCostModel::default()
},
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Last, PriceField::Last,
) )
@@ -9515,7 +9564,20 @@ mod tests {
let fill = report.fill_events.first().expect("fill event"); let fill = report.fill_events.first().expect("fill event");
assert_eq!(fill.quantity, 17_400); 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); assert!((fill.price - 7.15428).abs() < 1e-6);
} }
+125 -61
View File
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
use chrono::NaiveDate; use chrono::NaiveDate;
use crate::events::OrderSide; use crate::events::OrderSide;
use crate::fixed_point::{FixedChinaAShareCostModel, FixedMoney, FixedTradingCost};
use crate::risk_control::TradingConstraintConfig; use crate::risk_control::TradingConstraintConfig;
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -14,7 +15,20 @@ pub struct TradingCost {
impl TradingCost { impl TradingCost {
pub fn total(self) -> f64 { 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)] #[derive(Debug, Clone, Copy)]
pub struct ChinaAShareCostModel { pub struct ChinaAShareCostModel {
pub commission_rate: f64, fixed: FixedChinaAShareCostModel,
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,
} }
impl Default for ChinaAShareCostModel { impl Default for ChinaAShareCostModel {
@@ -52,42 +61,93 @@ impl Default for ChinaAShareCostModel {
impl ChinaAShareCostModel { impl ChinaAShareCostModel {
pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self { pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self {
Self { Self {
commission_rate: config.commission_rate, fixed: FixedChinaAShareCostModel {
stamp_tax_rate_before_change: config.stamp_tax_rate_before_change, commission_rate: Self::fixed_money(config.commission_rate, "commission rate"),
stamp_tax_rate_after_change: config.stamp_tax_rate_after_change, stamp_tax_rate_before_change: Self::fixed_money(
stamp_tax_change_date: config.stamp_tax_change_date, config.stamp_tax_rate_before_change,
minimum_commission: config.minimum_commission, "stamp tax rate before change",
transfer_fee_rate: config.transfer_fee_rate, ),
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 { pub fn commission_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 { if gross_amount <= 0.0 {
return 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 { pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 {
if date < self.stamp_tax_change_date { self.fixed.stamp_tax_rate_for(date).to_f64()
self.stamp_tax_rate_before_change
} else {
self.stamp_tax_rate_after_change
}
} }
pub fn stamp_tax_for(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> f64 { pub fn stamp_tax_for(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 || side == OrderSide::Buy { if gross_amount <= 0.0 || side == OrderSide::Buy {
return 0.0; 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 { pub fn transfer_fee_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 { if gross_amount <= 0.0 {
return 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( pub fn commission_for_order_fill(
@@ -100,31 +160,29 @@ impl ChinaAShareCostModel {
return 0.0; return 0.0;
} }
let raw_commission = gross_amount * self.commission_rate;
let Some(order_id) = order_id else { 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 let remaining_minimum = commission_state
.entry(order_id) .entry(order_id)
.or_insert(self.minimum_commission); .or_insert(self.fixed.minimum_commission.to_f64());
if raw_commission > *remaining_minimum { let mut fixed_remaining = Self::fixed_money(*remaining_minimum, "remaining commission");
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 { let charged = self.fixed_model().commission_for_order_fill_remaining(
raw_commission Self::fixed_money(gross_amount, "gross amount"),
} else { &mut fixed_remaining,
raw_commission - *remaining_minimum );
}; *remaining_minimum = fixed_remaining.to_f64();
*remaining_minimum = 0.0; charged.to_f64()
charged }
} else {
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 { fn fixed_money(value: f64, label: &str) -> FixedMoney {
self.minimum_commission FixedMoney::from_f64(value)
} else { .unwrap_or_else(|| panic!("{label} is not representable as fixed-point money: {value}"))
0.0 }
};
*remaining_minimum -= raw_commission; fn fixed_model(&self) -> FixedChinaAShareCostModel {
charged self.fixed
}
} }
} }
@@ -138,15 +196,11 @@ impl CostModel for ChinaAShareCostModel {
}; };
} }
let commission = self.commission_for(gross_amount); TradingCost::from_fixed(self.fixed_model().calculate(
let stamp_tax = self.stamp_tax_for(date, side, gross_amount); date,
let transfer_fee = self.transfer_fee_for(gross_amount); side,
Self::fixed_money(gross_amount, "gross amount"),
TradingCost { ))
commission,
stamp_tax,
transfer_fee,
}
} }
fn calculate_with_order_state( 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 fixed_model = self.fixed_model();
let stamp_tax = self.stamp_tax_for(date, side, gross_amount); let fixed_gross = Self::fixed_money(gross_amount, "gross amount");
let transfer_fee = self.transfer_fee_for(gross_amount); let commission = if let Some(order_id) = order_id {
let remaining = commission_state
TradingCost { .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, commission,
stamp_tax, stamp_tax: fixed_model.stamp_tax_for(date, side, fixed_gross),
transfer_fee, transfer_fee: fixed_model.transfer_fee_for(fixed_gross),
} })
} }
} }
@@ -182,13 +246,13 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn default_matches_configurable_trading_constraints() { fn default_quantizes_fees_to_micro_yuan() {
let model = ChinaAShareCostModel::default(); let model = ChinaAShareCostModel::default();
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date"); 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!( 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 { if outcome.cash_delta.abs() > f64::EPSILON {
let cash_before = portfolio.cash(); 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 { report.account_events.push(AccountEvent {
date, date,
cash_before, cash_before,
@@ -3279,7 +3281,9 @@ where
if reinvest_quantity > 0 { if reinvest_quantity > 0 {
let reinvest_cash = reinvest_quantity as f64 * price; let reinvest_cash = reinvest_quantity as f64 * price;
let residual_cash = receivable.amount - reinvest_cash; 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( portfolio.position_mut(&receivable.symbol).buy(
date, date,
reinvest_quantity, 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 //! Market data and analytics remain floating point at their API boundaries.
//! represented that way today. This module is deliberately separate: it is a //! The execution kernel quantizes monetary values to micro-yuan before fee,
//! deterministic shadow model used to prove that cash, fees, budget checks, //! budget and cash-ledger arithmetic so repeated fills and external cash flows
//! FIFO PnL, and external cash flows do not depend on binary floating-point //! do not accumulate binary floating-point drift.
//! accumulation.
use std::collections::{BTreeMap, VecDeque}; use std::collections::{BTreeMap, VecDeque};
@@ -105,6 +104,10 @@ impl FixedMoney {
self.0.checked_mul(i128::from(quantity)).map(Self) 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> { pub fn checked_mul_rate(self, rate: Self) -> Option<Self> {
let product = self.0.checked_mul(rate.0)?; let product = self.0.checked_mul(rate.0)?;
let half = MONEY_SCALE / 2; let half = MONEY_SCALE / 2;
@@ -116,6 +119,20 @@ impl FixedMoney {
Some(Self(rounded)) 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 { pub fn abs(self) -> Self {
Self(self.0.abs()) Self(self.0.abs())
} }
@@ -217,6 +234,20 @@ impl FixedChinaAShareCostModel {
let remaining = commission_state let remaining = commission_state
.entry(order_id) .entry(order_id)
.or_insert(self.minimum_commission); .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 { if raw > *remaining {
let charged = if *remaining == self.minimum_commission { let charged = if *remaining == self.minimum_commission {
raw raw
@@ -415,7 +446,7 @@ mod tests {
} }
#[test] #[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 fixed = fixed_model();
let float = ChinaAShareCostModel::default(); let float = ChinaAShareCostModel::default();
let dates = [ let dates = [
@@ -433,13 +464,7 @@ mod tests {
(actual.stamp_tax, expected.stamp_tax), (actual.stamp_tax, expected.stamp_tax),
(actual.transfer_fee, expected.transfer_fee), (actual.transfer_fee, expected.transfer_fee),
] { ] {
assert!( assert_eq!(actual.to_f64(), expected);
(actual.to_f64() - expected).abs() <= 1.0 / MONEY_SCALE_F64,
"fixed={} float={} gross={} date={date} side={side:?}",
actual.to_f64(),
expected,
gross
);
} }
} }
} }
@@ -447,7 +472,7 @@ mod tests {
} }
#[test] #[test]
fn fixed_order_commission_state_matches_float_order_split() { fn runtime_split_commission_matches_fixed_execution_primitive() {
let fixed = fixed_model(); let fixed = fixed_model();
let float = ChinaAShareCostModel::default(); let float = ChinaAShareCostModel::default();
let mut fixed_state = BTreeMap::new(); let mut fixed_state = BTreeMap::new();
@@ -464,7 +489,7 @@ mod tests {
fixed_total = fixed_total.checked_add(fixed_fee).unwrap(); fixed_total = fixed_total.checked_add(fixed_fee).unwrap();
float_total += float_fee; 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] #[test]
@@ -493,6 +518,16 @@ mod tests {
assert!(quantity < 5_000); 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] #[test]
fn fixed_fifo_pnl_and_external_flow_are_deterministic() { fn fixed_fifo_pnl_and_external_flow_are_deterministic() {
let day_one = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); 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::engine::BacktestError;
use crate::events::OrderSide; use crate::events::OrderSide;
use crate::fixed_point::FixedMoney;
use crate::numeric_expr_vm::{ use crate::numeric_expr_vm::{
self, EvalError as NumericVmEvalError, Program as NumericVmProgram, self, EvalError as NumericVmEvalError, Program as NumericVmProgram,
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType, Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
@@ -1904,14 +1905,47 @@ impl PlatformExprStrategy {
(holding_days >= max_days).then_some(holding_days) (holding_days >= max_days).then_some(holding_days)
} }
fn buy_commission(&self, gross_amount: f64) -> f64 { fn buy_cost(&self, gross_amount: f64) -> f64 {
self.cost_model().commission_for(gross_amount) 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 { fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 {
let model = self.cost_model(); let model = self.cost_model();
model.commission_for(gross_amount) FixedMoney::checked_sum_f64([
+ model.stamp_tax_for(date, OrderSide::Sell, gross_amount) 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 { fn cost_model(&self) -> ChinaAShareCostModel {
@@ -1919,19 +1953,19 @@ impl PlatformExprStrategy {
self.config.risk_config.trading_constraints, self.config.risk_config.trading_constraints,
); );
if let Some(value) = self.config.commission_rate { 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 { 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 { 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 { 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 { if let Some(value) = self.config.stamp_tax_change_date {
model.stamp_tax_change_date = value; model.set_stamp_tax_change_date(value);
} }
model model
} }
@@ -2026,7 +2060,7 @@ impl PlatformExprStrategy {
self.round_lot_quantity(raw_quantity, minimum_order_quantity, order_step_size); self.round_lot_quantity(raw_quantity, minimum_order_quantity, order_step_size);
while quantity >= minimum { while quantity >= minimum {
let gross_amount = price * quantity as f64; 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; return quantity;
} }
quantity = quantity =
@@ -2609,7 +2643,9 @@ impl PlatformExprStrategy {
break; break;
} }
let candidate_gross = gross_amount + quote_price * take_qty as f64; 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 = self.decrement_order_quantity(
take_qty, take_qty,
minimum_order_quantity, minimum_order_quantity,
@@ -2617,7 +2653,7 @@ impl PlatformExprStrategy {
); );
continue; 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; break;
} }
take_qty = self.decrement_order_quantity( take_qty = self.decrement_order_quantity(
@@ -2772,12 +2808,14 @@ impl PlatformExprStrategy {
} }
})?; })?;
let gross_amount = fill.price * fill.quantity as f64; 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 projected
.position_mut(symbol) .position_mut(symbol)
.sell(fill.quantity, fill.price) .sell(fill.quantity, fill.price)
.ok()?; .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 *execution_state
.intraday_turnover .intraday_turnover
.entry(symbol.to_string()) .entry(symbol.to_string())
@@ -2872,12 +2910,14 @@ impl PlatformExprStrategy {
execution_state, execution_state,
)?; )?;
let gross_amount = fill.price * fill.quantity as f64; 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 projected
.position_mut(symbol) .position_mut(symbol)
.sell(fill.quantity, fill.price) .sell(fill.quantity, fill.price)
.ok()?; .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 *execution_state
.intraday_turnover .intraday_turnover
.entry(symbol.to_string()) .entry(symbol.to_string())
@@ -3339,8 +3379,8 @@ impl PlatformExprStrategy {
}; };
while quantity > 0 { while quantity > 0 {
let gross_amount = sizing_price * quantity as f64; let gross_amount = sizing_price * quantity as f64;
if gross_limit.map_or(true, |limit| gross_amount <= limit + 1e-6) if gross_limit.is_none_or(|limit| Self::fixed_cash_fits(gross_amount, limit))
&& gross_amount + self.buy_commission(gross_amount) <= cash_limit + 1e-6 && Self::fixed_cash_fits(self.buy_cash_out(gross_amount), cash_limit)
{ {
break; break;
} }
@@ -3411,11 +3451,13 @@ impl PlatformExprStrategy {
return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity); return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity);
}; };
let gross_amount = fill.price * fill.quantity as f64; let gross_amount = fill.price * fill.quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount); let cash_out = self.buy_cash_out(gross_amount);
if cash_out > cash_limit + 1e-6 { if !Self::fixed_cash_fits(cash_out, cash_limit) {
return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity); 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 projected
.position_mut(symbol) .position_mut(symbol)
.buy(date, fill.quantity, fill.price); .buy(date, fill.quantity, fill.price);
@@ -13401,8 +13443,8 @@ mod tests {
cfg.minimum_commission = Some(5.0); cfg.minimum_commission = Some(5.0);
let strategy = PlatformExprStrategy::new(cfg); let strategy = PlatformExprStrategy::new(cfg);
assert!((strategy.buy_commission(100_000.0) - 30.0).abs() < 1e-9); assert!((strategy.buy_cost(100_000.0) - 30.0).abs() < 1e-9);
assert!((strategy.buy_commission(1_000.0) - 5.0).abs() < 1e-9); assert!((strategy.buy_cost(1_000.0) - 5.0).abs() < 1e-9);
} }
#[test] #[test]
@@ -13412,7 +13454,7 @@ mod tests {
cfg.minimum_commission = Some(0.0); cfg.minimum_commission = Some(0.0);
let strategy = PlatformExprStrategy::new(cfg); 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] #[test]
@@ -18211,7 +18253,7 @@ mod tests {
portfolio portfolio
.position_mut(symbol) .position_mut(symbol)
.buy(d(2023, 5, 4), 30_000, 10.0); .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 subscriptions = BTreeSet::new();
let ctx = StrategyContext { let ctx = StrategyContext {
execution_date: date, execution_date: date,
@@ -18324,7 +18366,7 @@ mod tests {
portfolio portfolio
.position_mut(symbol) .position_mut(symbol)
.buy(d(2023, 5, 4), 30_000, 10.0); .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 subscriptions = BTreeSet::new();
let ctx = StrategyContext { let ctx = StrategyContext {
execution_date: date, execution_date: date,
@@ -18455,7 +18497,7 @@ mod tests {
portfolio portfolio
.position_mut(symbol) .position_mut(symbol)
.buy(d(2023, 5, 4), 30_000, 10.0); .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 subscriptions = BTreeSet::new();
let ctx = StrategyContext { let ctx = StrategyContext {
execution_date: date, execution_date: date,
@@ -18582,7 +18624,7 @@ mod tests {
portfolio portfolio
.position_mut(symbol) .position_mut(symbol)
.buy(d(2023, 5, 4), 30_000, 10.0); .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 subscriptions = BTreeSet::new();
let ctx = StrategyContext { let ctx = StrategyContext {
execution_date, execution_date,
@@ -18736,7 +18778,7 @@ mod tests {
portfolio portfolio
.position_mut("000002.SZ") .position_mut("000002.SZ")
.buy(prev_date, 2_400, 10.0); .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 subscriptions = BTreeSet::new();
let ctx = StrategyContext { let ctx = StrategyContext {
execution_date: date, execution_date: date,
@@ -18897,7 +18939,7 @@ mod tests {
.expect("dataset"); .expect("dataset");
let mut portfolio = PortfolioState::new(100_000.0); let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut(symbol).buy(prev_date, 3_000, 10.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 subscriptions = BTreeSet::new();
let ctx = StrategyContext { let ctx = StrategyContext {
execution_date: date, execution_date: date,
@@ -19053,7 +19095,7 @@ mod tests {
.expect("dataset"); .expect("dataset");
let mut portfolio = PortfolioState::new(100_000.0); let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut(symbol).buy(prev_date, 3_000, 10.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 subscriptions = BTreeSet::new();
let ctx = StrategyContext { let ctx = StrategyContext {
execution_date: date, execution_date: date,
@@ -19209,7 +19251,7 @@ mod tests {
.expect("dataset"); .expect("dataset");
let mut portfolio = PortfolioState::new(100_000.0); let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut(symbol).buy(prev_date, 3_000, 10.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 subscriptions = BTreeSet::new();
let ctx = StrategyContext { let ctx = StrategyContext {
execution_date: date, execution_date: date,
@@ -19370,7 +19412,7 @@ mod tests {
portfolio portfolio
.position_mut("000002.SZ") .position_mut("000002.SZ")
.buy(prev_date, 2_400, 10.0); .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 subscriptions = BTreeSet::new();
let ctx = StrategyContext { let ctx = StrategyContext {
execution_date: date, execution_date: date,
+206 -73
View File
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use crate::data::{DataSet, DataSetError, PriceField}; use crate::data::{DataSet, DataSetError, PriceField};
use crate::fixed_point::{FixedMoney, MONEY_SCALE};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PositionLot { pub struct PositionLot {
@@ -429,17 +430,17 @@ fn normalized_mark_price(mark_price: f64, fallback: f64) -> f64 {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PortfolioState { pub struct PortfolioState {
initial_cash: f64, initial_cash: FixedMoney,
units: f64, units: FixedMoney,
cash: f64, cash: FixedMoney,
/// Cumulative external cash flow (deposits positive, withdrawals negative). /// Cumulative external cash flow (deposits positive, withdrawals negative).
/// Trading proceeds, dividends, fees and financing are deliberately not /// Trading proceeds, dividends, fees and financing are deliberately not
/// included. The value is used by the engine to build a cash-flow-neutral /// included. The value is used by the engine to build a cash-flow-neutral
/// equity curve and is not a return measure itself. /// equity curve and is not a return measure itself.
external_cash_flow_total: f64, external_cash_flow_total: FixedMoney,
cash_liabilities: f64, cash_liabilities: FixedMoney,
management_fee_rate: f64, management_fee_rate: f64,
management_fees: f64, management_fees: FixedMoney,
positions: IndexMap<String, Position>, positions: IndexMap<String, Position>,
cash_receivables: Vec<CashReceivable>, cash_receivables: Vec<CashReceivable>,
pending_cash_flows: Vec<PendingCashFlow>, pending_cash_flows: Vec<PendingCashFlow>,
@@ -466,14 +467,16 @@ 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")
.expect("initial cash must be finite fixed-point money");
Self { Self {
initial_cash, initial_cash,
units: initial_cash, units: initial_cash,
cash: initial_cash, cash: initial_cash,
external_cash_flow_total: 0.0, external_cash_flow_total: FixedMoney::ZERO,
cash_liabilities: 0.0, cash_liabilities: FixedMoney::ZERO,
management_fee_rate: 0.0, management_fee_rate: 0.0,
management_fees: 0.0, management_fees: FixedMoney::ZERO,
positions: IndexMap::new(), positions: IndexMap::new(),
cash_receivables: Vec::new(), cash_receivables: Vec::new(),
pending_cash_flows: Vec::new(), pending_cash_flows: Vec::new(),
@@ -484,27 +487,27 @@ impl PortfolioState {
pub fn starting_cash(&self) -> f64 { pub fn starting_cash(&self) -> f64 {
// Keep the configured opening capital stable. External flows change // Keep the configured opening capital stable. External flows change
// `units`, not the meaning of this reporting field. // `units`, not the meaning of this reporting field.
self.initial_cash self.initial_cash.to_f64()
} }
pub fn initial_cash(&self) -> f64 { pub fn initial_cash(&self) -> f64 {
self.initial_cash self.initial_cash.to_f64()
} }
pub fn units(&self) -> f64 { pub fn units(&self) -> f64 {
self.units self.units.to_f64()
} }
pub fn cash(&self) -> f64 { pub fn cash(&self) -> f64 {
self.cash self.cash.to_f64()
} }
pub fn external_cash_flow_total(&self) -> 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 { pub fn cash_liabilities(&self) -> f64 {
self.cash_liabilities self.cash_liabilities.to_f64()
} }
pub fn management_fee_rate(&self) -> f64 { pub fn management_fee_rate(&self) -> f64 {
@@ -512,7 +515,7 @@ impl PortfolioState {
} }
pub fn management_fees(&self) -> f64 { pub fn management_fees(&self) -> f64 {
self.management_fees self.management_fees.to_f64()
} }
pub fn positions(&self) -> &IndexMap<String, Position> { pub fn positions(&self) -> &IndexMap<String, Position> {
@@ -533,8 +536,12 @@ impl PortfolioState {
.or_insert_with(|| Position::new(symbol)) .or_insert_with(|| Position::new(symbol))
} }
pub fn apply_cash_delta(&mut self, delta: f64) { pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> {
self.cash += delta; 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) { pub fn prune_flat_positions(&mut self) {
@@ -558,21 +565,34 @@ impl PortfolioState {
} }
pub fn deposit_withdraw(&mut self, amount: f64) -> Result<(), String> { pub fn deposit_withdraw(&mut self, amount: f64) -> Result<(), String> {
if !amount.is_finite() { let amount_money = Self::fixed_money(amount, "deposit_withdraw amount")?;
return Err("deposit_withdraw amount must be finite".to_string()); let pending_withdrawal =
} Self::fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 { let available_cash = self
let available_cash = self.cash - self.pending_withdrawal_total(); .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!( return Err(format!(
"insufficient cash for withdrawal amount={:.2} available_cash={:.2}", "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(); let unit_net_value = self.unit_net_value();
self.cash += amount; self.cash = self
self.external_cash_flow_total += amount; .cash
self.rebase_units_after_external_cash_flow(unit_net_value); .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(()) Ok(())
} }
@@ -582,14 +602,21 @@ impl PortfolioState {
amount: f64, amount: f64,
reason: impl Into<String>, reason: impl Into<String>,
) -> Result<(), String> { ) -> Result<(), String> {
if !amount.is_finite() { let amount_money = Self::fixed_money(amount, "deposit_withdraw amount")?;
return Err("deposit_withdraw amount must be finite".to_string()); let pending_withdrawal =
} Self::fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 { let available_cash = self
let available_cash = self.cash - self.pending_withdrawal_total(); .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!( return Err(format!(
"insufficient cash for scheduled withdrawal amount={:.2} available_cash={:.2}", "insufficient cash for scheduled withdrawal amount={:.2} available_cash={:.2}",
amount, available_cash amount,
available_cash.to_f64()
)); ));
} }
self.pending_cash_flows.push(PendingCashFlow { self.pending_cash_flows.push(PendingCashFlow {
@@ -620,24 +647,33 @@ impl PortfolioState {
// negative after trades on an earlier day. Validate the complete due // negative after trades on an earlier day. Validate the complete due
// batch before mutating either cash or the pending queue so a failed // batch before mutating either cash or the pending queue so a failed
// settlement is atomic and can be diagnosed/retried safely. // settlement is atomic and can be diagnosed/retried safely.
let incoming = due let incoming = Self::sum_fixed_money(
.iter() due.iter()
.filter(|flow| flow.amount > 0.0) .filter(|flow| flow.amount > 0.0)
.map(|flow| flow.amount) .map(|flow| flow.amount),
.sum::<f64>(); "incoming scheduled cash flows",
let outgoing = due )?;
.iter() let outgoing = Self::sum_fixed_money(
.filter(|flow| flow.amount < 0.0) due.iter()
.map(|flow| flow.amount) .filter(|flow| flow.amount < 0.0)
.sum::<f64>(); .map(|flow| flow.amount),
if self.cash + incoming + outgoing < -1e-6 { "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 = due.into_iter().chain(pending).collect();
self.pending_cash_flows self.pending_cash_flows
.sort_by_key(|flow| flow.payable_date); .sort_by_key(|flow| flow.payable_date);
return Err(format!( return Err(format!(
"insufficient cash to settle delayed cash flows on {date}: cash={:.2} net_due={:.2}", "insufficient cash to settle delayed cash flows on {date}: cash={:.2} net_due={:.2}",
self.cash, self.cash.to_f64(),
incoming + outgoing net_due.to_f64()
)); ));
} }
@@ -649,9 +685,16 @@ 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();
self.cash += flow.amount; let amount = Self::fixed_money(flow.amount, "scheduled cash flow")?;
self.external_cash_flow_total += flow.amount; self.cash = self
self.rebase_units_after_external_cash_flow(unit_net_value); .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); settled.push(flow);
} }
self.pending_cash_flows = pending; self.pending_cash_flows = pending;
@@ -671,24 +714,38 @@ impl PortfolioState {
} }
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> { pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> {
if !amount.is_finite() { let amount_money = Self::fixed_money(amount, "finance_repay amount")?;
return Err("finance_repay amount must be finite".to_string()); if amount_money.raw() > 0 {
} self.cash_liabilities = self
if amount > 0.0 { .cash_liabilities
self.cash_liabilities += amount; .checked_add(amount_money)
self.cash += amount; .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(()); return Ok(());
} }
if amount < 0.0 { if amount_money.raw() < 0 {
let repay_amount = (-amount).min(self.cash_liabilities); let requested = amount_money
if repay_amount > self.cash + 1e-6 { .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!( return Err(format!(
"insufficient cash for finance repay amount={:.2} cash={:.2}", "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_liabilities = self
self.cash -= repay_amount; .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(()) Ok(())
} }
@@ -706,11 +763,18 @@ impl PortfolioState {
} }
pub fn apply_management_fee(&mut self, fee: f64) -> Result<(), String> { 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()); return Err("management fee must be finite and non-negative".to_string());
} }
self.cash -= fee; self.cash = self
self.management_fees += fee; .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(()) Ok(())
} }
@@ -719,7 +783,12 @@ impl PortfolioState {
let mut pending = Vec::new(); let mut pending = Vec::new();
for receivable in self.cash_receivables.drain(..) { for receivable in self.cash_receivables.drain(..) {
if receivable.payable_date <= date { 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); settled.push(receivable);
} else { } else {
pending.push(receivable); pending.push(receivable);
@@ -838,7 +907,7 @@ impl PortfolioState {
} }
pub fn total_equity(&self) -> f64 { 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 { pub fn total_value(&self) -> f64 {
@@ -850,18 +919,18 @@ impl PortfolioState {
} }
pub fn unit_net_value(&self) -> f64 { pub fn unit_net_value(&self) -> f64 {
if self.units.abs() < f64::EPSILON { if self.units.raw() == 0 {
0.0 0.0
} else { } else {
self.total_equity() / self.units self.total_equity() / self.units.to_f64()
} }
} }
pub fn static_unit_net_value(&self) -> f64 { pub fn static_unit_net_value(&self) -> f64 {
if self.units.abs() < f64::EPSILON { if self.units.raw() == 0 {
0.0 0.0
} else { } 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) { fn refresh_dividend_receivables(&mut self) {
let mut per_symbol = BTreeMap::<String, f64>::new(); let mut per_symbol = BTreeMap::<String, f64>::new();
for receivable in &self.cash_receivables { 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() { 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; 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] #[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();
@@ -1682,7 +1815,7 @@ mod tests {
// A strategy cannot spend the reserved cash by scheduling a second // A strategy cannot spend the reserved cash by scheduling a second
// withdrawal; settlement remains safe even if earlier trading reduced // withdrawal; settlement remains safe even if earlier trading reduced
// the current cash balance. // the current cash balance.
portfolio.apply_cash_delta(-3_000.0); portfolio.apply_cash_delta(-3_000.0).unwrap();
let error = portfolio let error = portfolio
.settle_pending_cash_flows(date) .settle_pending_cash_flows(date)
.expect_err("settlement must reject an underfunded withdrawal batch"); .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::engine::BacktestError;
use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent}; use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent};
use crate::fixed_point::FixedMoney;
use crate::futures::{FuturesAccountState, FuturesOrderIntent}; use crate::futures::{FuturesAccountState, FuturesOrderIntent};
use crate::instrument::Instrument; use crate::instrument::Instrument;
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
@@ -1750,14 +1751,47 @@ impl OmniMicroCapStrategy {
ChinaAShareCostModel::from_trading_constraints(self.config.risk_config.trading_constraints) ChinaAShareCostModel::from_trading_constraints(self.config.risk_config.trading_constraints)
} }
fn buy_commission(&self, gross_amount: f64) -> f64 { fn buy_cost(&self, gross_amount: f64) -> f64 {
self.cost_model().commission_for(gross_amount) 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 { fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 {
let model = self.cost_model(); let model = self.cost_model();
model.commission_for(gross_amount) FixedMoney::checked_sum_f64([
+ model.stamp_tax_for(date, OrderSide::Sell, gross_amount) 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( 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); let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100, 100);
while quantity > 0 { while quantity > 0 {
let gross_amount = execution_price * quantity as f64; 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; return quantity;
} }
quantity = self.decrement_order_quantity(quantity, 100, 100); quantity = self.decrement_order_quantity(quantity, 100, 100);
@@ -1874,8 +1908,10 @@ impl OmniMicroCapStrategy {
); );
while snapshot_requested_qty > 0 { while snapshot_requested_qty > 0 {
let gross_amount = sizing_price * snapshot_requested_qty as f64; let gross_amount = sizing_price * snapshot_requested_qty as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount); let cash_out = self.buy_cash_out(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { if Self::fixed_cash_fits(cash_out, order_value)
&& Self::fixed_cash_fits(cash_out, projected.cash())
{
break; break;
} }
snapshot_requested_qty = self.decrement_order_quantity( snapshot_requested_qty = self.decrement_order_quantity(
@@ -1902,8 +1938,10 @@ impl OmniMicroCapStrategy {
let mut quantity = snapshot_requested_qty; let mut quantity = snapshot_requested_qty;
while quantity > 0 { while quantity > 0 {
let gross_amount = projected_execution_price * quantity as f64; let gross_amount = projected_execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount); let cash_out = self.buy_cash_out(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { if Self::fixed_cash_fits(cash_out, order_value)
&& Self::fixed_cash_fits(cash_out, projected.cash())
{
break; break;
} }
quantity = quantity =
@@ -1918,8 +1956,10 @@ impl OmniMicroCapStrategy {
.unwrap_or(projected_execution_price); .unwrap_or(projected_execution_price);
while quantity > 0 { while quantity > 0 {
let gross_amount = execution_price * quantity as f64; let gross_amount = execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount); let cash_out = self.buy_cash_out(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { if Self::fixed_cash_fits(cash_out, order_value)
&& Self::fixed_cash_fits(cash_out, projected.cash())
{
break; break;
} }
quantity = quantity =
@@ -1934,11 +1974,15 @@ impl OmniMicroCapStrategy {
next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1), next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1),
}; };
let gross_amount = fill.price * fill.quantity as f64; let gross_amount = fill.price * fill.quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount); let cash_out = self.buy_cash_out(gross_amount);
if cash_out > projected.cash() + 1e-6 || cash_out > order_value + 1e-6 { if !Self::fixed_cash_fits(cash_out, projected.cash())
|| !Self::fixed_cash_fits(cash_out, order_value)
{
return 0; return 0;
} }
projected.apply_cash_delta(-cash_out); projected
.apply_cash_delta(-cash_out)
.expect("projected buy cash must fit fixed-point ledger");
projected projected
.position_mut(symbol) .position_mut(symbol)
.buy(date, fill.quantity, fill.price); .buy(date, fill.quantity, fill.price);
@@ -1994,12 +2038,14 @@ impl OmniMicroCapStrategy {
+ Duration::seconds(1), + Duration::seconds(1),
}); });
let gross_amount = fill.price * fill.quantity as f64; 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 projected
.position_mut(symbol) .position_mut(symbol)
.sell(fill.quantity, fill.price) .sell(fill.quantity, fill.price)
.ok()?; .ok()?;
projected.apply_cash_delta(net_cash); projected
.apply_cash_delta(net_cash)
.expect("projected sell cash must fit fixed-point ledger");
*execution_state *execution_state
.intraday_turnover .intraday_turnover
.entry(symbol.to_string()) .entry(symbol.to_string())
@@ -2144,7 +2190,9 @@ impl OmniMicroCapStrategy {
); );
while take_qty > 0 { while take_qty > 0 {
let candidate_gross = execution_price * take_qty as f64; 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 = self.decrement_order_quantity(
take_qty, take_qty,
minimum_order_quantity, minimum_order_quantity,
@@ -2152,9 +2200,8 @@ impl OmniMicroCapStrategy {
); );
continue; continue;
} }
let candidate_cash = let candidate_cash = self.buy_cash_out(candidate_gross);
candidate_gross + self.buy_commission(candidate_gross); if Self::fixed_cash_fits(candidate_cash, cash) {
if candidate_cash <= cash + 1e-6 {
break; break;
} }
take_qty = self.decrement_order_quantity( take_qty = self.decrement_order_quantity(
@@ -2254,7 +2301,9 @@ impl OmniMicroCapStrategy {
if let Some(cash) = cash_limit { if let Some(cash) = cash_limit {
while take_qty > 0 { while take_qty > 0 {
let candidate_gross = gross_amount + quote_price * take_qty as f64; 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 = self.decrement_order_quantity(
take_qty, take_qty,
minimum_order_quantity, minimum_order_quantity,
@@ -2262,7 +2311,7 @@ impl OmniMicroCapStrategy {
); );
continue; 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; break;
} }
take_qty = self.decrement_order_quantity( take_qty = self.decrement_order_quantity(
@@ -2870,8 +2919,8 @@ mod tests {
.stamp_tax_rate_after_change = 0.0005; .stamp_tax_rate_after_change = 0.0005;
let strategy = OmniMicroCapStrategy::new(cfg); let strategy = OmniMicroCapStrategy::new(cfg);
assert!((strategy.buy_commission(100_000.0) - 30.0).abs() < 1e-9); assert!((strategy.buy_cost(100_000.0) - 30.0).abs() < 1e-9);
assert!((strategy.buy_commission(1_000.0) - 5.0).abs() < 1e-9); assert!((strategy.buy_cost(1_000.0) - 5.0).abs() < 1e-9);
assert!( assert!(
(strategy.sell_cost(NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), 100_000.0) - 80.0) (strategy.sell_cost(NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), 100_000.0) - 80.0)
.abs() .abs()
+3 -5
View File
@@ -368,11 +368,9 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
first_date: buy_date, first_date: buy_date,
}, },
BrokerSimulator::new_with_execution_price( BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel { ChinaAShareCostModel::default()
commission_rate: 0.0008, .with_commission_rate(0.0008)
minimum_commission: 0.0, .with_minimum_commission(0.0),
..ChinaAShareCostModel::default()
},
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
), ),
+3
View File
@@ -39,6 +39,7 @@ futures path. Confirmed aligned areas:
| P0 | Futures intraday matching | Closed for daily/open/close, tick-price futures fills, and true multi-level order-book sweeping when optional `order_book_depth` data exists. L1-only data still uses the existing L1 matcher and is not inflated into fake depth. | Extend depth fields only if production vendors expose more levels or exchange-specific fields. | | P0 | Futures intraday matching | Closed for daily/open/close, tick-price futures fills, and true multi-level order-book sweeping when optional `order_book_depth` data exists. L1-only data still uses the existing L1 matcher and is not inflated into fake depth. | Extend depth fields only if production vendors expose more levels or exchange-specific fields. |
| P0 | Futures open-order lifecycle | Closed for futures pending limit orders, cross-day rematching, cancellation by id/symbol/all, and merged open-order runtime views. | Add more order status transitions only if UI requires extra intermediate event names. | | P0 | Futures open-order lifecycle | Closed for futures pending limit orders, cross-day rematching, cancellation by id/symbol/all, and merged open-order runtime views. | Add more order status transitions only if UI requires extra intermediate event names. |
| P0 | Combined multi-account NAV | Closed. `DailyEquityPoint`, progress events, and metrics use aggregate stock + futures initial cash and total equity. | None. | | P0 | Combined multi-account NAV | Closed. `DailyEquityPoint`, progress events, and metrics use aggregate stock + futures initial cash and total equity. | None. |
| P0 | Fixed-point execution money | Stock execution now freezes fee rates once and uses signed micro-yuan `i128` for gross amount, commission, stamp tax, transfer fee, strict budget checks, cash, liabilities, management fees, external flows and account units. Market indicators and return statistics remain `f64` outside the execution boundary. | Migrate position cost/PnL and the standalone futures cash ledger only after independent artifact and performance A/B gates. |
| P1 | Futures trading parameter data source | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. | | P1 | Futures trading parameter data source | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. |
| P1 | Futures transaction cost decider | Closed. `FuturesTransactionCostModel` calculates by-money/by-volume open/close/close-today costs from trading parameters. | None. | | P1 | Futures transaction cost decider | Closed. `FuturesTransactionCostModel` calculates by-money/by-volume open/close/close-today costs from trading parameters. | None. |
| P1 | Futures settlement price mode | Closed. Engine supports configurable settlement price mode and resolves settlement/prev-settlement from factor fields with close/prev_close fallback. | Add dedicated settlement columns if the storage layer later separates them from factors. | | P1 | Futures settlement price mode | Closed. Engine supports configurable settlement price mode and resolves settlement/prev-settlement from factor fields with close/prev_close fallback. | Add dedicated settlement columns if the storage layer later separates them from factors. |
@@ -55,6 +56,8 @@ futures path. Confirmed aligned areas:
- [x] Minute-level `time_rule` semantics including market-open, market-close, - [x] Minute-level `time_rule` semantics including market-open, market-close,
and physical-time style schedules. and physical-time style schedules.
- [x] Fine-grained daily and minute execution quote strategy entrypoints. - [x] Fine-grained daily and minute execution quote strategy entrypoints.
- [x] Stock broker fee, budget and cash-ledger arithmetic uses a micro-yuan
fixed-point execution primitive; one-micro over-budget orders fail.
- [x] Scheduled actions evaluated against explicit intraday times. - [x] Scheduled actions evaluated against explicit intraday times.
- [x] `update_universe`, `subscribe`, and `unsubscribe`. - [x] `update_universe`, `subscribe`, and `unsubscribe`.
- [x] Intraday subscription guards at strategy API level; intraday execution uses minute quote semantics. - [x] Intraday subscription guards at strategy API level; intraday execution uses minute quote semantics.