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