修正下一开盘目标仓位计算
This commit is contained in:
+330
-46
@@ -64,6 +64,7 @@ struct TargetConstraint {
|
|||||||
desired_qty: u32,
|
desired_qty: u32,
|
||||||
provisional_target_qty: u32,
|
provisional_target_qty: u32,
|
||||||
price: f64,
|
price: f64,
|
||||||
|
buy_execution_price: f64,
|
||||||
minimum_order_quantity: u32,
|
minimum_order_quantity: u32,
|
||||||
order_step_size: u32,
|
order_step_size: u32,
|
||||||
}
|
}
|
||||||
@@ -193,6 +194,7 @@ pub struct BrokerSimulator<C, R> {
|
|||||||
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
||||||
runtime_decision_date: Cell<Option<NaiveDate>>,
|
runtime_decision_date: Cell<Option<NaiveDate>>,
|
||||||
runtime_order_created_date: Cell<Option<NaiveDate>>,
|
runtime_order_created_date: Cell<Option<NaiveDate>>,
|
||||||
|
runtime_decision_total_equity: Cell<Option<f64>>,
|
||||||
next_order_id: Cell<u64>,
|
next_order_id: Cell<u64>,
|
||||||
open_orders: RefCell<Vec<OpenOrder>>,
|
open_orders: RefCell<Vec<OpenOrder>>,
|
||||||
}
|
}
|
||||||
@@ -210,7 +212,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
volume_limit: true,
|
volume_limit: true,
|
||||||
inactive_limit: true,
|
inactive_limit: true,
|
||||||
liquidity_limit: true,
|
liquidity_limit: true,
|
||||||
strict_value_budget: false,
|
strict_value_budget: true,
|
||||||
rebalance_cash_mode: RebalanceCashMode::default(),
|
rebalance_cash_mode: RebalanceCashMode::default(),
|
||||||
sell_then_buy_delay_slippage_rate: 0.0,
|
sell_then_buy_delay_slippage_rate: 0.0,
|
||||||
aiquant_execution_rules: false,
|
aiquant_execution_rules: false,
|
||||||
@@ -222,6 +224,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
runtime_intraday_end_time: Cell::new(None),
|
runtime_intraday_end_time: Cell::new(None),
|
||||||
runtime_decision_date: Cell::new(None),
|
runtime_decision_date: Cell::new(None),
|
||||||
runtime_order_created_date: Cell::new(None),
|
runtime_order_created_date: Cell::new(None),
|
||||||
|
runtime_decision_total_equity: Cell::new(None),
|
||||||
next_order_id: Cell::new(1),
|
next_order_id: Cell::new(1),
|
||||||
open_orders: RefCell::new(Vec::new()),
|
open_orders: RefCell::new(Vec::new()),
|
||||||
}
|
}
|
||||||
@@ -243,7 +246,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
volume_limit: true,
|
volume_limit: true,
|
||||||
inactive_limit: true,
|
inactive_limit: true,
|
||||||
liquidity_limit: true,
|
liquidity_limit: true,
|
||||||
strict_value_budget: false,
|
strict_value_budget: true,
|
||||||
rebalance_cash_mode: RebalanceCashMode::default(),
|
rebalance_cash_mode: RebalanceCashMode::default(),
|
||||||
sell_then_buy_delay_slippage_rate: 0.0,
|
sell_then_buy_delay_slippage_rate: 0.0,
|
||||||
aiquant_execution_rules: false,
|
aiquant_execution_rules: false,
|
||||||
@@ -255,6 +258,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
runtime_intraday_end_time: Cell::new(None),
|
runtime_intraday_end_time: Cell::new(None),
|
||||||
runtime_decision_date: Cell::new(None),
|
runtime_decision_date: Cell::new(None),
|
||||||
runtime_order_created_date: Cell::new(None),
|
runtime_order_created_date: Cell::new(None),
|
||||||
|
runtime_decision_total_equity: Cell::new(None),
|
||||||
next_order_id: Cell::new(1),
|
next_order_id: Cell::new(1),
|
||||||
open_orders: RefCell::new(Vec::new()),
|
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 {
|
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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,11 +435,11 @@ where
|
|||||||
symbol: &str,
|
symbol: &str,
|
||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
if self.matching_type == MatchingType::NextBarOpen
|
if self.matching_type == MatchingType::NextBarOpen {
|
||||||
&& snapshot.prev_close.is_finite()
|
let execution_price = snapshot.price(PriceField::Open);
|
||||||
&& snapshot.prev_close > 0.0
|
if execution_price.is_finite() && execution_price > 0.0 {
|
||||||
{
|
return execution_price;
|
||||||
return snapshot.prev_close;
|
}
|
||||||
}
|
}
|
||||||
if self.aiquant_execution_rules && self.execution_price_field == PriceField::Last {
|
if self.aiquant_execution_rules && self.execution_price_field == PriceField::Last {
|
||||||
let start_cursor = self
|
let start_cursor = self
|
||||||
@@ -716,16 +724,42 @@ where
|
|||||||
portfolio: &mut PortfolioState,
|
portfolio: &mut PortfolioState,
|
||||||
data: &DataSet,
|
data: &DataSet,
|
||||||
decision: &StrategyDecision,
|
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> {
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||||
let previous_decision_date = self.runtime_decision_date.get();
|
let previous_decision_date = self.runtime_decision_date.get();
|
||||||
let previous_order_created_date = self.runtime_order_created_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_decision_date.set(Some(decision_date));
|
||||||
self.runtime_order_created_date
|
self.runtime_order_created_date
|
||||||
.set(Some(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);
|
let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
|
||||||
self.runtime_decision_date.set(previous_decision_date);
|
self.runtime_decision_date.set(previous_decision_date);
|
||||||
self.runtime_order_created_date
|
self.runtime_order_created_date
|
||||||
.set(previous_order_created_date);
|
.set(previous_order_created_date);
|
||||||
|
self.runtime_decision_total_equity
|
||||||
|
.set(previous_decision_total_equity);
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,15 +914,42 @@ where
|
|||||||
decision: &StrategyDecision,
|
decision: &StrategyDecision,
|
||||||
start_time: Option<NaiveTime>,
|
start_time: Option<NaiveTime>,
|
||||||
end_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> {
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||||
let previous_start_time = self.runtime_intraday_start_time.get();
|
let previous_start_time = self.runtime_intraday_start_time.get();
|
||||||
let previous_end_time = self.runtime_intraday_end_time.get();
|
let previous_end_time = self.runtime_intraday_end_time.get();
|
||||||
self.runtime_intraday_start_time.set(start_time);
|
self.runtime_intraday_start_time.set(start_time);
|
||||||
self.runtime_intraday_end_time.set(end_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,
|
date,
|
||||||
decision_date,
|
decision_date,
|
||||||
order_created_date,
|
order_created_date,
|
||||||
|
decision_total_equity,
|
||||||
portfolio,
|
portfolio,
|
||||||
data,
|
data,
|
||||||
decision,
|
decision,
|
||||||
@@ -2017,8 +2078,22 @@ where
|
|||||||
target_weights: &BTreeMap<String, f64>,
|
target_weights: &BTreeMap<String, f64>,
|
||||||
valuation_prices: Option<&BTreeMap<String, f64>>,
|
valuation_prices: Option<&BTreeMap<String, f64>>,
|
||||||
) -> Result<(BTreeMap<String, u32>, Vec<String>), BacktestError> {
|
) -> Result<(BTreeMap<String, u32>, Vec<String>), BacktestError> {
|
||||||
let equity =
|
let equity = if valuation_prices.is_none() {
|
||||||
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?;
|
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
|
let target_weight_sum = target_weights
|
||||||
.values()
|
.values()
|
||||||
.copied()
|
.copied()
|
||||||
@@ -2036,15 +2111,33 @@ where
|
|||||||
data,
|
data,
|
||||||
valuation_prices,
|
valuation_prices,
|
||||||
)?;
|
)?;
|
||||||
let raw_qty = ((equity * weight) / price).floor() as u32;
|
let current_qty = portfolio
|
||||||
desired_targets.insert(
|
.position(symbol)
|
||||||
symbol.clone(),
|
.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(
|
self.round_buy_quantity(
|
||||||
raw_qty,
|
(target_value / price).floor() as u32,
|
||||||
self.minimum_order_quantity(data, symbol),
|
minimum_order_quantity,
|
||||||
self.order_step_size(data, symbol),
|
order_step_size,
|
||||||
),
|
)
|
||||||
);
|
};
|
||||||
|
desired_targets.insert(symbol.clone(), desired_qty);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut symbols = BTreeSet::new();
|
let mut symbols = BTreeSet::new();
|
||||||
@@ -2087,6 +2180,22 @@ where
|
|||||||
order_step_size,
|
order_step_size,
|
||||||
);
|
);
|
||||||
let provisional_target_qty = desired_qty.clamp(min_target_qty, max_target_qty);
|
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
|
if desired_qty < current_qty
|
||||||
&& min_target_qty >= current_qty
|
&& min_target_qty >= current_qty
|
||||||
&& diagnostics.len() < 16
|
&& diagnostics.len() < 16
|
||||||
@@ -2132,7 +2241,7 @@ where
|
|||||||
if current_qty > provisional_target_qty && cash_mode != RebalanceCashMode::PreOpenCash {
|
if current_qty > provisional_target_qty && cash_mode != RebalanceCashMode::PreOpenCash {
|
||||||
projected_cash += self.estimated_sell_net_cash(
|
projected_cash += self.estimated_sell_net_cash(
|
||||||
date,
|
date,
|
||||||
price,
|
sell_execution_price,
|
||||||
current_qty.saturating_sub(provisional_target_qty),
|
current_qty.saturating_sub(provisional_target_qty),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2142,6 +2251,7 @@ where
|
|||||||
desired_qty,
|
desired_qty,
|
||||||
provisional_target_qty,
|
provisional_target_qty,
|
||||||
price,
|
price,
|
||||||
|
buy_execution_price,
|
||||||
minimum_order_quantity,
|
minimum_order_quantity,
|
||||||
order_step_size,
|
order_step_size,
|
||||||
});
|
});
|
||||||
@@ -2186,7 +2296,7 @@ where
|
|||||||
if target_qty > constraint.current_qty {
|
if target_qty > constraint.current_qty {
|
||||||
buy_cash_out += self.estimated_buy_cash_out(
|
buy_cash_out += self.estimated_buy_cash_out(
|
||||||
date,
|
date,
|
||||||
constraint.price,
|
constraint.buy_execution_price,
|
||||||
target_qty - constraint.current_qty,
|
target_qty - constraint.current_qty,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2445,8 +2555,22 @@ where
|
|||||||
reason: &str,
|
reason: &str,
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> Result<(), BacktestError> {
|
||||||
let equity =
|
let equity = if valuation_prices.is_none() {
|
||||||
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?;
|
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 {
|
for (symbol, weight) in target_weights {
|
||||||
if weight.abs() <= f64::EPSILON {
|
if weight.abs() <= f64::EPSILON {
|
||||||
continue;
|
continue;
|
||||||
@@ -4054,7 +4178,7 @@ where
|
|||||||
commission_state: &mut BTreeMap<u64, f64>,
|
commission_state: &mut BTreeMap<u64, f64>,
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> 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(
|
self.process_target_value(
|
||||||
date,
|
date,
|
||||||
portfolio,
|
portfolio,
|
||||||
@@ -4085,7 +4209,7 @@ where
|
|||||||
commission_state: &mut BTreeMap<u64, f64>,
|
commission_state: &mut BTreeMap<u64, f64>,
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> 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(
|
self.process_limit_target_value(
|
||||||
date,
|
date,
|
||||||
portfolio,
|
portfolio,
|
||||||
@@ -4324,7 +4448,7 @@ where
|
|||||||
commission_state: &mut BTreeMap<u64, f64>,
|
commission_state: &mut BTreeMap<u64, f64>,
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> 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(
|
self.process_value(
|
||||||
date,
|
date,
|
||||||
portfolio,
|
portfolio,
|
||||||
@@ -4355,7 +4479,7 @@ where
|
|||||||
commission_state: &mut BTreeMap<u64, f64>,
|
commission_state: &mut BTreeMap<u64, f64>,
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> 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(
|
self.process_limit_value(
|
||||||
date,
|
date,
|
||||||
portfolio,
|
portfolio,
|
||||||
@@ -4495,7 +4619,7 @@ where
|
|||||||
commission_state: &mut BTreeMap<u64, f64>,
|
commission_state: &mut BTreeMap<u64, f64>,
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> 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(
|
self.process_algo_value(
|
||||||
date,
|
date,
|
||||||
portfolio,
|
portfolio,
|
||||||
@@ -5343,6 +5467,22 @@ where
|
|||||||
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, None)
|
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(
|
fn rebalance_total_equity_at_with_overrides(
|
||||||
&self,
|
&self,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -5443,6 +5583,60 @@ where
|
|||||||
0
|
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(
|
fn decrement_order_quantity(
|
||||||
&self,
|
&self,
|
||||||
quantity: u32,
|
quantity: u32,
|
||||||
@@ -7154,7 +7348,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
let broker = BrokerSimulator::new_with_execution_price(
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
ChinaAShareCostModel::default(),
|
ChinaAShareCostModel::default(),
|
||||||
@@ -7181,10 +7375,68 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
broker.target_value_valuation_price(date, &data, "000001.SZ", snapshot),
|
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]
|
#[test]
|
||||||
fn target_portfolio_smart_ignores_zero_weight_symbols_without_market_snapshot() {
|
fn target_portfolio_smart_ignores_zero_weight_symbols_without_market_snapshot() {
|
||||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
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]
|
#[test]
|
||||||
fn target_portfolio_smart_records_buy_rejection_when_target_is_blacklisted() {
|
fn target_portfolio_smart_records_buy_rejection_when_target_is_blacklisted() {
|
||||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
@@ -7344,7 +7641,7 @@ mod tests {
|
|||||||
let (aiquant_targets, _) = aiquant_broker
|
let (aiquant_targets, _) = aiquant_broker
|
||||||
.target_quantities(date, &portfolio, &data, &target_weights)
|
.target_quantities(date, &portfolio, &data, &target_weights)
|
||||||
.expect("aiquant target quantities");
|
.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]
|
#[test]
|
||||||
@@ -7558,7 +7855,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
let symbols = ["000001.SZ", "000002.SZ"];
|
let symbols = ["000001.SZ", "000002.SZ"];
|
||||||
let instruments = symbols
|
let instruments = symbols
|
||||||
@@ -7618,20 +7915,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(target_quantities.get("000001.SZ").copied(), Some(400));
|
assert_eq!(target_quantities.get("000001.SZ").copied(), Some(400));
|
||||||
assert_eq!(target_quantities.get("000002.SZ").copied(), Some(400));
|
assert_eq!(target_quantities.get("000002.SZ").copied(), Some(400));
|
||||||
assert!(
|
assert!(diagnostics.is_empty(), "{diagnostics:?}");
|
||||||
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:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+103
-12
@@ -1728,6 +1728,7 @@ where
|
|||||||
daily_holdings: Vec::new(),
|
daily_holdings: Vec::new(),
|
||||||
metrics: BacktestMetrics::default(),
|
metrics: BacktestMetrics::default(),
|
||||||
};
|
};
|
||||||
|
let mut stock_equity_by_date = BTreeMap::<NaiveDate, f64>::new();
|
||||||
|
|
||||||
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
|
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
|
||||||
let mut corporate_action_notes = Vec::new();
|
let mut corporate_action_notes = Vec::new();
|
||||||
@@ -1875,8 +1876,12 @@ where
|
|||||||
process_events: day_process_events,
|
process_events: day_process_events,
|
||||||
});
|
});
|
||||||
result.process_events.append(&mut process_events);
|
result.process_events.append(&mut process_events);
|
||||||
|
stock_equity_by_date.insert(execution_date, portfolio.total_equity());
|
||||||
continue;
|
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 process_events = Vec::new();
|
||||||
let mut directive_report = BrokerExecutionReport::default();
|
let mut directive_report = BrokerExecutionReport::default();
|
||||||
let pre_open_orders = self.open_order_views();
|
let pre_open_orders = self.open_order_views();
|
||||||
@@ -2073,10 +2078,11 @@ where
|
|||||||
None,
|
None,
|
||||||
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,
|
execution_date,
|
||||||
decision_date,
|
decision_date,
|
||||||
decision_date,
|
decision_date,
|
||||||
|
decision_total_equity,
|
||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&self.data,
|
&self.data,
|
||||||
&auction_decision,
|
&auction_decision,
|
||||||
@@ -2321,10 +2327,11 @@ where
|
|||||||
None,
|
None,
|
||||||
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,
|
execution_date,
|
||||||
decision_date,
|
decision_date,
|
||||||
decision_date,
|
decision_date,
|
||||||
|
decision_total_equity,
|
||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&self.data,
|
&self.data,
|
||||||
&decision,
|
&decision,
|
||||||
@@ -2492,16 +2499,19 @@ where
|
|||||||
Some(minute_time),
|
Some(minute_time),
|
||||||
Some(minute_time),
|
Some(minute_time),
|
||||||
)?;
|
)?;
|
||||||
let mut minute_report = self.broker.execute_between_with_event_dates(
|
let mut minute_report = self
|
||||||
execution_date,
|
.broker
|
||||||
decision_date,
|
.execute_between_with_event_dates_and_decision_equity(
|
||||||
decision_date,
|
execution_date,
|
||||||
&mut portfolio,
|
decision_date,
|
||||||
&self.data,
|
decision_date,
|
||||||
&minute_decision,
|
decision_total_equity,
|
||||||
Some(minute_time),
|
&mut portfolio,
|
||||||
Some(minute_time),
|
&self.data,
|
||||||
)?;
|
&minute_decision,
|
||||||
|
Some(minute_time),
|
||||||
|
Some(minute_time),
|
||||||
|
)?;
|
||||||
let post_minute_open_orders = self.open_order_views();
|
let post_minute_open_orders = self.open_order_views();
|
||||||
publish_process_events(
|
publish_process_events(
|
||||||
&mut self.strategy,
|
&mut self.strategy,
|
||||||
@@ -2888,6 +2898,7 @@ where
|
|||||||
process_events: day_process_events,
|
process_events: day_process_events,
|
||||||
});
|
});
|
||||||
result.process_events.extend(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() {
|
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)]
|
#[derive(Debug)]
|
||||||
struct ScheduledEligibleUniverseBuyStrategy {
|
struct ScheduledEligibleUniverseBuyStrategy {
|
||||||
rule: ScheduleRule,
|
rule: ScheduleRule,
|
||||||
@@ -5003,6 +5051,49 @@ mod tests {
|
|||||||
assert_eq!(result.fills[0].quantity, 8_300);
|
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]
|
#[test]
|
||||||
fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() {
|
fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() {
|
||||||
let first = d(2025, 1, 2);
|
let first = d(2025, 1, 2);
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ fn band_low(index_close) {
|
|||||||
stamp_tax_rate_before_change: None,
|
stamp_tax_rate_before_change: None,
|
||||||
stamp_tax_rate_after_change: None,
|
stamp_tax_rate_after_change: None,
|
||||||
stamp_tax_change_date: None,
|
stamp_tax_change_date: None,
|
||||||
strict_value_budget: false,
|
strict_value_budget: true,
|
||||||
rebalance_cash_mode: RebalanceCashMode::default(),
|
rebalance_cash_mode: RebalanceCashMode::default(),
|
||||||
sell_then_buy_delay_slippage_rate: 0.0,
|
sell_then_buy_delay_slippage_rate: 0.0,
|
||||||
risk_config: FidcRiskControlConfig::default(),
|
risk_config: FidcRiskControlConfig::default(),
|
||||||
|
|||||||
@@ -1130,9 +1130,10 @@ fn apply_execution_behavior_overrides(
|
|||||||
cfg.slippage_model = parsed;
|
cfg.slippage_model = parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(enabled) = strict_value_budget {
|
if strict_value_budget == Some(false) {
|
||||||
cfg.strict_value_budget = enabled;
|
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 let Some(rate) = sell_then_buy_delay_slippage_rate {
|
||||||
if !rate.is_finite() || !(0.0..1.0).contains(&rate) {
|
if !rate.is_finite() || !(0.0..1.0).contains(&rate) {
|
||||||
return Err(
|
return Err(
|
||||||
@@ -1886,9 +1887,7 @@ pub fn platform_expr_config_from_spec(
|
|||||||
{
|
{
|
||||||
cfg.minimum_commission = None;
|
cfg.minimum_commission = None;
|
||||||
}
|
}
|
||||||
if aiquant_profile {
|
cfg.strict_value_budget = true;
|
||||||
cfg.strict_value_budget = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
@@ -2767,7 +2766,7 @@ mod tests {
|
|||||||
"matchingType": "current_bar_close",
|
"matchingType": "current_bar_close",
|
||||||
"slippageModel": "none",
|
"slippageModel": "none",
|
||||||
"slippageValue": 0.0,
|
"slippageValue": 0.0,
|
||||||
"strictValueBudget": false
|
"strictValueBudget": true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
|||||||
"AI 生成策略时只能输出完整 engine-script 代码,不输出 Markdown、解释、推理过程、JSON 包装或手册复述。".to_string(),
|
"AI 生成策略时只能输出完整 engine-script 代码,不输出 Markdown、解释、推理过程、JSON 包装或手册复述。".to_string(),
|
||||||
"表达式字段以运行时字段为准:市值使用 market_cap,流通市值使用 free_float_cap;不要在策略表达式中使用数据库原始字段 float_market_cap。".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(),
|
"任意窗口价格均线使用 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(),
|
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
|
||||||
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
|
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
|
||||||
],
|
],
|
||||||
@@ -258,7 +258,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
|||||||
},
|
},
|
||||||
ManualSection {
|
ManualSection {
|
||||||
title: "execution.matching_type / execution.slippage".to_string(),
|
title: "execution.matching_type / execution.slippage".to_string(),
|
||||||
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\");current_bar_close 使用决策日当日 close,next_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_buy;sell_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 使用决策日当日 close,next_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_buy;sell_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 {
|
ManualSection {
|
||||||
title: "期货提交校验".to_string(),
|
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("- 完整三元表达式 `cond ? a : b` 可在表达式参数中使用;若当前运行环境报 `Unknown operator: '?'`,先重编译并重启回测服务,不要改写策略语义掩盖运行时漂移。\n");
|
||||||
out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\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 日只生成订单意图,涨跌停、停牌、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("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
|
||||||
out.push_str("## 语句块\n");
|
out.push_str("## 语句块\n");
|
||||||
for item in &manual.statement_blocks {
|
for item in &manual.statement_blocks {
|
||||||
@@ -564,7 +565,7 @@ pub fn build_generation_prompt(
|
|||||||
prompt.push('\n');
|
prompt.push('\n');
|
||||||
prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\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("只允许使用这些可编译语句: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_ma60,60日价格均线写 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 == 0;filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;risk.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_type;next_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_ma60,60日价格均线写 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 == 0;filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;risk.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_type;next_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("回测成功但 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("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\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);
|
prompt.push_str(DEFAULT_RISK_POLICY_DSL_CODE);
|
||||||
|
|||||||
@@ -3714,15 +3714,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy)
|
.any(|event| event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy)
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics);
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user