修正下一开盘目标仓位计算

This commit is contained in:
boris
2026-07-12 14:59:12 +08:00
parent 0ea5fae69d
commit bacb70e327
6 changed files with 444 additions and 77 deletions
+330 -46
View File
@@ -64,6 +64,7 @@ struct TargetConstraint {
desired_qty: u32,
provisional_target_qty: u32,
price: f64,
buy_execution_price: f64,
minimum_order_quantity: u32,
order_step_size: u32,
}
@@ -193,6 +194,7 @@ pub struct BrokerSimulator<C, R> {
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
runtime_decision_date: Cell<Option<NaiveDate>>,
runtime_order_created_date: Cell<Option<NaiveDate>>,
runtime_decision_total_equity: Cell<Option<f64>>,
next_order_id: Cell<u64>,
open_orders: RefCell<Vec<OpenOrder>>,
}
@@ -210,7 +212,7 @@ impl<C, R> BrokerSimulator<C, R> {
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
strict_value_budget: false,
strict_value_budget: true,
rebalance_cash_mode: RebalanceCashMode::default(),
sell_then_buy_delay_slippage_rate: 0.0,
aiquant_execution_rules: false,
@@ -222,6 +224,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_end_time: Cell::new(None),
runtime_decision_date: Cell::new(None),
runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None),
next_order_id: Cell::new(1),
open_orders: RefCell::new(Vec::new()),
}
@@ -243,7 +246,7 @@ impl<C, R> BrokerSimulator<C, R> {
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
strict_value_budget: false,
strict_value_budget: true,
rebalance_cash_mode: RebalanceCashMode::default(),
sell_then_buy_delay_slippage_rate: 0.0,
aiquant_execution_rules: false,
@@ -255,6 +258,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_end_time: Cell::new(None),
runtime_decision_date: Cell::new(None),
runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None),
next_order_id: Cell::new(1),
open_orders: RefCell::new(Vec::new()),
}
@@ -276,7 +280,11 @@ impl<C, R> BrokerSimulator<C, R> {
}
pub fn with_strict_value_budget(mut self, enabled: bool) -> Self {
self.strict_value_budget = enabled;
assert!(
enabled,
"strict value budget is mandatory for FIDC order sizing"
);
self.strict_value_budget = true;
self
}
@@ -427,11 +435,11 @@ where
symbol: &str,
snapshot: &crate::data::DailyMarketSnapshot,
) -> f64 {
if self.matching_type == MatchingType::NextBarOpen
&& snapshot.prev_close.is_finite()
&& snapshot.prev_close > 0.0
{
return snapshot.prev_close;
if self.matching_type == MatchingType::NextBarOpen {
let execution_price = snapshot.price(PriceField::Open);
if execution_price.is_finite() && execution_price > 0.0 {
return execution_price;
}
}
if self.aiquant_execution_rules && self.execution_price_field == PriceField::Last {
let start_cursor = self
@@ -716,16 +724,42 @@ where
portfolio: &mut PortfolioState,
data: &DataSet,
decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> {
self.execute_with_event_dates_and_decision_equity(
date,
decision_date,
order_created_date,
None,
portfolio,
data,
decision,
)
}
pub fn execute_with_event_dates_and_decision_equity(
&self,
date: NaiveDate,
decision_date: NaiveDate,
order_created_date: NaiveDate,
decision_total_equity: Option<f64>,
portfolio: &mut PortfolioState,
data: &DataSet,
decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> {
let previous_decision_date = self.runtime_decision_date.get();
let previous_order_created_date = self.runtime_order_created_date.get();
let previous_decision_total_equity = self.runtime_decision_total_equity.get();
self.runtime_decision_date.set(Some(decision_date));
self.runtime_order_created_date
.set(Some(order_created_date));
self.runtime_decision_total_equity
.set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0));
let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
self.runtime_decision_date.set(previous_decision_date);
self.runtime_order_created_date
.set(previous_order_created_date);
self.runtime_decision_total_equity
.set(previous_decision_total_equity);
result
}
@@ -880,15 +914,42 @@ where
decision: &StrategyDecision,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
) -> Result<BrokerExecutionReport, BacktestError> {
self.execute_between_with_event_dates_and_decision_equity(
date,
decision_date,
order_created_date,
None,
portfolio,
data,
decision,
start_time,
end_time,
)
}
#[allow(clippy::too_many_arguments)]
pub fn execute_between_with_event_dates_and_decision_equity(
&self,
date: NaiveDate,
decision_date: NaiveDate,
order_created_date: NaiveDate,
decision_total_equity: Option<f64>,
portfolio: &mut PortfolioState,
data: &DataSet,
decision: &StrategyDecision,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
) -> Result<BrokerExecutionReport, BacktestError> {
let previous_start_time = self.runtime_intraday_start_time.get();
let previous_end_time = self.runtime_intraday_end_time.get();
self.runtime_intraday_start_time.set(start_time);
self.runtime_intraday_end_time.set(end_time);
let result = self.execute_with_event_dates(
let result = self.execute_with_event_dates_and_decision_equity(
date,
decision_date,
order_created_date,
decision_total_equity,
portfolio,
data,
decision,
@@ -2017,8 +2078,22 @@ where
target_weights: &BTreeMap<String, f64>,
valuation_prices: Option<&BTreeMap<String, f64>>,
) -> Result<(BTreeMap<String, u32>, Vec<String>), BacktestError> {
let equity =
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?;
let equity = if valuation_prices.is_none() {
self.target_total_equity_at(date, portfolio, data)?
} else {
self.runtime_decision_total_equity
.get()
.filter(|equity| equity.is_finite() && *equity >= 0.0)
.map(Ok)
.unwrap_or_else(|| {
self.rebalance_total_equity_at_with_overrides(
date,
portfolio,
data,
valuation_prices,
)
})?
};
let target_weight_sum = target_weights
.values()
.copied()
@@ -2036,15 +2111,33 @@ where
data,
valuation_prices,
)?;
let raw_qty = ((equity * weight) / price).floor() as u32;
desired_targets.insert(
symbol.clone(),
let current_qty = portfolio
.position(symbol)
.map(|position| position.quantity)
.unwrap_or(0);
let target_value = (equity * weight).max(0.0);
let current_value = price * current_qty as f64;
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol);
let desired_qty = if target_value > current_value + f64::EPSILON {
let buy_budget = target_value - current_value;
current_qty.saturating_add(self.target_buy_quantity_for_budget(
date,
data,
symbol,
buy_budget,
price,
minimum_order_quantity,
order_step_size,
))
} else {
self.round_buy_quantity(
raw_qty,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
),
);
(target_value / price).floor() as u32,
minimum_order_quantity,
order_step_size,
)
};
desired_targets.insert(symbol.clone(), desired_qty);
}
let mut symbols = BTreeSet::new();
@@ -2087,6 +2180,22 @@ where
order_step_size,
);
let provisional_target_qty = desired_qty.clamp(min_target_qty, max_target_qty);
let buy_quantity = provisional_target_qty.saturating_sub(current_qty);
let sell_quantity = current_qty.saturating_sub(provisional_target_qty);
let buy_execution_price = data
.market(date, &symbol)
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(buy_quantity))
})
.filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0)
.unwrap_or(price);
let sell_execution_price = data
.market(date, &symbol)
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(sell_quantity))
})
.filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0)
.unwrap_or(price);
if desired_qty < current_qty
&& min_target_qty >= current_qty
&& diagnostics.len() < 16
@@ -2132,7 +2241,7 @@ where
if current_qty > provisional_target_qty && cash_mode != RebalanceCashMode::PreOpenCash {
projected_cash += self.estimated_sell_net_cash(
date,
price,
sell_execution_price,
current_qty.saturating_sub(provisional_target_qty),
);
}
@@ -2142,6 +2251,7 @@ where
desired_qty,
provisional_target_qty,
price,
buy_execution_price,
minimum_order_quantity,
order_step_size,
});
@@ -2186,7 +2296,7 @@ where
if target_qty > constraint.current_qty {
buy_cash_out += self.estimated_buy_cash_out(
date,
constraint.price,
constraint.buy_execution_price,
target_qty - constraint.current_qty,
);
}
@@ -2445,8 +2555,22 @@ where
reason: &str,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let equity =
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?;
let equity = if valuation_prices.is_none() {
self.target_total_equity_at(date, portfolio, data)?
} else {
self.runtime_decision_total_equity
.get()
.filter(|equity| equity.is_finite() && *equity >= 0.0)
.map(Ok)
.unwrap_or_else(|| {
self.rebalance_total_equity_at_with_overrides(
date,
portfolio,
data,
valuation_prices,
)
})?
};
for (symbol, weight) in target_weights {
if weight.abs() <= f64::EPSILON {
continue;
@@ -4054,7 +4178,7 @@ where
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?;
let total_equity = self.target_total_equity_at(date, portfolio, data)?;
self.process_target_value(
date,
portfolio,
@@ -4085,7 +4209,7 @@ where
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?;
let total_equity = self.target_total_equity_at(date, portfolio, data)?;
self.process_limit_target_value(
date,
portfolio,
@@ -4324,7 +4448,7 @@ where
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?;
let total_equity = self.target_total_equity_at(date, portfolio, data)?;
self.process_value(
date,
portfolio,
@@ -4355,7 +4479,7 @@ where
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?;
let total_equity = self.target_total_equity_at(date, portfolio, data)?;
self.process_limit_value(
date,
portfolio,
@@ -4495,7 +4619,7 @@ where
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?;
let total_equity = self.target_total_equity_at(date, portfolio, data)?;
self.process_algo_value(
date,
portfolio,
@@ -5343,6 +5467,22 @@ where
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, None)
}
fn target_total_equity_at(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
) -> Result<f64, BacktestError> {
if let Some(equity) = self
.runtime_decision_total_equity
.get()
.filter(|equity| equity.is_finite() && *equity >= 0.0)
{
return Ok(equity);
}
self.rebalance_total_equity_at(date, portfolio, data)
}
fn rebalance_total_equity_at_with_overrides(
&self,
date: NaiveDate,
@@ -5443,6 +5583,60 @@ where
0
}
#[allow(clippy::too_many_arguments)]
fn target_buy_quantity_for_budget(
&self,
date: NaiveDate,
data: &DataSet,
symbol: &str,
value_budget: f64,
fallback_price: f64,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
let snapshot = data.market(date, symbol);
let mut quantity = self.value_buy_quantity(
date,
value_budget,
fallback_price,
minimum_order_quantity,
order_step_size,
);
for _ in 0..8 {
let execution_price = snapshot
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity))
})
.filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price);
let resolved = self.value_buy_quantity(
date,
value_budget,
execution_price,
minimum_order_quantity,
order_step_size,
);
if resolved == quantity {
return quantity;
}
quantity = resolved;
}
while quantity >= minimum_order_quantity.max(1) {
let execution_price = snapshot
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity))
})
.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 {
return quantity;
}
quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
}
0
}
fn decrement_order_quantity(
&self,
quantity: u32,
@@ -7154,7 +7348,7 @@ mod tests {
}
#[test]
fn next_open_target_value_valuation_uses_previous_close() {
fn next_open_target_value_valuation_uses_execution_open() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
@@ -7181,10 +7375,68 @@ mod tests {
assert_eq!(
broker.target_value_valuation_price(date, &data, "000001.SZ", snapshot),
10.0
11.0
);
}
#[test]
fn next_open_target_value_recomputes_quantity_from_execution_open() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_matching_type(MatchingType::NextBarOpen)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let mut snapshot = limit_test_snapshot();
snapshot.date = date;
snapshot.prev_close = 10.0;
snapshot.open = 11.0;
snapshot.close = 20.0;
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![snapshot],
Vec::new(),
vec![limit_test_candidate(true, true)],
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let mut portfolio = PortfolioState::new(20_000.0);
portfolio.position_mut("000001.SZ").buy(
date.pred_opt().expect("previous date"),
1_000,
10.0,
);
portfolio.apply_cash_delta(-10_000.0);
let mut report = BrokerExecutionReport::default();
broker
.process_target_value(
date,
&mut portfolio,
&data,
"000001.SZ",
5_500.0,
"next_open_target_value",
&mut BTreeMap::new(),
&mut BTreeMap::new(),
&mut None,
&mut BTreeMap::new(),
&mut report,
)
.expect("target value execution");
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].price, 11.0);
assert_eq!(report.fill_events[0].quantity, 500);
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 500);
}
#[test]
fn target_portfolio_smart_ignores_zero_weight_symbols_without_market_snapshot() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
@@ -7238,6 +7490,51 @@ mod tests {
);
}
#[test]
fn target_weight_buy_quantity_respects_per_symbol_budget_after_slippage_and_fees() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_matching_type(MatchingType::NextBarOpen)
.with_slippage_model(SlippageModel::PriceRatio(0.002))
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let mut snapshot = limit_test_snapshot();
snapshot.date = date;
snapshot.open = 10.0;
snapshot.close = 10.0;
snapshot.last_price = 10.0;
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![snapshot],
Vec::new(),
vec![limit_test_candidate(true, true)],
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let portfolio = PortfolioState::new(100_000.0);
let target_weights = BTreeMap::from([("000001.SZ".to_string(), 0.5)]);
let (targets, _) = broker
.target_quantities(date, &portfolio, &data, &target_weights)
.expect("target quantities");
let quantity = targets["000001.SZ"];
let execution_price = 10.0 * 1.002;
let allocated_amount = 50_000.0;
assert_eq!(quantity, 4_900);
assert!(broker.estimated_buy_cash_out(date, execution_price, quantity) <= allocated_amount);
assert!(
broker.estimated_buy_cash_out(date, execution_price, quantity + 100) > allocated_amount
);
}
#[test]
fn target_portfolio_smart_records_buy_rejection_when_target_is_blacklisted() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
@@ -7344,7 +7641,7 @@ mod tests {
let (aiquant_targets, _) = aiquant_broker
.target_quantities(date, &portfolio, &data, &target_weights)
.expect("aiquant target quantities");
assert_eq!(aiquant_targets.get("000001.SZ").copied(), Some(50_000));
assert_eq!(aiquant_targets.get("000001.SZ").copied(), Some(49_900));
}
#[test]
@@ -7558,7 +7855,7 @@ mod tests {
}
#[test]
fn target_portfolio_smart_scales_buys_when_full_targets_exceed_cash_by_fees() {
fn target_portfolio_smart_budgets_each_buy_before_cash_optimization() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let symbols = ["000001.SZ", "000002.SZ"];
let instruments = symbols
@@ -7618,20 +7915,7 @@ mod tests {
assert_eq!(target_quantities.get("000001.SZ").copied(), Some(400));
assert_eq!(target_quantities.get("000002.SZ").copied(), Some(400));
assert!(
diagnostics
.iter()
.any(|line| line.contains("rebalance_safety_scaled")),
"{diagnostics:?}"
);
assert!(
diagnostics
.iter()
.any(|line| line.contains("rebalance_buy_reduced")
&& line.contains("provisional=500")
&& line.contains("final=400")),
"{diagnostics:?}"
);
assert!(diagnostics.is_empty(), "{diagnostics:?}");
}
#[test]