将股票执行资金切换为定点账本
This commit is contained in:
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user