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

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]
+94 -3
View File
@@ -1728,6 +1728,7 @@ where
daily_holdings: Vec::new(),
metrics: BacktestMetrics::default(),
};
let mut stock_equity_by_date = BTreeMap::<NaiveDate, f64>::new();
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
let mut corporate_action_notes = Vec::new();
@@ -1875,8 +1876,12 @@ where
process_events: day_process_events,
});
result.process_events.append(&mut process_events);
stock_equity_by_date.insert(execution_date, portfolio.total_equity());
continue;
};
let decision_total_equity = (decision_date < execution_date)
.then(|| stock_equity_by_date.get(&decision_date).copied())
.flatten();
let mut process_events = Vec::new();
let mut directive_report = BrokerExecutionReport::default();
let pre_open_orders = self.open_order_views();
@@ -2073,10 +2078,11 @@ where
None,
None,
)?;
let mut report = self.broker.execute_with_event_dates(
let mut report = self.broker.execute_with_event_dates_and_decision_equity(
execution_date,
decision_date,
decision_date,
decision_total_equity,
&mut portfolio,
&self.data,
&auction_decision,
@@ -2321,10 +2327,11 @@ where
None,
None,
)?;
let mut intraday_report = self.broker.execute_with_event_dates(
let mut intraday_report = self.broker.execute_with_event_dates_and_decision_equity(
execution_date,
decision_date,
decision_date,
decision_total_equity,
&mut portfolio,
&self.data,
&decision,
@@ -2492,10 +2499,13 @@ where
Some(minute_time),
Some(minute_time),
)?;
let mut minute_report = self.broker.execute_between_with_event_dates(
let mut minute_report = self
.broker
.execute_between_with_event_dates_and_decision_equity(
execution_date,
decision_date,
decision_date,
decision_total_equity,
&mut portfolio,
&self.data,
&minute_decision,
@@ -2888,6 +2898,7 @@ where
process_events: day_process_events,
});
result.process_events.extend(process_events);
stock_equity_by_date.insert(execution_date, portfolio.total_equity());
}
if let Some(last_date) = execution_dates.last().copied() {
@@ -4323,6 +4334,43 @@ mod tests {
}
}
#[derive(Debug)]
struct ScheduledTargetPercentStrategy {
first_decision_date: NaiveDate,
second_decision_date: NaiveDate,
}
impl Strategy for ScheduledTargetPercentStrategy {
fn name(&self) -> &str {
"scheduled_target_percent"
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, super::BacktestError> {
let order_intents = if ctx.decision_date == self.first_decision_date {
vec![OrderIntent::Shares {
symbol: SYMBOL.to_string(),
quantity: 1_000,
reason: "initial_position".to_string(),
}]
} else if ctx.decision_date == self.second_decision_date {
vec![OrderIntent::TargetPercent {
symbol: SYMBOL.to_string(),
target_percent: 0.5,
reason: "frozen_target_percent".to_string(),
}]
} else {
Vec::new()
};
Ok(StrategyDecision {
order_intents,
..StrategyDecision::default()
})
}
}
#[derive(Debug)]
struct ScheduledEligibleUniverseBuyStrategy {
rule: ScheduleRule,
@@ -5003,6 +5051,49 @@ mod tests {
assert_eq!(result.fills[0].quantity, 8_300);
}
#[test]
fn next_bar_open_target_percent_freezes_decision_day_equity() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let third = d(2025, 1, 6);
let dataset = dataset_from_market_and_candidates(
vec![
market(first, 10.0, 10.0),
market(second, 10.0, 10.0),
market(third, 20.0, 20.0),
],
vec![candidate(first), candidate(second), candidate(third)],
);
let config = BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(first),
end_date: Some(third),
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
};
let result = BacktestEngine::new(
dataset,
ScheduledTargetPercentStrategy {
first_decision_date: first,
second_decision_date: second,
},
scheduled_next_open_broker(FidcRiskControlConfig::default()),
config,
)
.run()
.expect("backtest run");
assert_eq!(result.fills.len(), 2, "fills={:?}", result.fills);
assert_eq!(result.fills[0].date, second);
assert_eq!(result.fills[0].quantity, 1_000);
assert_eq!(result.fills[1].date, third);
assert_eq!(result.fills[1].price, 20.0);
assert_eq!(result.fills[1].quantity, 1_400);
assert_eq!(result.fills[1].decision_date, Some(second));
}
#[test]
fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() {
let first = d(2025, 1, 2);
@@ -315,7 +315,7 @@ fn band_low(index_close) {
stamp_tax_rate_before_change: None,
stamp_tax_rate_after_change: None,
stamp_tax_change_date: None,
strict_value_budget: false,
strict_value_budget: true,
rebalance_cash_mode: RebalanceCashMode::default(),
sell_then_buy_delay_slippage_rate: 0.0,
risk_config: FidcRiskControlConfig::default(),
@@ -1130,9 +1130,10 @@ fn apply_execution_behavior_overrides(
cfg.slippage_model = parsed;
}
}
if let Some(enabled) = strict_value_budget {
cfg.strict_value_budget = enabled;
if strict_value_budget == Some(false) {
return Err("strictValueBudget=false is not supported".to_string());
}
cfg.strict_value_budget = true;
if let Some(rate) = sell_then_buy_delay_slippage_rate {
if !rate.is_finite() || !(0.0..1.0).contains(&rate) {
return Err(
@@ -1886,9 +1887,7 @@ pub fn platform_expr_config_from_spec(
{
cfg.minimum_commission = None;
}
if aiquant_profile {
cfg.strict_value_budget = true;
}
Ok(cfg)
}
@@ -2767,7 +2766,7 @@ mod tests {
"matchingType": "current_bar_close",
"slippageModel": "none",
"slippageValue": 0.0,
"strictValueBudget": false
"strictValueBudget": true
}
});
+4 -3
View File
@@ -135,7 +135,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
"AI 生成策略时只能输出完整 engine-script 代码,不输出 Markdown、解释、推理过程、JSON 包装或手册复述。".to_string(),
"表达式字段以运行时字段为准:市值使用 market_cap,流通市值使用 free_float_cap;不要在策略表达式中使用数据库原始字段 float_market_cap。".to_string(),
"任意窗口价格均线使用 rolling_mean(\"close\", n) 或 ma(\"close\", n),任意窗口均量使用 rolling_mean(\"volume\", n) 或 vma(n);不要使用未列出的 ma60、stock_ma60、signal_ma60 或 benchmark_ma60 变量。".to_string(),
"next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(),
"next_bar_open 会在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数;不得把执行日 open/high/low/close 当成下单前已知信息,也不得用 T+1 prev_close 或 T 日估算股数直接成交;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(),
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
],
@@ -258,7 +258,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
},
ManualSection {
title: "execution.matching_type / execution.slippage".to_string(),
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\")current_bar_close 使用决策日当日 closenext_bar_open 使用决策日信号并在下一可交易日 open 撮合,禁止把执行日 open/high/low/close 解释为下单前已知数据;next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。日线调仓现金口径由 execution.rebalance_cash_mode(\"sell_then_buy\" | \"same_point_net\" | \"pre_open_cash\") 或页面/API 参数控制,默认 sell_then_buysell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\")current_bar_close 使用决策日当日 closenext_bar_open 在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数,保证执行金额加手续费不超过分配金额;禁止把执行日 open/high/low/close 解释为下单前已知数据,也禁止用 T+1 prev_close 或 T 日估算股数直接成交next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。金额预算始终严格,execution.strict_value_budget(false) 会被拒绝。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。日线调仓现金口径由 execution.rebalance_cash_mode(\"sell_then_buy\" | \"same_point_net\" | \"pre_open_cash\") 或页面/API 参数控制,默认 sell_then_buysell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
},
ManualSection {
title: "期货提交校验".to_string(),
@@ -485,6 +485,7 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
out.push_str("- 完整三元表达式 `cond ? a : b` 可在表达式参数中使用;若当前运行环境报 `Unknown operator: '?'`,先重编译并重启回测服务,不要改写策略语义掩盖运行时漂移。\n");
out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\n");
out.push_str("- `next_bar_open` 必须区分信号日、订单创建日和实际成交日:T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须比较实际 next-open 成交价与涨跌停价,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
out.push_str("- 日线目标金额、目标比例和目标权重在 `next_bar_open` 下冻结 T 日收盘目标,T+1 按实际 open、滑点、卖后买延迟滑点、手续费和证券数量步长重算股数;禁止用 T+1 prev_close、T 日估算股数或 T+1 开盘后权益替代。金额预算始终严格,不能生成 `execution.strict_value_budget(false)`。\n");
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
out.push_str("## 语句块\n");
for item in &manual.statement_blocks {
@@ -564,7 +565,7 @@ pub fn build_generation_prompt(
prompt.push('\n');
prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\n");
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、risk.policy、risk.blacklist、execution.matching_type、execution.slippage、universe.exclude。universe.exclude 只用于用户明确要求的业务排除项,不能表达 FIDC 基础风控。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
prompt.push_str(&format!("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma6060日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n)rolling_mean、rolling_sum/min/max/stddev/zscore、pct_change、factor_value 等 helper 的第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposurerisk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,必须覆盖完整默认配置面,例如 {DEFAULT_RISK_POLICY_DSL_PROMPT},不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_typenext_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n"));
prompt.push_str(&format!("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma6060日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n)rolling_mean、rolling_sum/min/max/stddev/zscore、pct_change、factor_value 等 helper 的第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposurerisk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,必须覆盖完整默认配置面,例如 {DEFAULT_RISK_POLICY_DSL_PROMPT},不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_typenext_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图并在收盘冻结目标金额或目标权益,T+1 按实际 open、滑点、手续费和证券数量步长重算股数,不能用 T+1 prev_close 或 T 日估算股数直接成交;涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单;金额预算始终严格,禁止 execution.strict_value_budget(false)execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n"));
prompt.push_str("回测成功但 tradeCount=0 或 holdingCount=0 是无效策略;第一版必须保持稳定买入覆盖率,复杂因子只能在后续优化中逐步加严。\n");
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && close > 2)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.policy(");
prompt.push_str(DEFAULT_RISK_POLICY_DSL_CODE);
@@ -3714,15 +3714,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
.iter()
.any(|event| event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy)
);
assert!(
report
.diagnostics
.iter()
.any(|line| line.contains("rebalance_safety_scaled")
|| line.contains("rebalance_buy_reduced")),
"expected rebalance diagnostics when cash is tight, got {:?}",
report.diagnostics
);
assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics);
}
#[test]