Compare commits

...

7 Commits

6 changed files with 542 additions and 212 deletions
+231 -78
View File
@@ -292,42 +292,76 @@ pub struct DynamicSlippageConfig {
impl DynamicSlippageConfig { impl DynamicSlippageConfig {
pub fn new(impact_coefficient: f64, volatility_coefficient: f64, max_ratio: f64) -> Self { pub fn new(impact_coefficient: f64, volatility_coefficient: f64, max_ratio: f64) -> Self {
Self { Self {
impact_coefficient: impact_coefficient.max(0.0), impact_coefficient,
volatility_coefficient: volatility_coefficient.max(0.0), volatility_coefficient,
max_ratio: max_ratio.max(0.0), max_ratio,
} }
} }
pub(crate) fn ratio( pub(crate) fn ratio(
&self, &self,
snapshot: &crate::data::DailyMarketSnapshot, calibration: &HistoricalSlippageCalibration,
raw_price: f64,
order_value: Option<f64>, order_value: Option<f64>,
) -> f64 { ) -> Result<f64, BacktestError> {
let daily_amount = (snapshot.volume as f64 * raw_price).max(0.0); if [self.impact_coefficient, self.volatility_coefficient, self.max_ratio]
.into_iter().any(|value| !value.is_finite() || value < 0.0)
|| self.max_ratio >= 1.0
|| order_value.is_some_and(|value| !value.is_finite() || value < 0.0)
{
return Err(BacktestError::Execution("invalid_historical_slippage_parameters_or_order_value".into()));
}
let impact_ratio = match order_value { let impact_ratio = match order_value {
Some(value) if value.is_finite() && value > 0.0 && daily_amount > 0.0 => { Some(value) if value.is_finite() && value > 0.0 => {
value / daily_amount value / calibration.turnover_proxy
} }
_ => 0.0, _ => 0.0,
}; };
let volatility_base = if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 { let impact = if self.impact_coefficient == 0.0 { 0.0 } else { impact_ratio * self.impact_coefficient };
snapshot.prev_close let ratio = impact
} else { + calibration.range_ratio * self.volatility_coefficient;
raw_price Ok(ratio.clamp(0.0, self.max_ratio))
}; }
let volatility = if snapshot.high.is_finite() }
&& snapshot.low.is_finite()
&& volatility_base.is_finite() #[derive(Debug, Clone, Copy)]
&& volatility_base > 0.0 pub(crate) struct HistoricalSlippageCalibration {
source_date: NaiveDate,
turnover_proxy: f64,
range_ratio: f64,
}
impl HistoricalSlippageCalibration {
pub(crate) fn for_execution(data: &DataSet, date: NaiveDate, symbol: &str) -> Result<Self, BacktestError> {
let missing = || BacktestError::Execution(format!(
"historical_slippage_calibration_missing symbol={symbol} execution_date={date} policy=previous_completed_session"
));
let previous_date = data.previous_trading_date(date, 1).ok_or_else(missing)?;
let row = data.market(previous_date, symbol).ok_or_else(missing)?;
Self::from_completed_snapshot(row, date)
}
fn from_completed_snapshot(
row: &crate::data::DailyMarketSnapshot,
execution_date: NaiveDate,
) -> Result<Self, BacktestError> {
let turnover_proxy = row.volume as f64 * row.close;
let range_ratio = (row.high - row.low) / row.prev_close;
if row.date >= execution_date
|| [row.high, row.low, row.close, row.prev_close, turnover_proxy]
.into_iter().any(|value| !value.is_finite() || value <= 0.0)
|| row.high < row.low
|| !range_ratio.is_finite()
{ {
((snapshot.high - snapshot.low).abs() / volatility_base).max(0.0) return Err(BacktestError::Execution(format!(
} else { "historical_slippage_calibration_invalid symbol={} source_date={} execution_date={} volume={} high={} low={} close={} prev_close={}",
0.0 row.symbol, row.date, execution_date, row.volume, row.high, row.low, row.close, row.prev_close,
}; )));
let ratio = }
impact_ratio * self.impact_coefficient + volatility * self.volatility_coefficient; Ok(Self {
ratio.clamp(0.0, self.max_ratio) source_date: row.date,
turnover_proxy,
range_ratio,
})
} }
} }
@@ -343,7 +377,7 @@ pub enum SlippageModel {
PriceRatio(f64), PriceRatio(f64),
TickSize(f64), TickSize(f64),
LimitPrice, LimitPrice,
Dynamic(DynamicSlippageConfig), HistoricalVolumeVolatility(DynamicSlippageConfig),
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1109,12 +1143,28 @@ where
fn snapshot_execution_price( fn snapshot_execution_price(
&self, &self,
data: &DataSet,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide, side: OrderSide,
quantity: Option<u32>, quantity: Option<u32>,
) -> f64 { ) -> Result<f64, BacktestError> {
let raw_price = self.snapshot_raw_execution_price(snapshot, side); let raw_price = self.snapshot_raw_execution_price(snapshot, side);
self.apply_slippage(snapshot, side, raw_price, quantity) let calibration = self.slippage_calibration(data, snapshot)?;
self.apply_slippage(snapshot, side, raw_price, quantity, calibration.as_ref())
}
fn slippage_calibration(
&self,
data: &DataSet,
snapshot: &crate::data::DailyMarketSnapshot,
) -> Result<Option<HistoricalSlippageCalibration>, BacktestError> {
if !matches!(self.slippage_model, SlippageModel::HistoricalVolumeVolatility(_))
|| self.is_open_auction_matching()
|| self.is_post_close_fixed_price(snapshot.date)
{
return Ok(None);
}
HistoricalSlippageCalibration::for_execution(data, snapshot.date, &snapshot.symbol).map(Some)
} }
fn snapshot_raw_execution_price( fn snapshot_raw_execution_price(
@@ -1184,17 +1234,18 @@ where
side: OrderSide, side: OrderSide,
raw_price: f64, raw_price: f64,
quantity: Option<u32>, quantity: Option<u32>,
) -> f64 { calibration: Option<&HistoricalSlippageCalibration>,
) -> Result<f64, BacktestError> {
if !raw_price.is_finite() || raw_price <= 0.0 { if !raw_price.is_finite() || raw_price <= 0.0 {
return raw_price; return Ok(raw_price);
} }
if self.is_open_auction_matching() { if self.is_open_auction_matching() {
return self.clamp_execution_price(snapshot, side, raw_price); return Ok(self.clamp_execution_price(snapshot, side, raw_price));
} }
if self.is_post_close_fixed_price(snapshot.date) { if self.is_post_close_fixed_price(snapshot.date) {
return self.clamp_execution_price(snapshot, side, raw_price); return Ok(self.clamp_execution_price(snapshot, side, raw_price));
} }
let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64)); let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64));
@@ -1216,8 +1267,12 @@ where
} }
} }
SlippageModel::LimitPrice => raw_price, SlippageModel::LimitPrice => raw_price,
SlippageModel::Dynamic(config) => { SlippageModel::HistoricalVolumeVolatility(config) => {
let ratio = config.ratio(snapshot, raw_price, order_value); let calibration = calibration.filter(|value| value.source_date < snapshot.date)
.ok_or_else(|| BacktestError::Execution(format!(
"historical_slippage_calibration_required symbol={} execution_date={}", snapshot.symbol, snapshot.date,
)))?;
let ratio = config.ratio(calibration, order_value)?;
match side { match side {
OrderSide::Buy => raw_price * (1.0 + ratio), OrderSide::Buy => raw_price * (1.0 + ratio),
OrderSide::Sell => raw_price * (1.0 - ratio), OrderSide::Sell => raw_price * (1.0 - ratio),
@@ -1231,7 +1286,7 @@ where
adjusted *= 1.0 + self.sell_then_buy_delay_slippage_rate; adjusted *= 1.0 + self.sell_then_buy_delay_slippage_rate;
} }
self.clamp_execution_price(snapshot, side, adjusted) Ok(self.clamp_execution_price(snapshot, side, adjusted))
} }
fn clamp_execution_price( fn clamp_execution_price(
@@ -1266,8 +1321,9 @@ where
side: OrderSide, side: OrderSide,
raw_price: f64, raw_price: f64,
quantity: Option<u32>, quantity: Option<u32>,
) -> f64 { calibration: Option<&HistoricalSlippageCalibration>,
self.apply_slippage(snapshot, side, raw_price, quantity) ) -> Result<f64, BacktestError> {
self.apply_slippage(snapshot, side, raw_price, quantity, calibration)
} }
fn matching_type_for_algo_request( fn matching_type_for_algo_request(
@@ -1577,7 +1633,7 @@ where
.unwrap_or(0); .unwrap_or(0);
if target_qty > current_qty { if target_qty > current_qty {
let requested_qty = target_qty - current_qty; let requested_qty = target_qty - current_qty;
if !self.can_afford_minimum_buy(date, portfolio, data, &symbol) { if !self.can_afford_minimum_buy(date, portfolio, data, &symbol)? {
if report.diagnostics.len() < 32 { if report.diagnostics.len() < 32 {
report.diagnostics.push(format!( report.diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells", "rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
@@ -3385,7 +3441,7 @@ where
price, price,
minimum_order_quantity, minimum_order_quantity,
order_step_size, order_step_size,
)) )?)
} else { } else {
self.round_buy_quantity( self.round_buy_quantity(
(target_value / price).floor() as u32, (target_value / price).floor() as u32,
@@ -3441,15 +3497,17 @@ where
let buy_execution_price = data let buy_execution_price = data
.market(date, &symbol) .market(date, &symbol)
.map(|snapshot| { .map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(buy_quantity)) self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(buy_quantity))
}) })
.transpose()?
.filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0) .filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0)
.unwrap_or(price); .unwrap_or(price);
let sell_execution_price = data let sell_execution_price = data
.market(date, &symbol) .market(date, &symbol)
.map(|snapshot| { .map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(sell_quantity)) self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(sell_quantity))
}) })
.transpose()?
.filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0) .filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0)
.unwrap_or(price); .unwrap_or(price);
if desired_qty < current_qty if desired_qty < current_qty
@@ -3779,7 +3837,7 @@ where
continue; continue;
} }
let buy_qty = target_qty - current_qty; let buy_qty = target_qty - current_qty;
if !self.can_afford_minimum_buy(date, portfolio, data, symbol) { if !self.can_afford_minimum_buy(date, portfolio, data, symbol)? {
if report.diagnostics.len() < 32 { if report.diagnostics.len() < 32 {
report.diagnostics.push(format!( report.diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells", "rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
@@ -4283,9 +4341,9 @@ where
portfolio: &PortfolioState, portfolio: &PortfolioState,
data: &DataSet, data: &DataSet,
symbol: &str, symbol: &str,
) -> bool { ) -> Result<bool, BacktestError> {
let Some(snapshot) = data.market(date, symbol) else { let Some(snapshot) = data.market(date, symbol) else {
return true; return Ok(true);
}; };
let minimum_order_quantity = self.minimum_order_quantity(data, symbol); let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol); let order_step_size = self.order_step_size(data, symbol);
@@ -4295,14 +4353,14 @@ where
order_step_size, order_step_size,
); );
if minimum_buy_quantity == 0 { if minimum_buy_quantity == 0 {
return false; return Ok(false);
} }
let minimum_execution_price = let minimum_execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity)); self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(minimum_buy_quantity))?;
Self::fixed_cash_fits( Ok(Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity), self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity),
portfolio.cash(), portfolio.cash(),
) ))
} }
fn process_sell( fn process_sell(
@@ -4710,7 +4768,7 @@ where
None, None,
algo_request, algo_request,
limit_price, limit_price,
); )?;
let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) = let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) =
fill fill
{ {
@@ -4724,7 +4782,7 @@ where
) )
} else { } else {
let execution_price = let execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(fillable_qty)); self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(fillable_qty))?;
if let Some(reason) = if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price) self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price)
{ {
@@ -6438,7 +6496,7 @@ where
value_gross_limit, value_gross_limit,
algo_request, algo_request,
limit_price, limit_price,
); )?;
let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) = let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) =
fill fill
{ {
@@ -6452,7 +6510,7 @@ where
) )
} else { } else {
let execution_price = let execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(constrained_qty)); self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(constrained_qty))?;
if let Some(reason) = if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price) self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price)
{ {
@@ -6494,10 +6552,11 @@ where
let mut blocked_by_final_price = false; let mut blocked_by_final_price = false;
if filled_qty > 0 { if filled_qty > 0 {
execution_price = self.snapshot_execution_price( execution_price = self.snapshot_execution_price(
data,
snapshot, snapshot,
OrderSide::Buy, OrderSide::Buy,
Some(filled_qty), Some(filled_qty),
); )?;
match self.execution_price_with_limit_slippage_or_rejection( match self.execution_price_with_limit_slippage_or_rejection(
snapshot, snapshot,
OrderSide::Buy, OrderSide::Buy,
@@ -7085,7 +7144,7 @@ where
fallback_price: f64, fallback_price: f64,
minimum_order_quantity: u32, minimum_order_quantity: u32,
order_step_size: u32, order_step_size: u32,
) -> u32 { ) -> Result<u32, BacktestError> {
let snapshot = data.market(date, symbol); let snapshot = data.market(date, symbol);
let mut quantity = self.value_buy_quantity( let mut quantity = self.value_buy_quantity(
date, date,
@@ -7097,8 +7156,9 @@ where
for _ in 0..8 { for _ in 0..8 {
let execution_price = snapshot let execution_price = snapshot
.map(|snapshot| { .map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity)) self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(quantity))
}) })
.transpose()?
.filter(|price| price.is_finite() && *price > 0.0) .filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price); .unwrap_or(fallback_price);
let resolved = self.value_buy_quantity( let resolved = self.value_buy_quantity(
@@ -7109,27 +7169,28 @@ where
order_step_size, order_step_size,
); );
if resolved == quantity { if resolved == quantity {
return quantity; return Ok(quantity);
} }
quantity = resolved; quantity = resolved;
} }
while quantity >= minimum_order_quantity.max(1) { while quantity >= minimum_order_quantity.max(1) {
let execution_price = snapshot let execution_price = snapshot
.map(|snapshot| { .map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity)) self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(quantity))
}) })
.transpose()?
.filter(|price| price.is_finite() && *price > 0.0) .filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price); .unwrap_or(fallback_price);
if Self::fixed_cash_fits( if Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, execution_price, quantity), self.estimated_buy_cash_out(date, execution_price, quantity),
value_budget, value_budget,
) { ) {
return quantity; return Ok(quantity);
} }
quantity = quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
} }
0 Ok(0)
} }
fn decrement_order_quantity( fn decrement_order_quantity(
@@ -7396,14 +7457,14 @@ where
gross_limit: Option<f64>, gross_limit: Option<f64>,
algo_request: Option<&AlgoExecutionRequest>, algo_request: Option<&AlgoExecutionRequest>,
limit_price: Option<f64>, limit_price: Option<f64>,
) -> Option<ExecutionFill> { ) -> Result<Option<ExecutionFill>, BacktestError> {
let matching_type = self.matching_type_for_algo_request(algo_request); let matching_type = self.matching_type_for_algo_request(algo_request);
let post_close_window = self.post_close_execution_window(date); let post_close_window = self.post_close_execution_window(date);
let use_intraday_quotes = post_close_window.is_some() let use_intraday_quotes = post_close_window.is_some()
|| algo_request.is_some() || algo_request.is_some()
|| self.matching_type_uses_intraday_quotes(); || self.matching_type_uses_intraday_quotes();
if !use_intraday_quotes { if !use_intraday_quotes {
return None; return Ok(None);
} }
let runtime_start_time = self.runtime_intraday_start_time.get(); let runtime_start_time = self.runtime_intraday_start_time.get();
@@ -7430,6 +7491,7 @@ where
end_cursor end_cursor
}; };
let quotes = data.execution_quotes_on(date, symbol); let quotes = data.execution_quotes_on(date, symbol);
let calibration = self.slippage_calibration(data, snapshot)?;
if let Some(fill) = self.select_execution_fill_with_ledger( if let Some(fill) = self.select_execution_fill_with_ledger(
symbol, symbol,
@@ -7448,8 +7510,9 @@ where
gross_limit, gross_limit,
limit_price, limit_price,
execution_ledger, execution_ledger,
) { calibration.as_ref(),
return Some(fill); )? {
return Ok(Some(fill));
} }
if post_close_window.is_some() if post_close_window.is_some()
@@ -7464,7 +7527,7 @@ where
.or(self.intraday_execution_start_time) .or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time) + Duration::seconds(1)) .map(|start_time| date.and_time(start_time) + Duration::seconds(1))
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight")); .unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
return Some(ExecutionFill { return Ok(Some(ExecutionFill {
quantity: 0, quantity: 0,
next_cursor, next_cursor,
legs: Vec::new(), legs: Vec::new(),
@@ -7475,10 +7538,10 @@ where
end_cursor, end_cursor,
matching_type == MatchingType::MinuteLast && start_cursor.is_some(), matching_type == MatchingType::MinuteLast && start_cursor.is_some(),
)), )),
}); }));
} }
None Ok(None)
} }
fn empty_intraday_quote_reason( fn empty_intraday_quote_reason(
@@ -7542,7 +7605,9 @@ where
gross_limit, gross_limit,
limit_price, limit_price,
&IntradayExecutionLedger::default(), &IntradayExecutionLedger::default(),
None,
) )
.expect("test quote selection without historical calibration")
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -7564,9 +7629,10 @@ where
gross_limit: Option<f64>, gross_limit: Option<f64>,
limit_price: Option<f64>, limit_price: Option<f64>,
execution_ledger: &IntradayExecutionLedger, execution_ledger: &IntradayExecutionLedger,
) -> Option<ExecutionFill> { calibration: Option<&HistoricalSlippageCalibration>,
) -> Result<Option<ExecutionFill>, BacktestError> {
if requested_qty == 0 { if requested_qty == 0 {
return None; return Ok(None);
} }
let quote_quantity_limited = let quote_quantity_limited =
@@ -7714,7 +7780,7 @@ where
} }
let mut quote_price = let mut quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
{ {
execution_block_reason.get_or_insert(reason); execution_block_reason.get_or_insert(reason);
@@ -7734,7 +7800,7 @@ where
if let Some(cash) = cash_limit { if let Some(cash) = cash_limit {
while take_qty > 0 { while take_qty > 0 {
quote_price = quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
if !quote_price.is_finite() || quote_price <= 0.0 { if !quote_price.is_finite() || quote_price <= 0.0 {
budget_block_reason = Some("invalid execution price"); budget_block_reason = Some("invalid execution price");
take_qty = 0; take_qty = 0;
@@ -7786,7 +7852,7 @@ where
} }
quote_price = quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price);
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
{ {
@@ -7844,7 +7910,7 @@ where
if let Some(reason) = execution_block_reason if let Some(reason) = execution_block_reason
&& !saw_non_blocked_execution_price && !saw_non_blocked_execution_price
{ {
return Some(ExecutionFill { return Ok(Some(ExecutionFill {
quantity: 0, quantity: 0,
next_cursor: execution_block_timestamp next_cursor: execution_block_timestamp
.expect("blocked execution quote timestamp") .expect("blocked execution quote timestamp")
@@ -7852,12 +7918,12 @@ where
legs: Vec::new(), legs: Vec::new(),
liquidity_consumption: Vec::new(), liquidity_consumption: Vec::new(),
unfilled_reason: Some(reason), unfilled_reason: Some(reason),
}); }));
} }
return None; return Ok(None);
} }
Some(ExecutionFill { Ok(Some(ExecutionFill {
quantity: filled_qty, quantity: filled_qty,
next_cursor: last_timestamp.unwrap() + Duration::seconds(1), next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
legs: if matching_type == MatchingType::Vwap { legs: if matching_type == MatchingType::Vwap {
@@ -7881,7 +7947,7 @@ where
} else { } else {
None None
}, },
}) }))
} }
fn quote_has_executable_liquidity( fn quote_has_executable_liquidity(
@@ -8129,6 +8195,88 @@ mod tests {
} }
} }
#[test]
fn historical_slippage_does_not_read_later_daily_fields_for_open_or_minute_fills() {
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let build_data = |changed: bool| {
let mut prior = dated_limit_test_snapshot(previous);
prior.timestamp = None;
let mut current = dated_limit_test_snapshot(date);
current.timestamp = None;
if changed {
current.high = 10.9;
current.low = 9.1;
current.close = 10.8;
current.volume = 400;
}
let mut quote = limit_test_quote(10.0, 10.0, 10.0);
quote.date = date;
quote.timestamp = date.and_hms_opt(13, 7, 0).unwrap();
DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()], vec![prior, current], Vec::new(),
vec![dated_limit_test_candidate(previous, false, false, true, true), dated_limit_test_candidate(date, false, false, true, true)],
vec![dated_limit_test_benchmark(previous), dated_limit_test_benchmark(date)],
Vec::new(), vec![quote],
).unwrap()
};
let decision = StrategyDecision {
order_intents: vec![OrderIntent::Value {
symbol: "000001.SZ".into(), value: 50_000.0, reason: "historical_model_invariance".into(),
}], ..StrategyDecision::default()
};
for matching in [MatchingType::NextBarOpen, MatchingType::MinuteLast] {
let mut results = Vec::new();
for changed in [false, true] {
let data = build_data(changed);
let mut broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(matching)
.with_volume_limit(false).with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(super::DynamicSlippageConfig::new(0.5, 0.3, 0.1)));
if matching == MatchingType::MinuteLast {
broker = broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(13, 7, 0).unwrap());
}
let mut account = PortfolioState::new(1_000_000.0);
let report = broker.execute_with_event_dates(date, previous, previous, &mut account, &data, &decision).unwrap();
assert_eq!(report.fill_events.len(), 1, "{report:?}");
results.push((serde_json::to_value(&report.fill_events).unwrap(), account.cash()));
}
assert_eq!(results[0], results[1], "{matching:?}");
}
}
#[test]
fn historical_slippage_rejects_missing_future_or_invalid_calibration_without_raw_price_fallback() {
let snapshot = limit_test_snapshot();
let date = snapshot.date;
assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&snapshot, date).is_err());
let later = date + chrono::Duration::days(1);
let mut bad = snapshot.clone();
bad.volume = 0;
assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&bad, later).is_err());
bad = snapshot.clone();
bad.high = f64::NAN;
assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&bad, later).is_err());
let calibration = super::HistoricalSlippageCalibration::from_completed_snapshot(&snapshot, later).unwrap();
assert!(super::DynamicSlippageConfig::new(f64::NAN, 0.3, 0.1).ratio(&calibration, Some(100.0)).is_err());
assert!(super::DynamicSlippageConfig::new(-1.0, 0.3, 0.1).ratio(&calibration, Some(100.0)).is_err());
assert!(super::DynamicSlippageConfig::new(0.5, 0.3, 1.0).ratio(&calibration, Some(100.0)).is_err());
let data = DataSet::from_components(vec![limit_test_instrument()], vec![snapshot], Vec::new(),
vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(false).with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(super::DynamicSlippageConfig::default()));
let mut account = PortfolioState::new(1_000_000.0);
let decision = StrategyDecision { order_intents: vec![OrderIntent::Value {
symbol: "000001.SZ".into(), value: 50_000.0, reason: "missing_calibration".into(),
}], ..StrategyDecision::default() };
let error = broker.execute(date, &mut account, &data, &decision).unwrap_err();
assert!(error.to_string().contains("historical_slippage_calibration_missing"), "{error}");
assert_eq!(account.cash(), 1_000_000.0);
assert!(account.positions().is_empty());
}
fn limit_test_candidate(allow_buy: bool, allow_sell: bool) -> CandidateEligibility { fn limit_test_candidate(allow_buy: bool, allow_sell: bool) -> CandidateEligibility {
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");
CandidateEligibility { CandidateEligibility {
@@ -8442,6 +8590,11 @@ mod tests {
let mut snapshot = dated_limit_test_snapshot(date); let mut snapshot = dated_limit_test_snapshot(date);
snapshot.close = 10.0; snapshot.close = 10.0;
snapshot.upper_limit = 20.0; snapshot.upper_limit = 20.0;
let data = DataSet::from_components(
vec![limit_test_instrument()], vec![snapshot.clone()], Vec::new(),
vec![dated_limit_test_candidate(date, false, false, true, true)],
vec![dated_limit_test_benchmark(date)],
).unwrap();
for (hour, minute) in [(14, 59), (15, 31)] { for (hour, minute) in [(14, 59), (15, 31)] {
broker broker
@@ -8452,7 +8605,7 @@ mod tests {
EquityExecutionPhase::ContinuousAuction EquityExecutionPhase::ContinuousAuction
); );
assert_eq!( assert_eq!(
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)), broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(),
12.5 12.5
); );
} }
@@ -8465,7 +8618,7 @@ mod tests {
EquityExecutionPhase::PostCloseFixedPrice EquityExecutionPhase::PostCloseFixedPrice
); );
assert_eq!( assert_eq!(
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)), broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(),
10.0 10.0
); );
} }
+159 -94
View File
@@ -675,6 +675,9 @@ pub struct PlatformExprStrategyConfig {
pub current_day_precomputed_factors: bool, pub current_day_precomputed_factors: bool,
pub completed_session_factor_fields: BTreeSet<String>, pub completed_session_factor_fields: BTreeSet<String>,
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>, pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
/// Explicit frozen candidate order, independent of the strategy's legacy
/// rank expression. Membership-only books keep their original ranking.
pub candidate_order_by_date: BTreeMap<NaiveDate, BTreeMap<String, usize>>,
pub intraday_execution_time: Option<NaiveTime>, pub intraday_execution_time: Option<NaiveTime>,
pub session_event_times: Vec<NaiveTime>, pub session_event_times: Vec<NaiveTime>,
pub explicit_action_times: Vec<NaiveTime>, pub explicit_action_times: Vec<NaiveTime>,
@@ -759,6 +762,7 @@ impl PlatformExprStrategyConfig {
current_day_precomputed_factors: false, current_day_precomputed_factors: false,
completed_session_factor_fields: BTreeSet::new(), completed_session_factor_fields: BTreeSet::new(),
candidate_symbols_by_date: BTreeMap::new(), candidate_symbols_by_date: BTreeMap::new(),
candidate_order_by_date: BTreeMap::new(),
intraday_execution_time: None, intraday_execution_time: None,
session_event_times: Vec::new(), session_event_times: Vec::new(),
explicit_action_times: Vec::new(), explicit_action_times: Vec::new(),
@@ -3007,13 +3011,14 @@ impl PlatformExprStrategy {
fn projected_apply_slippage( fn projected_apply_slippage(
&self, &self,
ctx: &StrategyContext<'_>,
market: &DailyMarketSnapshot, market: &DailyMarketSnapshot,
side: OrderSide, side: OrderSide,
raw_price: f64, raw_price: f64,
quantity: Option<u32>, quantity: Option<u32>,
) -> f64 { ) -> Result<f64, BacktestError> {
if !raw_price.is_finite() || raw_price <= 0.0 { if !raw_price.is_finite() || raw_price <= 0.0 {
return raw_price; return Ok(raw_price);
} }
let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64)); let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64));
let mut adjusted = match self.config.slippage_model { let mut adjusted = match self.config.slippage_model {
@@ -3033,8 +3038,11 @@ impl PlatformExprStrategy {
OrderSide::Sell => raw_price - tick * ticks, OrderSide::Sell => raw_price - tick * ticks,
} }
} }
SlippageModel::Dynamic(config) => { SlippageModel::HistoricalVolumeVolatility(config) => {
let ratio = config.ratio(market, raw_price, order_value); let calibration = crate::broker::HistoricalSlippageCalibration::for_execution(
ctx.data, market.date, &market.symbol,
)?;
let ratio = config.ratio(&calibration, order_value)?;
match side { match side {
OrderSide::Buy => raw_price * (1.0 + ratio), OrderSide::Buy => raw_price * (1.0 + ratio),
OrderSide::Sell => raw_price * (1.0 - ratio), OrderSide::Sell => raw_price * (1.0 - ratio),
@@ -3047,7 +3055,7 @@ impl PlatformExprStrategy {
{ {
adjusted *= 1.0 + self.config.sell_then_buy_delay_slippage_rate; adjusted *= 1.0 + self.config.sell_then_buy_delay_slippage_rate;
} }
Self::projected_clamp_execution_price(market, side, adjusted) Ok(Self::projected_clamp_execution_price(market, side, adjusted))
} }
fn projected_clamp_execution_price( fn projected_clamp_execution_price(
@@ -3246,7 +3254,7 @@ impl PlatformExprStrategy {
cash_limit: Option<f64>, cash_limit: Option<f64>,
gross_limit: Option<f64>, gross_limit: Option<f64>,
execution_state: &ProjectedExecutionState, execution_state: &ProjectedExecutionState,
) -> Option<ProjectedExecutionFill> { ) -> Result<Option<ProjectedExecutionFill>, BacktestError> {
self.projected_select_execution_fill_at_time( self.projected_select_execution_fill_at_time(
ctx, ctx,
date, date,
@@ -3280,11 +3288,11 @@ impl PlatformExprStrategy {
gross_limit: Option<f64>, gross_limit: Option<f64>,
execution_state: &ProjectedExecutionState, execution_state: &ProjectedExecutionState,
execution_time: Option<NaiveTime>, execution_time: Option<NaiveTime>,
) -> Option<ProjectedExecutionFill> { ) -> Result<Option<ProjectedExecutionFill>, BacktestError> {
if requested_qty == 0 { if requested_qty == 0 {
return None; return Ok(None);
} }
let market = ctx.data.market(date, symbol)?; let Some(market) = ctx.data.market(date, symbol) else { return Ok(None); };
let start_cursor = self.projected_execution_start_cursor_at_time( let start_cursor = self.projected_execution_start_cursor_at_time(
ctx, ctx,
@@ -3340,7 +3348,7 @@ impl PlatformExprStrategy {
} }
let mut quote_price = let mut quote_price =
self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty)); self.projected_apply_slippage(ctx, market, side, raw_quote_price, Some(take_qty))?;
if self if self
.projected_execution_limit_rejection_reason(market, side, quote_price) .projected_execution_limit_rejection_reason(market, side, quote_price)
.is_some() .is_some()
@@ -3351,11 +3359,12 @@ impl PlatformExprStrategy {
if let Some(cash) = cash_limit { if let Some(cash) = cash_limit {
while take_qty > 0 { while take_qty > 0 {
quote_price = self.projected_apply_slippage( quote_price = self.projected_apply_slippage(
ctx,
market, market,
side, side,
raw_quote_price, raw_quote_price,
Some(take_qty), Some(take_qty),
); )?;
if self if self
.projected_execution_limit_rejection_reason(market, side, quote_price) .projected_execution_limit_rejection_reason(market, side, quote_price)
.is_some() .is_some()
@@ -3389,7 +3398,7 @@ impl PlatformExprStrategy {
} }
quote_price = quote_price =
self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty)); self.projected_apply_slippage(ctx, market, side, raw_quote_price, Some(take_qty))?;
if self if self
.projected_execution_limit_rejection_reason(market, side, quote_price) .projected_execution_limit_rejection_reason(market, side, quote_price)
.is_some() .is_some()
@@ -3405,13 +3414,13 @@ impl PlatformExprStrategy {
} }
if filled_qty == 0 { if filled_qty == 0 {
return None; return Ok(None);
} }
Some(ProjectedExecutionFill { Ok(Some(ProjectedExecutionFill {
price: gross_amount / filled_qty as f64, price: gross_amount / filled_qty as f64,
quantity: filled_qty, quantity: filled_qty,
next_cursor: last_timestamp.unwrap_or(start_cursor) + Duration::seconds(1), next_cursor: last_timestamp.unwrap_or(start_cursor) + Duration::seconds(1),
}) }))
} }
fn has_execution_quote_at_or_before_at_time( fn has_execution_quote_at_or_before_at_time(
@@ -3442,7 +3451,7 @@ impl PlatformExprStrategy {
date: NaiveDate, date: NaiveDate,
symbol: &str, symbol: &str,
execution_state: &mut ProjectedExecutionState, execution_state: &mut ProjectedExecutionState,
) -> Option<u32> { ) -> Result<Option<u32>, BacktestError> {
self.project_target_zero_at_time(ctx, projected, date, symbol, execution_state, None) self.project_target_zero_at_time(ctx, projected, date, symbol, execution_state, None)
} }
@@ -3454,27 +3463,27 @@ impl PlatformExprStrategy {
symbol: &str, symbol: &str,
execution_state: &mut ProjectedExecutionState, execution_state: &mut ProjectedExecutionState,
execution_time: Option<NaiveTime>, execution_time: Option<NaiveTime>,
) -> Option<u32> { ) -> Result<Option<u32>, BacktestError> {
let position = projected.position(symbol)?; let Some(position) = projected.position(symbol) else { return Ok(None); };
let current_qty = position.quantity; let current_qty = position.quantity;
let sellable_qty = position.sellable_qty(date); let sellable_qty = position.sellable_qty(date);
if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) { if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) {
return None; return Ok(None);
} }
let quantity = current_qty.min(sellable_qty); let quantity = current_qty.min(sellable_qty);
if quantity == 0 { if quantity == 0 {
return None; return Ok(None);
} }
if !Self::defer_projection_execution_risk(ctx, date) if !Self::defer_projection_execution_risk(ctx, date)
&& !self.can_sell_position_at_time(ctx, date, symbol, execution_time) && !self.can_sell_position_at_time(ctx, date, symbol, execution_time)
{ {
return None; return Ok(None);
} }
let market = ctx.data.market(date, symbol)?; let Some(market) = ctx.data.market(date, symbol) else { return Ok(None); };
let round_lot = self.projected_round_lot(ctx, symbol); let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol);
let fill = self let Some(fill) = self
.projected_select_execution_fill_at_time( .projected_select_execution_fill_at_time(
ctx, ctx,
date, date,
@@ -3489,7 +3498,7 @@ impl PlatformExprStrategy {
None, None,
execution_state, execution_state,
execution_time, execution_time,
) )?
.or_else(|| { .or_else(|| {
if self.uses_intraday_execution_quotes() if self.uses_intraday_execution_quotes()
&& !Self::defer_projection_execution_risk(ctx, date) && !Self::defer_projection_execution_risk(ctx, date)
@@ -3530,13 +3539,13 @@ impl PlatformExprStrategy {
} else { } else {
None None
} }
})?; }) else { return Ok(None); };
let gross_amount = fill.price * fill.quantity as f64; let gross_amount = fill.price * fill.quantity as f64;
let net_cash = self.sell_net_cash(date, gross_amount); let net_cash = self.sell_net_cash(date, gross_amount);
projected projected
.position_mut(symbol) .position_mut(symbol)
.sell(fill.quantity, fill.price) .sell(fill.quantity, fill.price)
.ok()?; .map_err(BacktestError::Execution)?;
projected projected
.apply_cash_delta(net_cash) .apply_cash_delta(net_cash)
.expect("projected sell cash must fit fixed-point ledger"); .expect("projected sell cash must fit fixed-point ledger");
@@ -3548,7 +3557,7 @@ impl PlatformExprStrategy {
.execution_cursors .execution_cursors
.insert(symbol.to_string(), fill.next_cursor); .insert(symbol.to_string(), fill.next_cursor);
projected.prune_flat_positions(); projected.prune_flat_positions();
Some(fill.quantity) Ok(Some(fill.quantity))
} }
fn project_target_value( fn project_target_value(
@@ -3559,34 +3568,35 @@ impl PlatformExprStrategy {
symbol: &str, symbol: &str,
target_value: f64, target_value: f64,
execution_state: &mut ProjectedExecutionState, execution_state: &mut ProjectedExecutionState,
) -> Option<u32> { ) -> Result<Option<u32>, BacktestError> {
let current_qty = projected.position(symbol)?.quantity; let Some(position) = projected.position(symbol) else { return Ok(None); };
let current_qty = position.quantity;
if current_qty == 0 { if current_qty == 0 {
return None; return Ok(None);
} }
if target_value <= f64::EPSILON { if target_value <= f64::EPSILON {
return self.project_target_zero(ctx, projected, date, symbol, execution_state); return self.project_target_zero(ctx, projected, date, symbol, execution_state);
} }
let market = ctx.data.market(date, symbol)?; let Some(market) = ctx.data.market(date, symbol) else { return Ok(None); };
let current_value = let current_value =
self.projected_target_value_current_position_value(ctx, projected, date, symbol); self.projected_target_value_current_position_value(ctx, projected, date, symbol);
if !current_value.is_finite() || current_value <= 0.0 { if !current_value.is_finite() || current_value <= 0.0 {
return None; return Ok(None);
} }
let cash_delta = target_value.max(0.0) - current_value; let cash_delta = target_value.max(0.0) - current_value;
if cash_delta.abs() <= f64::EPSILON { if cash_delta.abs() <= f64::EPSILON {
return None; return Ok(None);
} }
if cash_delta > 0.0 { if cash_delta > 0.0 {
let result = let result =
self.project_order_value(ctx, projected, date, symbol, cash_delta, execution_state); self.project_order_value(ctx, projected, date, symbol, cash_delta, execution_state)?;
return (result.filled_quantity > 0).then_some(result.filled_quantity); return Ok((result.filled_quantity > 0).then_some(result.filled_quantity));
} }
if !Self::defer_projection_execution_risk(ctx, date) if !Self::defer_projection_execution_risk(ctx, date)
&& !self.can_sell_position(ctx, date, symbol) && !self.can_sell_position(ctx, date, symbol)
{ {
return None; return Ok(None);
} }
let sizing_price = self let sizing_price = self
.scheduled_quote(ctx, date, symbol) .scheduled_quote(ctx, date, symbol)
@@ -3599,17 +3609,17 @@ impl PlatformExprStrategy {
}) })
.unwrap_or_else(|| self.projected_execution_price(market, OrderSide::Sell)); .unwrap_or_else(|| self.projected_execution_price(market, OrderSide::Sell));
if !sizing_price.is_finite() || sizing_price <= 0.0 { if !sizing_price.is_finite() || sizing_price <= 0.0 {
return None; return Ok(None);
} }
let round_lot = self.projected_round_lot(ctx, symbol); let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol);
let sellable_qty = projected.position(symbol)?.sellable_qty(date); let sellable_qty = position.sellable_qty(date);
if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) { if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) {
return None; return Ok(None);
} }
if sellable_qty == 0 { if sellable_qty == 0 {
return None; return Ok(None);
} }
let requested_qty = self let requested_qty = self
.round_lot_quantity( .round_lot_quantity(
@@ -3620,9 +3630,9 @@ impl PlatformExprStrategy {
.min(current_qty) .min(current_qty)
.min(sellable_qty); .min(sellable_qty);
if requested_qty == 0 { if requested_qty == 0 {
return None; return Ok(None);
} }
let fill = self.projected_select_execution_fill( let Some(fill) = self.projected_select_execution_fill(
ctx, ctx,
date, date,
symbol, symbol,
@@ -3635,13 +3645,13 @@ impl PlatformExprStrategy {
None, None,
None, None,
execution_state, execution_state,
)?; )? else { return Ok(None); };
let gross_amount = fill.price * fill.quantity as f64; let gross_amount = fill.price * fill.quantity as f64;
let net_cash = self.sell_net_cash(date, gross_amount); let net_cash = self.sell_net_cash(date, gross_amount);
projected projected
.position_mut(symbol) .position_mut(symbol)
.sell(fill.quantity, fill.price) .sell(fill.quantity, fill.price)
.ok()?; .map_err(BacktestError::Execution)?;
projected projected
.apply_cash_delta(net_cash) .apply_cash_delta(net_cash)
.expect("projected sell cash must fit fixed-point ledger"); .expect("projected sell cash must fit fixed-point ledger");
@@ -3653,7 +3663,7 @@ impl PlatformExprStrategy {
.execution_cursors .execution_cursors
.insert(symbol.to_string(), fill.next_cursor); .insert(symbol.to_string(), fill.next_cursor);
projected.prune_flat_positions(); projected.prune_flat_positions();
Some(fill.quantity) Ok(Some(fill.quantity))
} }
fn projected_position_is_flat(projected: &PortfolioState, symbol: &str) -> bool { fn projected_position_is_flat(projected: &PortfolioState, symbol: &str) -> bool {
@@ -3941,7 +3951,7 @@ impl PlatformExprStrategy {
symbol, symbol,
buy_cash, buy_cash,
projected_execution_state, projected_execution_state,
); )?;
if order_result.was_submitted() { if order_result.was_submitted() {
order_intents.push(OrderIntent::Value { order_intents.push(OrderIntent::Value {
symbol: symbol.clone(), symbol: symbol.clone(),
@@ -4042,32 +4052,30 @@ impl PlatformExprStrategy {
symbol: &str, symbol: &str,
order_value: f64, order_value: f64,
execution_state: &mut ProjectedExecutionState, execution_state: &mut ProjectedExecutionState,
) -> ProjectedOrderValueResult { ) -> Result<ProjectedOrderValueResult, BacktestError> {
if order_value <= 0.0 { if order_value <= 0.0 {
return ProjectedOrderValueResult::not_submitted(); return Ok(ProjectedOrderValueResult::not_submitted());
} }
let round_lot = self.projected_round_lot(ctx, symbol); let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol);
let market = match ctx.data.market(date, symbol) { let market = match ctx.data.market(date, symbol) {
Some(market) => market, Some(market) => market,
None => return ProjectedOrderValueResult::not_submitted(), None => return Ok(ProjectedOrderValueResult::not_submitted()),
}; };
let stock = match self.stock_state(ctx, date, symbol) { let stock = match self.stock_state(ctx, date, symbol) {
Ok(stock) => stock, Ok(stock) => stock,
Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. })) => { Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. })) => {
return ProjectedOrderValueResult::not_submitted(); return Ok(ProjectedOrderValueResult::not_submitted());
} }
Err(_) => return ProjectedOrderValueResult::not_submitted(), Err(error) => return Err(error),
}; };
if !Self::defer_projection_execution_risk(ctx, date) if !Self::defer_projection_execution_risk(ctx, date)
&& self && self
.buy_rejection_reason(ctx, date, symbol, &stock) .buy_rejection_reason(ctx, date, symbol, &stock)?
.ok()
.flatten()
.is_some() .is_some()
{ {
return ProjectedOrderValueResult::not_submitted(); return Ok(ProjectedOrderValueResult::not_submitted());
} }
let raw_sizing_price = if self.uses_intraday_execution_quotes() { let raw_sizing_price = if self.uses_intraday_execution_quotes() {
self.scheduled_last_price(ctx, date, symbol) self.scheduled_last_price(ctx, date, symbol)
@@ -4076,9 +4084,9 @@ impl PlatformExprStrategy {
self.projected_execution_price(market, OrderSide::Buy) self.projected_execution_price(market, OrderSide::Buy)
}; };
let sizing_price = let sizing_price =
self.projected_apply_slippage(market, OrderSide::Buy, raw_sizing_price, None); self.projected_apply_slippage(ctx, market, OrderSide::Buy, raw_sizing_price, None)?;
if !sizing_price.is_finite() || sizing_price <= 0.0 { if !sizing_price.is_finite() || sizing_price <= 0.0 {
return ProjectedOrderValueResult::not_submitted(); return Ok(ProjectedOrderValueResult::not_submitted());
} }
let snapshot_requested_qty = self.value_buy_quantity( let snapshot_requested_qty = self.value_buy_quantity(
projected.cash().min(order_value), projected.cash().min(order_value),
@@ -4108,7 +4116,7 @@ impl PlatformExprStrategy {
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
} }
if quantity == 0 { if quantity == 0 {
return ProjectedOrderValueResult::not_submitted(); return Ok(ProjectedOrderValueResult::not_submitted());
} }
let submitted_quantity = quantity; let submitted_quantity = quantity;
let defer_projection_execution_risk = Self::defer_projection_execution_risk(ctx, date); let defer_projection_execution_risk = Self::defer_projection_execution_risk(ctx, date);
@@ -4126,7 +4134,7 @@ impl PlatformExprStrategy {
Some(cash_limit), Some(cash_limit),
gross_limit, gross_limit,
execution_state, execution_state,
) )?
.or_else(|| { .or_else(|| {
if !defer_projection_execution_risk if !defer_projection_execution_risk
&& ctx.data.has_execution_quotes_on_date(date) && ctx.data.has_execution_quotes_on_date(date)
@@ -4168,12 +4176,12 @@ impl PlatformExprStrategy {
} }
}); });
let Some(fill) = fill else { let Some(fill) = fill else {
return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity); return Ok(ProjectedOrderValueResult::submitted_without_fill(submitted_quantity));
}; };
let gross_amount = fill.price * fill.quantity as f64; let gross_amount = fill.price * fill.quantity as f64;
let cash_out = self.buy_cash_out(gross_amount); let cash_out = self.buy_cash_out(gross_amount);
if !Self::fixed_cash_fits(cash_out, cash_limit) { if !Self::fixed_cash_fits(cash_out, cash_limit) {
return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity); return Ok(ProjectedOrderValueResult::submitted_without_fill(submitted_quantity));
} }
projected projected
.apply_cash_delta(-cash_out) .apply_cash_delta(-cash_out)
@@ -4188,7 +4196,7 @@ impl PlatformExprStrategy {
execution_state execution_state
.execution_cursors .execution_cursors
.insert(symbol.to_string(), fill.next_cursor); .insert(symbol.to_string(), fill.next_cursor);
ProjectedOrderValueResult::submitted_with_fill(submitted_quantity, fill.quantity) Ok(ProjectedOrderValueResult::submitted_with_fill(submitted_quantity, fill.quantity))
} }
fn defer_projection_execution_risk(ctx: &StrategyContext<'_>, date: NaiveDate) -> bool { fn defer_projection_execution_risk(ctx: &StrategyContext<'_>, date: NaiveDate) -> bool {
@@ -10660,7 +10668,8 @@ impl PlatformExprStrategy {
} }
fn rank_reuses_market_cap_order(&self) -> bool { fn rank_reuses_market_cap_order(&self) -> bool {
!self.rank_expr_present self.config.candidate_order_by_date.is_empty()
&& !self.rank_expr_present
&& !self.config.rank_desc && !self.config.rank_desc
&& matches!(self.config.rank_by.trim(), "market_cap" | "market_cap_bn") && matches!(self.config.rank_by.trim(), "market_cap" | "market_cap_bn")
} }
@@ -11046,8 +11055,13 @@ impl PlatformExprStrategy {
if field_value < band_low || field_value > band_high { if field_value < band_low || field_value > band_high {
continue; continue;
} }
let rank_value = let rank_value = if let Some(order) = self.config.candidate_order_by_date.get(&date) {
self.rank_value_from_caps(ctx, day, market_cap_bn, free_float_cap_bn, &stock)?; *order.get(symbol).ok_or_else(|| BacktestError::Execution(format!(
"frozen candidate order is missing {date}/{symbol}"
)))? as f64
} else {
self.rank_value_from_caps(ctx, day, market_cap_bn, free_float_cap_bn, &stock)?
};
if !rank_value.is_finite() { if !rank_value.is_finite() {
// Model-score artifacts intentionally contain only the PIT-eligible // Model-score artifacts intentionally contain only the PIT-eligible
// ranked universe. Do not report a missing score for a symbol that // ranked universe. Do not report a missing score for a symbol that
@@ -11094,7 +11108,7 @@ impl PlatformExprStrategy {
candidates.sort_by(|lhs, rhs| { candidates.sort_by(|lhs, rhs| {
let lhs_value = lhs.1; let lhs_value = lhs.1;
let rhs_value = rhs.1; let rhs_value = rhs.1;
let ordering = if self.config.rank_desc { let ordering = if self.config.rank_desc && self.config.candidate_order_by_date.is_empty() {
rhs_value rhs_value
.partial_cmp(&lhs_value) .partial_cmp(&lhs_value)
.unwrap_or(std::cmp::Ordering::Equal) .unwrap_or(std::cmp::Ordering::Equal)
@@ -13022,7 +13036,7 @@ impl PlatformExprStrategy {
&symbol, &symbol,
&mut projected_execution_state, &mut projected_execution_state,
Some(delayed_limit_exit_time), Some(delayed_limit_exit_time),
) )?
.is_some() .is_some()
&& Self::projected_position_is_flat(&projected, &symbol) && Self::projected_position_is_flat(&projected, &symbol)
}; };
@@ -13178,7 +13192,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
&position.symbol, &position.symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell( self.refresh_available_cash_after_projected_sell(
@@ -13278,7 +13292,7 @@ impl PlatformExprStrategy {
&symbol, &symbol,
&mut projected_execution_state, &mut projected_execution_state,
Some(risk_level_forced_exit_time), Some(risk_level_forced_exit_time),
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected); self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected);
@@ -13333,7 +13347,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
symbol, symbol,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
} else { } else {
let current_value = self.projected_position_value_at_execution_price( let current_value = self.projected_position_value_at_execution_price(
ctx, ctx,
@@ -13350,7 +13364,7 @@ impl PlatformExprStrategy {
symbol, symbol,
target_value, target_value,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
} }
self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected); self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected);
if Self::projected_position_is_flat(&projected, symbol) { if Self::projected_position_is_flat(&projected, symbol) {
@@ -13504,7 +13518,7 @@ impl PlatformExprStrategy {
&position.symbol, &position.symbol,
target_value, target_value,
&mut trial_execution_state, &mut trial_execution_state,
); )?;
let after_qty = trial_projected let after_qty = trial_projected
.position(&position.symbol) .position(&position.symbol)
.map(|projected_position| projected_position.quantity) .map(|projected_position| projected_position.quantity)
@@ -13599,7 +13613,7 @@ impl PlatformExprStrategy {
&symbol, &symbol,
target_value, target_value,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
let after_qty = projected let after_qty = projected
.position(&symbol) .position(&symbol)
.map(|position| position.quantity) .map(|position| position.quantity)
@@ -13650,7 +13664,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
&position.symbol, &position.symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell( self.refresh_available_cash_after_projected_sell(
@@ -13761,7 +13775,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
&position.symbol, &position.symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell( self.refresh_available_cash_after_projected_sell(
@@ -13849,7 +13863,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
&position.symbol, &position.symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell( self.refresh_available_cash_after_projected_sell(
@@ -13931,7 +13945,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
&position.symbol, &position.symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell( self.refresh_available_cash_after_projected_sell(
@@ -14036,7 +14050,7 @@ impl PlatformExprStrategy {
&symbol, &symbol,
target_value, target_value,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
} else { } else {
self.project_order_value( self.project_order_value(
ctx, ctx,
@@ -14045,7 +14059,7 @@ impl PlatformExprStrategy {
&symbol, &symbol,
target_value, target_value,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
intraday_attempted_buys.insert(symbol.clone()); intraday_attempted_buys.insert(symbol.clone());
self.remember_position_entry_date(symbol, signal_date); self.remember_position_entry_date(symbol, signal_date);
} }
@@ -14112,7 +14126,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
symbol, symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some() .is_some()
&& Self::projected_position_is_flat(&projected, symbol) && Self::projected_position_is_flat(&projected, symbol)
{ {
@@ -14172,7 +14186,7 @@ impl PlatformExprStrategy {
symbol, symbol,
target_value, target_value,
&mut trial_execution_state, &mut trial_execution_state,
); )?;
let after_qty = trial_projected let after_qty = trial_projected
.position(symbol) .position(symbol)
.map(|position| position.quantity) .map(|position| position.quantity)
@@ -14235,7 +14249,7 @@ impl PlatformExprStrategy {
symbol, symbol,
target_value, target_value,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
order_intents.push(OrderIntent::TargetValue { order_intents.push(OrderIntent::TargetValue {
symbol: symbol.clone(), symbol: symbol.clone(),
target_value, target_value,
@@ -15004,6 +15018,31 @@ mod tests {
assert_eq!(shared.version_sha256(),shared_version); assert_eq!(shared.version_sha256(),shared_version);
} }
#[test]
fn projected_historical_slippage_does_not_swallow_missing_calibration() {
let date = d(2025, 1, 7);
let symbol = "000001.SZ";
let data = single_symbol_platform_data(&[date], symbol);
let portfolio = PortfolioState::new(100_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date, decision_date: date, decision_index: 0, data: &data, portfolio: &portfolio,
futures_account: None, open_orders: &[], dynamic_universe: None, subscriptions: &subscriptions,
process_events: &[], active_process_event: None, active_datetime: Some(date.and_hms_opt(15, 0, 0).unwrap()),
order_events: &[], fills: &[],
};
let mut config = PlatformExprStrategyConfig::generic();
config.signal_symbol = symbol.into();
config.slippage_model = SlippageModel::HistoricalVolumeVolatility(crate::DynamicSlippageConfig::default());
let strategy = PlatformExprStrategy::new(config);
let mut projected = portfolio.clone();
let result = strategy.project_order_value(&ctx, &mut projected, date, symbol, 50_000.0, &mut super::ProjectedExecutionState::default());
let error = result.expect_err("calibration failures must reach the strategy caller");
assert!(error.to_string().contains("historical_slippage_calibration_missing"), "{error}");
assert_eq!(projected.cash(), portfolio.cash());
assert!(projected.positions().is_empty());
}
#[test] #[test]
fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() { fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() {
use std::sync::Mutex; use std::sync::Mutex;
@@ -16039,7 +16078,7 @@ mod tests {
symbol, symbol,
3_410.0, 3_410.0,
&mut execution_state, &mut execution_state,
), ).unwrap(),
Some(200) Some(200)
); );
assert_eq!(projected.position(symbol).unwrap().quantity, 300); assert_eq!(projected.position(symbol).unwrap().quantity, 300);
@@ -17719,7 +17758,7 @@ mod tests {
symbol, symbol,
125_000.0, 125_000.0,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert_eq!(result.filled_quantity, 24_400); assert_eq!(result.filled_quantity, 24_400);
let position = projected.position(symbol).expect("position"); let position = projected.position(symbol).expect("position");
@@ -17853,7 +17892,7 @@ mod tests {
symbol, symbol,
125_000.0, 125_000.0,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert_eq!(result.filled_quantity, 24_400); assert_eq!(result.filled_quantity, 24_400);
} }
@@ -18085,7 +18124,7 @@ mod tests {
symbol, symbol,
target_value, target_value,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert_eq!(filled, Some(500)); assert_eq!(filled, Some(500));
assert_eq!(projected.position(symbol).unwrap().quantity, 19_100); assert_eq!(projected.position(symbol).unwrap().quantity, 19_100);
@@ -24599,7 +24638,7 @@ mod tests {
symbol, symbol,
target_value, target_value,
&mut execution_state, &mut execution_state,
) ).unwrap()
.expect("target adjustment should buy"); .expect("target adjustment should buy");
assert_eq!(filled, 200); assert_eq!(filled, 200);
@@ -33043,7 +33082,7 @@ mod tests {
let mut execution_state = super::ProjectedExecutionState::default(); let mut execution_state = super::ProjectedExecutionState::default();
let filled = let filled =
strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state); strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state).unwrap();
assert_eq!(filled, Some(100)); assert_eq!(filled, Some(100));
assert!( assert!(
@@ -33187,7 +33226,7 @@ mod tests {
symbol, symbol,
10_000.0, 10_000.0,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert_eq!(result.filled_quantity, 0); assert_eq!(result.filled_quantity, 0);
assert_eq!( assert_eq!(
@@ -33337,7 +33376,7 @@ mod tests {
symbol, symbol,
10_000.0, 10_000.0,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert!(result.filled_quantity > 0); assert!(result.filled_quantity > 0);
assert_eq!( assert_eq!(
@@ -33457,7 +33496,7 @@ mod tests {
decision_date, decision_date,
symbol, symbol,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert_eq!(filled, Some(1_000)); assert_eq!(filled, Some(1_000));
assert!(projected.position(symbol).is_none()); assert!(projected.position(symbol).is_none());
@@ -33561,7 +33600,7 @@ mod tests {
let mut execution_state = super::ProjectedExecutionState::default(); let mut execution_state = super::ProjectedExecutionState::default();
let filled = let filled =
strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state); strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state).unwrap();
assert_eq!(filled, None); assert_eq!(filled, None);
assert_eq!(projected.cash(), portfolio.cash()); assert_eq!(projected.cash(), portfolio.cash());
@@ -33667,7 +33706,7 @@ mod tests {
let mut execution_state = super::ProjectedExecutionState::default(); let mut execution_state = super::ProjectedExecutionState::default();
let filled = let filled =
strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state); strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state).unwrap();
assert_eq!(filled, None); assert_eq!(filled, None);
assert_eq!(projected.cash(), portfolio.cash()); assert_eq!(projected.cash(), portfolio.cash());
@@ -34054,7 +34093,7 @@ mod tests {
filtered_cfg filtered_cfg
.candidate_symbols_by_date .candidate_symbols_by_date
.insert(curr, BTreeSet::from(["300002.SZ".to_string()])); .insert(curr, BTreeSet::from(["300002.SZ".to_string()]));
let mut filtered_strategy = PlatformExprStrategy::new(filtered_cfg); let mut filtered_strategy = PlatformExprStrategy::new(filtered_cfg.clone());
let filtered = filtered_strategy.on_day(&ctx).expect("filtered decision"); let filtered = filtered_strategy.on_day(&ctx).expect("filtered decision");
assert!( assert!(
matches!( matches!(
@@ -34065,6 +34104,32 @@ mod tests {
filtered.order_intents, filtered.order_intents,
filtered.diagnostics filtered.diagnostics
); );
// The published screen order can deliberately disagree with both code
// and market-cap order. The old rank direction must not reverse it.
for rank_desc in [false, true] {
let mut ordered_cfg = filtered_cfg.clone();
ordered_cfg.rank_desc = rank_desc;
ordered_cfg.candidate_symbols_by_date.insert(curr, BTreeSet::from([
"300001.SZ".to_string(), "300002.SZ".to_string(),
]));
ordered_cfg.candidate_order_by_date.insert(curr, BTreeMap::from([
("300002.SZ".to_string(), 0), ("300001.SZ".to_string(), 1),
]));
let mut ordered_strategy = PlatformExprStrategy::new(ordered_cfg.clone());
let ordered = ordered_strategy.on_day(&ctx).expect("ordered decision");
assert!(matches!(ordered.order_intents.first(),
Some(crate::strategy::OrderIntent::TargetValue { symbol, .. }) if symbol == "300002.SZ"
), "{:?}", ordered);
// Rejection before Top N advances to the next published candidate.
ordered_cfg.stock_filter_expr = "symbol != \"300002.SZ\"".to_string();
let mut excluded = PlatformExprStrategy::new(ordered_cfg);
let decision = excluded.on_day(&ctx).expect("filtered ordered decision");
assert!(matches!(decision.order_intents.first(),
Some(crate::strategy::OrderIntent::TargetValue { symbol, .. }) if symbol == "300001.SZ"
), "{:?}", decision);
}
} }
#[test] #[test]
+69 -24
View File
@@ -912,6 +912,8 @@ pub struct StrategyExpressionSelectionConfig {
pub current_day_precomputed_factors: Option<bool>, pub current_day_precomputed_factors: Option<bool>,
#[serde(default, alias = "candidate_symbols_by_date")] #[serde(default, alias = "candidate_symbols_by_date")]
pub candidate_symbols_by_date: BTreeMap<String, Vec<String>>, pub candidate_symbols_by_date: BTreeMap<String, Vec<String>>,
#[serde(default, alias = "preserve_candidate_order")]
pub preserve_candidate_order: bool,
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -1528,7 +1530,6 @@ fn normalize_slippage_model_name(value: &str) -> String {
| "price_rate" | "price_rate"
| "price_ratio_slippage" | "price_ratio_slippage"
| "priceratioslippage" => "price_ratio".to_string(), | "priceratioslippage" => "price_ratio".to_string(),
"dynamic_volume_volatility" => "dynamic".to_string(),
other => other.to_string(), other => other.to_string(),
} }
} }
@@ -1573,11 +1574,13 @@ fn parse_slippage_model(
impact_coefficient: Option<f64>, impact_coefficient: Option<f64>,
volatility_coefficient: Option<f64>, volatility_coefficient: Option<f64>,
max_value: Option<f64>, max_value: Option<f64>,
) -> Option<SlippageModel> { ) -> Result<SlippageModel, String> {
let value = valid_non_negative(value); for (name, parameter) in [("slippageValue", value), ("slippageImpactCoefficient", impact_coefficient),
let impact_coefficient = valid_non_negative(impact_coefficient); ("slippageVolatilityCoefficient", volatility_coefficient), ("slippageMaxValue", max_value)] {
let volatility_coefficient = valid_non_negative(volatility_coefficient); if parameter.is_some_and(|number| !number.is_finite() || number < 0.0) {
let max_value = valid_non_negative(max_value); return Err(format!("{name} must be finite and non-negative"));
}
}
let model = model let model = model
.map(normalize_slippage_model_name) .map(normalize_slippage_model_name)
.filter(|item| !item.is_empty()) .filter(|item| !item.is_empty())
@@ -1590,16 +1593,23 @@ fn parse_slippage_model(
}); });
match model.as_str() { match model.as_str() {
"none" => Some(SlippageModel::None), "none" => Ok(SlippageModel::None),
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))), "price_ratio" => Ok(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))), "tick_size" => Ok(SlippageModel::TickSize(value.unwrap_or(0.0))),
"limit_price" => Some(SlippageModel::LimitPrice), "limit_price" => Ok(SlippageModel::LimitPrice),
"dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new( "historical_volume_volatility" => {
impact_coefficient.unwrap_or(0.5), let max_ratio = max_value.or(value).unwrap_or(0.01);
volatility_coefficient.unwrap_or(0.3), if max_ratio >= 1.0 {
max_value.or(value).unwrap_or(0.01), return Err("historical slippage maximum must be less than 1".into());
))), }
_ => None, Ok(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
impact_coefficient.unwrap_or(0.5), volatility_coefficient.unwrap_or(0.3), max_ratio,
)))
},
"dynamic" | "dynamic_volume_volatility" => Err(
"retired_slippage_model: dynamic used unfinished daily data; explicitly select historical_volume_volatility or another supported model".into()
),
_ => Err(format!("unsupported slippageModel: {model}")),
} }
} }
@@ -1630,15 +1640,13 @@ fn apply_execution_behavior_overrides(
|| slippage_volatility_coefficient.is_some() || slippage_volatility_coefficient.is_some()
|| slippage_max_value.is_some() || slippage_max_value.is_some()
{ {
if let Some(parsed) = parse_slippage_model( cfg.slippage_model = parse_slippage_model(
slippage_model, slippage_model,
slippage_value, slippage_value,
slippage_impact_coefficient, slippage_impact_coefficient,
slippage_volatility_coefficient, slippage_volatility_coefficient,
slippage_max_value, slippage_max_value,
) { )?;
cfg.slippage_model = parsed;
}
} }
if strict_value_budget == Some(false) { if strict_value_budget == Some(false) {
return Err("strictValueBudget=false is not supported".to_string()); return Err("strictValueBudget=false is not supported".to_string());
@@ -2127,12 +2135,16 @@ pub fn platform_expr_config_from_spec(
if let Some(enabled) = selection.current_day_precomputed_factors { if let Some(enabled) = selection.current_day_precomputed_factors {
cfg.current_day_precomputed_factors = enabled; cfg.current_day_precomputed_factors = enabled;
} }
if selection.preserve_candidate_order && selection.candidate_symbols_by_date.is_empty() {
return Err("preserveCandidateOrder requires a dated candidate book".to_string());
}
for (raw_date, raw_symbols) in &selection.candidate_symbols_by_date { for (raw_date, raw_symbols) in &selection.candidate_symbols_by_date {
let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| { let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| {
format!("candidateSymbolsByDate contains invalid date: {raw_date}") format!("candidateSymbolsByDate contains invalid date: {raw_date}")
})?; })?;
let mut symbols = BTreeSet::new(); let mut symbols = BTreeSet::new();
for raw_symbol in raw_symbols { let mut order = BTreeMap::new();
for (index, raw_symbol) in raw_symbols.iter().enumerate() {
let symbol = normalize_symbol(raw_symbol, None); let symbol = normalize_symbol(raw_symbol, None);
let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| { let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
code.len() == 6 code.len() == 6
@@ -2149,8 +2161,12 @@ pub fn platform_expr_config_from_spec(
"candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}" "candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}"
)); ));
} }
order.insert(symbol, index);
} }
cfg.candidate_symbols_by_date.insert(trade_date, symbols); cfg.candidate_symbols_by_date.insert(trade_date, symbols);
if selection.preserve_candidate_order {
cfg.candidate_order_by_date.insert(trade_date, order);
}
} }
} }
if let Some(allocation) = runtime_expr.allocation.as_ref() if let Some(allocation) = runtime_expr.allocation.as_ref()
@@ -3329,6 +3345,25 @@ mod tests {
); );
} }
#[test]
fn frozen_candidate_order_is_explicit_and_preserves_source_positions() {
let mut spec = serde_json::json!({"runtimeExpressions": {"selection": {
"candidateSymbolsByDate": {
"2025-01-02": ["600000.SH", "000001.SZ"], "2025-01-03": []
}
}}});
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let legacy = platform_expr_config_from_value("", "", &spec).unwrap();
assert!(legacy.candidate_order_by_date.is_empty());
spec["runtimeExpressions"]["selection"]["preserveCandidateOrder"] = serde_json::json!(true);
let ordered = platform_expr_config_from_value("", "", &spec).unwrap();
assert_eq!(ordered.candidate_order_by_date[&date]["600000.SH"], 0);
assert_eq!(ordered.candidate_order_by_date[&date]["000001.SZ"], 1);
assert!(ordered.candidate_order_by_date[&NaiveDate::from_ymd_opt(2025, 1, 3).unwrap()].is_empty());
spec["runtimeExpressions"]["selection"]["candidateSymbolsByDate"] = serde_json::json!({});
assert!(platform_expr_config_from_value("", "", &spec).unwrap_err().to_string().contains("dated candidate book"));
}
#[test] #[test]
fn rejects_invalid_or_duplicate_static_universe_symbols() { fn rejects_invalid_or_duplicate_static_universe_symbols() {
let invalid = serde_json::json!({ let invalid = serde_json::json!({
@@ -4337,10 +4372,10 @@ mod tests {
} }
#[test] #[test]
fn parses_dynamic_slippage_into_platform_config() { fn parses_explicit_historical_slippage_into_platform_config() {
let spec = serde_json::json!({ let spec = serde_json::json!({
"execution": { "execution": {
"slippageModel": "dynamic", "slippageModel": "historical_volume_volatility",
"slippageImpactCoefficient": 0.6, "slippageImpactCoefficient": 0.6,
"slippageVolatilityCoefficient": 0.2, "slippageVolatilityCoefficient": 0.2,
"slippageMaxValue": 0.015 "slippageMaxValue": 0.015
@@ -4351,10 +4386,20 @@ mod tests {
assert_eq!( assert_eq!(
cfg.slippage_model, cfg.slippage_model,
SlippageModel::Dynamic(DynamicSlippageConfig::new(0.6, 0.2, 0.015)) SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(0.6, 0.2, 0.015))
); );
} }
#[test]
fn retired_or_unknown_slippage_models_do_not_fall_back_to_fixed_or_none() {
for model in ["dynamic", "dynamic_volume_volatility", "dynamic-volume-volatility", "unknown"] {
let spec = serde_json::json!({"execution": {"slippageModel": model, "slippageValue": 0.002}});
assert!(platform_expr_config_from_value("", "", &spec).is_err(), "{model}");
}
let spec = serde_json::json!({"execution": {"slippageModel": "historical_volume_volatility", "slippageImpactCoefficient": -1}});
assert!(platform_expr_config_from_value("", "", &spec).is_err());
}
#[test] #[test]
fn engine_stock_ma_filter_generates_price_and_volume_expr() { fn engine_stock_ma_filter_generates_price_and_volume_expr() {
let spec = serde_json::json!({ let spec = serde_json::json!({
+19 -16
View File
@@ -1740,8 +1740,9 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
} }
#[test] #[test]
fn broker_applies_dynamic_slippage_on_snapshot_fills() { fn broker_applies_explicit_historical_slippage_on_snapshot_fills() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let previous_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let data = DataSet::from_components( let data = DataSet::from_components(
vec![Instrument { vec![Instrument {
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
@@ -1752,20 +1753,20 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
delisted_at: None, delisted_at: None,
status: "active".to_string(), status: "active".to_string(),
}], }],
vec![DailyMarketSnapshot { [previous_date, date].into_iter().map(|day| DailyMarketSnapshot {
date, date: day,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()), timestamp: Some(format!("{day} 15:00:00")),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
high: 10.1, high: if day == previous_date { 10.1 } else { 10.9 },
low: 9.9, low: if day == previous_date { 9.9 } else { 9.1 },
close: 10.0, close: if day == previous_date { 10.0 } else { 10.8 },
last_price: 10.0, last_price: 10.0,
bid1: 9.99, bid1: 9.99,
ask1: 10.01, ask1: 10.01,
prev_close: 10.0, prev_close: 10.0,
volume: 100_000, volume: if day == previous_date { 100_000 } else { 2_000_000 },
minute_volume: 100_000, minute_volume: 100_000,
bid1_volume: 80_000, bid1_volume: 80_000,
ask1_volume: 80_000, ask1_volume: 80_000,
@@ -1774,7 +1775,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
upper_limit: 11.0, upper_limit: 11.0,
lower_limit: 9.0, lower_limit: 9.0,
price_tick: 0.01, price_tick: 0.01,
}], }).collect(),
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
@@ -1786,8 +1787,8 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { [previous_date, date].into_iter().map(|day| CandidateEligibility {
date, date: day,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
@@ -1798,15 +1799,15 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
is_kcb: false, is_kcb: false,
is_one_yuan: false, is_one_yuan: false,
risk_level_code: None, risk_level_code: None,
}], }).collect(),
vec![BenchmarkSnapshot { [previous_date, date].into_iter().map(|day| BenchmarkSnapshot {
date, date: day,
benchmark: "000300.SH".to_string(), benchmark: "000300.SH".to_string(),
open: 100.0, open: 100.0,
close: 100.0, close: 100.0,
prev_close: 99.0, prev_close: 99.0,
volume: 1_000_000, volume: 1_000_000,
}], }).collect(),
) )
.expect("dataset"); .expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -1815,7 +1816,9 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) )
.with_slippage_model(SlippageModel::Dynamic(DynamicSlippageConfig::new( .with_volume_limit(false)
.with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
0.5, 0.3, 0.1, 0.5, 0.3, 0.1,
))); )));
+54
View File
@@ -0,0 +1,54 @@
# 逐成交腿价格风控验收
## 修复范围
Engine `7e0877b5860d8724da1c4507a1d1ba393b3497f5`Trading `1f7bc074024191cfaa5975546f22c2c2c733602a`,均以 tag `v2026.9.11.2` 发布177。
- 回测在每条实际报价进入撮合前检查原始参考价,滑点和限价处理后再次检查最终价。买入一元股、买入涨停、卖出跌停以及无效价格均按本腿价格处理,不能只依赖最初下单的日线标记或价格。
- Paper和Live的订单前检查与Paper撮合共用`MarketSnapshot::execution_reference_price`:普通买入用卖一、卖出用买一;未提供该侧价格时保留既有最新价合同,显式0或负数不当缺失处理、不回退。
- 选股仍独立使用其日线最新价与显式规则,不被买卖盘差异改写。盘后固定价仍使用原正式收盘价合同。
- Paper已接受/部分成交订单在新报价到达时重新检查。后续被风控拒绝不删除或回滚此前真实模拟成交,不重复扣资金或手续费。
- 实盘这里只验证发单前路径;券商实际成交事实必须原样保存,不能声称本地检查能保证委托进入券商后市场不再变化。本轮未提交证券订单。
## 测试
- 原始报价0.9、正向滑点20%后为1.08,仍不得利用滑点绕过一元股规则。
- 先出现0.9、后出现1.2的报价,只允许在后一个实际时点成交;不回写到前一时点。
- 限价滑点将最终执行价变为0.9时仍拒绝;显式关闭一元股买入规则后放行;卖出不继承买入一元股规则。
- 最后价10而卖一11触及涨停:买入拒绝。最后价11而卖一10.5:执行检查不按旧最后价误拒;显式选股涨停规则仍可按最后价拒绝。
- 最后价10而买一9触及跌停:卖出拒绝。最后价9而买一9.5:执行检查不按旧最后价误拒。
- 原挂单/部分成交后,最后价1.1但卖一0.9:余单拒绝,既有成交数和现金保持不变。
177测试:Engine 667通过/8忽略,Trading工作区548通过/10忽略,Runner370通过/3忽略,API99通过/1忽略。新场景使用隔离合成账户/报价,未以此冒充原始市场样本。
## 真实分钟回放
- 同一冻结请求、信号及bundle2025-01-03至2025-01-06,分钟13:07,初始10,000,000,滑点0.002,佣金万三/最低5,分钟25%量约束不改。
- 原基准 `btr_1789074235759_2081201_1`
- 新运行 `btr_1789093974375_2601124_0`
- 均21成交、11个最终持仓,最终资产9,968,551.588547;订单、成交、账户、权益、持仓和风险审计六项canonical完全一致。
- 总SHA `a1aa004f544b34eae0ade41e849a0fd067e39600d1c4ad1a127f5a3d6a79be11`
- 服务端3.490秒,客户端提交/读取/轮询16.927秒。未采集客户端各子段,不能把差值归因到某个具体服务,也不与原报告“提交后轮询耗时”混比。缓存条件的短样本不能外推冷态或多年性能。
## 发布与状态
通过官方Backtest和Trading installer构建和发布,没有调用Source/因子重启入口。Backtest service源码仍`75202cc3b876daf99d0d2dffb988ca456c34aabf`并重新链接上述engine。运行二进制SHA与清单一致。
本轮发布前10:27已观测到3Paper/0Live,重复读取确认;这不同于上一轮的3Paper/1Live,不是本次发布删除。本轮没有新建、恢复或删除实例。发布后仍3Paper/0Live,完整配置/状态摘要与本轮发布前相同。
Source主PID2267019和因子主PID2178403、NRestarts不变。发布后样本Paper9行、Live11行无WARN/ERRORRuntime0行不能视为实际执行成功;行情`/readyz`仍503THS -4302配额问题未恢复。
## 未完成
next-open全天量容量和动态滑点使用全天high/low/volume的问题没有被本次修复覆盖,仍按P0时点问题处理。新的执行观察规格位于`/Users/boris/WorkSpace/docs/fidc/execution-observation-prd-20260911.md`,只是后续实现规格,不是已部署能力。禁止静默改用昨量、自动关闭风控、修改旧结果或把后续一分钟量回填到开盘。
自然Paper/Live还需要合格模型、正式审批和真实可用行情;不开放2026封存,不替研究模型审批。当前实盘列表为空,不自行补建。
## 证据
`/srv/fidc/canonical/run/research/execution-leg-risk-20260911/`
- `engine-focused.log``engine-full.log``trading-full.log``backtest-full.log`
- `minute-replay/request.json``submission.json``result.json``comparison.json`
- `deploy-before.json``deploy-after.json``running-binary-verification.json``post-deploy-log-audit.json`
- 官方部署日志、研究审计脚本与执行观察设计稿。不改旧证据目录和WFT V18制品。
@@ -0,0 +1,10 @@
# 股票池候选顺序合同
新请求可显式设置 `runtimeExpressions.selection.preserveCandidateOrder=true`,同一 `candidateSymbolsByDate` 同时冻结成员和顺序。原有未设置该标志的策略保留成员过滤后自行排名的语义,不改写历史回测。
- 顺序在解析时保留,重复证券仍报错;空日期保持空,不继承旧候选。
- 不再走市值快排或套用旧 rank 方向。选股风控和股票条件仍在 Top N 前执行,被排除后从后续已冻结候选补位。
- 该标志必须绑定非空的日期映射,不允许空映射放开全市场。
- 股票池完成日线筛选的新前端请求采用 next_bar_open,日线信号日与真实执行日分离。
本轮共享内核全量回归 668 项通过(8 项显式忽略),新增顺序/旧排名方向/选股排除补位验证。该记录不是实盘成交验收,也不代表手选与自动候选混合来源完整实现。