Merge remote-tracking branch 'refs/remotes/highmem177/main'
This commit is contained in:
+231
-78
@@ -292,42 +292,76 @@ pub struct DynamicSlippageConfig {
|
||||
impl DynamicSlippageConfig {
|
||||
pub fn new(impact_coefficient: f64, volatility_coefficient: f64, max_ratio: f64) -> Self {
|
||||
Self {
|
||||
impact_coefficient: impact_coefficient.max(0.0),
|
||||
volatility_coefficient: volatility_coefficient.max(0.0),
|
||||
max_ratio: max_ratio.max(0.0),
|
||||
impact_coefficient,
|
||||
volatility_coefficient,
|
||||
max_ratio,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ratio(
|
||||
&self,
|
||||
snapshot: &crate::data::DailyMarketSnapshot,
|
||||
raw_price: f64,
|
||||
calibration: &HistoricalSlippageCalibration,
|
||||
order_value: Option<f64>,
|
||||
) -> f64 {
|
||||
let daily_amount = (snapshot.volume as f64 * raw_price).max(0.0);
|
||||
) -> Result<f64, BacktestError> {
|
||||
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 {
|
||||
Some(value) if value.is_finite() && value > 0.0 && daily_amount > 0.0 => {
|
||||
value / daily_amount
|
||||
Some(value) if value.is_finite() && value > 0.0 => {
|
||||
value / calibration.turnover_proxy
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
let volatility_base = if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 {
|
||||
snapshot.prev_close
|
||||
} else {
|
||||
raw_price
|
||||
};
|
||||
let volatility = if snapshot.high.is_finite()
|
||||
&& snapshot.low.is_finite()
|
||||
&& volatility_base.is_finite()
|
||||
&& volatility_base > 0.0
|
||||
let impact = if self.impact_coefficient == 0.0 { 0.0 } else { impact_ratio * self.impact_coefficient };
|
||||
let ratio = impact
|
||||
+ calibration.range_ratio * self.volatility_coefficient;
|
||||
Ok(ratio.clamp(0.0, self.max_ratio))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
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)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let ratio =
|
||||
impact_ratio * self.impact_coefficient + volatility * self.volatility_coefficient;
|
||||
ratio.clamp(0.0, self.max_ratio)
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"historical_slippage_calibration_invalid symbol={} source_date={} execution_date={} volume={} high={} low={} close={} prev_close={}",
|
||||
row.symbol, row.date, execution_date, row.volume, row.high, row.low, row.close, row.prev_close,
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
source_date: row.date,
|
||||
turnover_proxy,
|
||||
range_ratio,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +377,7 @@ pub enum SlippageModel {
|
||||
PriceRatio(f64),
|
||||
TickSize(f64),
|
||||
LimitPrice,
|
||||
Dynamic(DynamicSlippageConfig),
|
||||
HistoricalVolumeVolatility(DynamicSlippageConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -1109,12 +1143,28 @@ where
|
||||
|
||||
fn snapshot_execution_price(
|
||||
&self,
|
||||
data: &DataSet,
|
||||
snapshot: &crate::data::DailyMarketSnapshot,
|
||||
side: OrderSide,
|
||||
quantity: Option<u32>,
|
||||
) -> f64 {
|
||||
) -> Result<f64, BacktestError> {
|
||||
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(
|
||||
@@ -1184,17 +1234,18 @@ where
|
||||
side: OrderSide,
|
||||
raw_price: f64,
|
||||
quantity: Option<u32>,
|
||||
) -> f64 {
|
||||
calibration: Option<&HistoricalSlippageCalibration>,
|
||||
) -> Result<f64, BacktestError> {
|
||||
if !raw_price.is_finite() || raw_price <= 0.0 {
|
||||
return raw_price;
|
||||
return Ok(raw_price);
|
||||
}
|
||||
|
||||
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) {
|
||||
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));
|
||||
@@ -1216,8 +1267,12 @@ where
|
||||
}
|
||||
}
|
||||
SlippageModel::LimitPrice => raw_price,
|
||||
SlippageModel::Dynamic(config) => {
|
||||
let ratio = config.ratio(snapshot, raw_price, order_value);
|
||||
SlippageModel::HistoricalVolumeVolatility(config) => {
|
||||
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 {
|
||||
OrderSide::Buy => 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;
|
||||
}
|
||||
|
||||
self.clamp_execution_price(snapshot, side, adjusted)
|
||||
Ok(self.clamp_execution_price(snapshot, side, adjusted))
|
||||
}
|
||||
|
||||
fn clamp_execution_price(
|
||||
@@ -1266,8 +1321,9 @@ where
|
||||
side: OrderSide,
|
||||
raw_price: f64,
|
||||
quantity: Option<u32>,
|
||||
) -> f64 {
|
||||
self.apply_slippage(snapshot, side, raw_price, quantity)
|
||||
calibration: Option<&HistoricalSlippageCalibration>,
|
||||
) -> Result<f64, BacktestError> {
|
||||
self.apply_slippage(snapshot, side, raw_price, quantity, calibration)
|
||||
}
|
||||
|
||||
fn matching_type_for_algo_request(
|
||||
@@ -1577,7 +1633,7 @@ where
|
||||
.unwrap_or(0);
|
||||
if 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 {
|
||||
report.diagnostics.push(format!(
|
||||
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
|
||||
@@ -3385,7 +3441,7 @@ where
|
||||
price,
|
||||
minimum_order_quantity,
|
||||
order_step_size,
|
||||
))
|
||||
)?)
|
||||
} else {
|
||||
self.round_buy_quantity(
|
||||
(target_value / price).floor() as u32,
|
||||
@@ -3441,15 +3497,17 @@ where
|
||||
let buy_execution_price = data
|
||||
.market(date, &symbol)
|
||||
.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)
|
||||
.unwrap_or(price);
|
||||
let sell_execution_price = data
|
||||
.market(date, &symbol)
|
||||
.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)
|
||||
.unwrap_or(price);
|
||||
if desired_qty < current_qty
|
||||
@@ -3779,7 +3837,7 @@ where
|
||||
continue;
|
||||
}
|
||||
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 {
|
||||
report.diagnostics.push(format!(
|
||||
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
|
||||
@@ -4283,9 +4341,9 @@ where
|
||||
portfolio: &PortfolioState,
|
||||
data: &DataSet,
|
||||
symbol: &str,
|
||||
) -> bool {
|
||||
) -> Result<bool, BacktestError> {
|
||||
let Some(snapshot) = data.market(date, symbol) else {
|
||||
return true;
|
||||
return Ok(true);
|
||||
};
|
||||
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
|
||||
let order_step_size = self.order_step_size(data, symbol);
|
||||
@@ -4295,14 +4353,14 @@ where
|
||||
order_step_size,
|
||||
);
|
||||
if minimum_buy_quantity == 0 {
|
||||
return false;
|
||||
return Ok(false);
|
||||
}
|
||||
let minimum_execution_price =
|
||||
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity));
|
||||
Self::fixed_cash_fits(
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(minimum_buy_quantity))?;
|
||||
Ok(Self::fixed_cash_fits(
|
||||
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity),
|
||||
portfolio.cash(),
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fn process_sell(
|
||||
@@ -4710,7 +4768,7 @@ where
|
||||
None,
|
||||
algo_request,
|
||||
limit_price,
|
||||
);
|
||||
)?;
|
||||
let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) =
|
||||
fill
|
||||
{
|
||||
@@ -4724,7 +4782,7 @@ where
|
||||
)
|
||||
} else {
|
||||
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) =
|
||||
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price)
|
||||
{
|
||||
@@ -6438,7 +6496,7 @@ where
|
||||
value_gross_limit,
|
||||
algo_request,
|
||||
limit_price,
|
||||
);
|
||||
)?;
|
||||
let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) =
|
||||
fill
|
||||
{
|
||||
@@ -6452,7 +6510,7 @@ where
|
||||
)
|
||||
} else {
|
||||
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) =
|
||||
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price)
|
||||
{
|
||||
@@ -6494,10 +6552,11 @@ where
|
||||
let mut blocked_by_final_price = false;
|
||||
if filled_qty > 0 {
|
||||
execution_price = self.snapshot_execution_price(
|
||||
data,
|
||||
snapshot,
|
||||
OrderSide::Buy,
|
||||
Some(filled_qty),
|
||||
);
|
||||
)?;
|
||||
match self.execution_price_with_limit_slippage_or_rejection(
|
||||
snapshot,
|
||||
OrderSide::Buy,
|
||||
@@ -7085,7 +7144,7 @@ where
|
||||
fallback_price: f64,
|
||||
minimum_order_quantity: u32,
|
||||
order_step_size: u32,
|
||||
) -> u32 {
|
||||
) -> Result<u32, BacktestError> {
|
||||
let snapshot = data.market(date, symbol);
|
||||
let mut quantity = self.value_buy_quantity(
|
||||
date,
|
||||
@@ -7097,8 +7156,9 @@ where
|
||||
for _ in 0..8 {
|
||||
let execution_price = 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)
|
||||
.unwrap_or(fallback_price);
|
||||
let resolved = self.value_buy_quantity(
|
||||
@@ -7109,27 +7169,28 @@ where
|
||||
order_step_size,
|
||||
);
|
||||
if resolved == quantity {
|
||||
return quantity;
|
||||
return Ok(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))
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(quantity))
|
||||
})
|
||||
.transpose()?
|
||||
.filter(|price| price.is_finite() && *price > 0.0)
|
||||
.unwrap_or(fallback_price);
|
||||
if Self::fixed_cash_fits(
|
||||
self.estimated_buy_cash_out(date, execution_price, quantity),
|
||||
value_budget,
|
||||
) {
|
||||
return quantity;
|
||||
return Ok(quantity);
|
||||
}
|
||||
quantity =
|
||||
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
|
||||
}
|
||||
0
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn decrement_order_quantity(
|
||||
@@ -7396,14 +7457,14 @@ where
|
||||
gross_limit: Option<f64>,
|
||||
algo_request: Option<&AlgoExecutionRequest>,
|
||||
limit_price: Option<f64>,
|
||||
) -> Option<ExecutionFill> {
|
||||
) -> Result<Option<ExecutionFill>, BacktestError> {
|
||||
let matching_type = self.matching_type_for_algo_request(algo_request);
|
||||
let post_close_window = self.post_close_execution_window(date);
|
||||
let use_intraday_quotes = post_close_window.is_some()
|
||||
|| algo_request.is_some()
|
||||
|| self.matching_type_uses_intraday_quotes();
|
||||
if !use_intraday_quotes {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let runtime_start_time = self.runtime_intraday_start_time.get();
|
||||
@@ -7430,6 +7491,7 @@ where
|
||||
end_cursor
|
||||
};
|
||||
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(
|
||||
symbol,
|
||||
@@ -7448,8 +7510,9 @@ where
|
||||
gross_limit,
|
||||
limit_price,
|
||||
execution_ledger,
|
||||
) {
|
||||
return Some(fill);
|
||||
calibration.as_ref(),
|
||||
)? {
|
||||
return Ok(Some(fill));
|
||||
}
|
||||
|
||||
if post_close_window.is_some()
|
||||
@@ -7464,7 +7527,7 @@ where
|
||||
.or(self.intraday_execution_start_time)
|
||||
.map(|start_time| date.and_time(start_time) + Duration::seconds(1))
|
||||
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
|
||||
return Some(ExecutionFill {
|
||||
return Ok(Some(ExecutionFill {
|
||||
quantity: 0,
|
||||
next_cursor,
|
||||
legs: Vec::new(),
|
||||
@@ -7475,10 +7538,10 @@ where
|
||||
end_cursor,
|
||||
matching_type == MatchingType::MinuteLast && start_cursor.is_some(),
|
||||
)),
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn empty_intraday_quote_reason(
|
||||
@@ -7542,7 +7605,9 @@ where
|
||||
gross_limit,
|
||||
limit_price,
|
||||
&IntradayExecutionLedger::default(),
|
||||
None,
|
||||
)
|
||||
.expect("test quote selection without historical calibration")
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -7564,9 +7629,10 @@ where
|
||||
gross_limit: Option<f64>,
|
||||
limit_price: Option<f64>,
|
||||
execution_ledger: &IntradayExecutionLedger,
|
||||
) -> Option<ExecutionFill> {
|
||||
calibration: Option<&HistoricalSlippageCalibration>,
|
||||
) -> Result<Option<ExecutionFill>, BacktestError> {
|
||||
if requested_qty == 0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let quote_quantity_limited =
|
||||
@@ -7714,7 +7780,7 @@ where
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
execution_block_reason.get_or_insert(reason);
|
||||
@@ -7734,7 +7800,7 @@ where
|
||||
if let Some(cash) = cash_limit {
|
||||
while take_qty > 0 {
|
||||
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 {
|
||||
budget_block_reason = Some("invalid execution price");
|
||||
take_qty = 0;
|
||||
@@ -7786,7 +7852,7 @@ where
|
||||
}
|
||||
|
||||
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);
|
||||
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
|
||||
&& !saw_non_blocked_execution_price
|
||||
{
|
||||
return Some(ExecutionFill {
|
||||
return Ok(Some(ExecutionFill {
|
||||
quantity: 0,
|
||||
next_cursor: execution_block_timestamp
|
||||
.expect("blocked execution quote timestamp")
|
||||
@@ -7852,12 +7918,12 @@ where
|
||||
legs: Vec::new(),
|
||||
liquidity_consumption: Vec::new(),
|
||||
unfilled_reason: Some(reason),
|
||||
});
|
||||
}));
|
||||
}
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Some(ExecutionFill {
|
||||
Ok(Some(ExecutionFill {
|
||||
quantity: filled_qty,
|
||||
next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
|
||||
legs: if matching_type == MatchingType::Vwap {
|
||||
@@ -7881,7 +7947,7 @@ where
|
||||
} else {
|
||||
None
|
||||
},
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
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 {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||
CandidateEligibility {
|
||||
@@ -8442,6 +8590,11 @@ mod tests {
|
||||
let mut snapshot = dated_limit_test_snapshot(date);
|
||||
snapshot.close = 10.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)] {
|
||||
broker
|
||||
@@ -8452,7 +8605,7 @@ mod tests {
|
||||
EquityExecutionPhase::ContinuousAuction
|
||||
);
|
||||
assert_eq!(
|
||||
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)),
|
||||
broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(),
|
||||
12.5
|
||||
);
|
||||
}
|
||||
@@ -8465,7 +8618,7 @@ mod tests {
|
||||
EquityExecutionPhase::PostCloseFixedPrice
|
||||
);
|
||||
assert_eq!(
|
||||
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)),
|
||||
broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(),
|
||||
10.0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3011,13 +3011,14 @@ impl PlatformExprStrategy {
|
||||
|
||||
fn projected_apply_slippage(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
market: &DailyMarketSnapshot,
|
||||
side: OrderSide,
|
||||
raw_price: f64,
|
||||
quantity: Option<u32>,
|
||||
) -> f64 {
|
||||
) -> Result<f64, BacktestError> {
|
||||
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 mut adjusted = match self.config.slippage_model {
|
||||
@@ -3037,8 +3038,11 @@ impl PlatformExprStrategy {
|
||||
OrderSide::Sell => raw_price - tick * ticks,
|
||||
}
|
||||
}
|
||||
SlippageModel::Dynamic(config) => {
|
||||
let ratio = config.ratio(market, raw_price, order_value);
|
||||
SlippageModel::HistoricalVolumeVolatility(config) => {
|
||||
let calibration = crate::broker::HistoricalSlippageCalibration::for_execution(
|
||||
ctx.data, market.date, &market.symbol,
|
||||
)?;
|
||||
let ratio = config.ratio(&calibration, order_value)?;
|
||||
match side {
|
||||
OrderSide::Buy => raw_price * (1.0 + ratio),
|
||||
OrderSide::Sell => raw_price * (1.0 - ratio),
|
||||
@@ -3051,7 +3055,7 @@ impl PlatformExprStrategy {
|
||||
{
|
||||
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(
|
||||
@@ -3250,7 +3254,7 @@ impl PlatformExprStrategy {
|
||||
cash_limit: Option<f64>,
|
||||
gross_limit: Option<f64>,
|
||||
execution_state: &ProjectedExecutionState,
|
||||
) -> Option<ProjectedExecutionFill> {
|
||||
) -> Result<Option<ProjectedExecutionFill>, BacktestError> {
|
||||
self.projected_select_execution_fill_at_time(
|
||||
ctx,
|
||||
date,
|
||||
@@ -3284,11 +3288,11 @@ impl PlatformExprStrategy {
|
||||
gross_limit: Option<f64>,
|
||||
execution_state: &ProjectedExecutionState,
|
||||
execution_time: Option<NaiveTime>,
|
||||
) -> Option<ProjectedExecutionFill> {
|
||||
) -> Result<Option<ProjectedExecutionFill>, BacktestError> {
|
||||
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(
|
||||
ctx,
|
||||
@@ -3344,7 +3348,7 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
|
||||
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
|
||||
.projected_execution_limit_rejection_reason(market, side, quote_price)
|
||||
.is_some()
|
||||
@@ -3355,11 +3359,12 @@ impl PlatformExprStrategy {
|
||||
if let Some(cash) = cash_limit {
|
||||
while take_qty > 0 {
|
||||
quote_price = self.projected_apply_slippage(
|
||||
ctx,
|
||||
market,
|
||||
side,
|
||||
raw_quote_price,
|
||||
Some(take_qty),
|
||||
);
|
||||
)?;
|
||||
if self
|
||||
.projected_execution_limit_rejection_reason(market, side, quote_price)
|
||||
.is_some()
|
||||
@@ -3393,7 +3398,7 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
|
||||
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
|
||||
.projected_execution_limit_rejection_reason(market, side, quote_price)
|
||||
.is_some()
|
||||
@@ -3409,13 +3414,13 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
|
||||
if filled_qty == 0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
Some(ProjectedExecutionFill {
|
||||
Ok(Some(ProjectedExecutionFill {
|
||||
price: gross_amount / filled_qty as f64,
|
||||
quantity: filled_qty,
|
||||
next_cursor: last_timestamp.unwrap_or(start_cursor) + Duration::seconds(1),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
fn has_execution_quote_at_or_before_at_time(
|
||||
@@ -3446,7 +3451,7 @@ impl PlatformExprStrategy {
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
execution_state: &mut ProjectedExecutionState,
|
||||
) -> Option<u32> {
|
||||
) -> Result<Option<u32>, BacktestError> {
|
||||
self.project_target_zero_at_time(ctx, projected, date, symbol, execution_state, None)
|
||||
}
|
||||
|
||||
@@ -3458,27 +3463,27 @@ impl PlatformExprStrategy {
|
||||
symbol: &str,
|
||||
execution_state: &mut ProjectedExecutionState,
|
||||
execution_time: Option<NaiveTime>,
|
||||
) -> Option<u32> {
|
||||
let position = projected.position(symbol)?;
|
||||
) -> Result<Option<u32>, BacktestError> {
|
||||
let Some(position) = projected.position(symbol) else { return Ok(None); };
|
||||
let current_qty = position.quantity;
|
||||
let sellable_qty = position.sellable_qty(date);
|
||||
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);
|
||||
if quantity == 0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
if !Self::defer_projection_execution_risk(ctx, date)
|
||||
&& !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 minimum_order_quantity = self.projected_minimum_order_quantity(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(
|
||||
ctx,
|
||||
date,
|
||||
@@ -3493,7 +3498,7 @@ impl PlatformExprStrategy {
|
||||
None,
|
||||
execution_state,
|
||||
execution_time,
|
||||
)
|
||||
)?
|
||||
.or_else(|| {
|
||||
if self.uses_intraday_execution_quotes()
|
||||
&& !Self::defer_projection_execution_risk(ctx, date)
|
||||
@@ -3534,13 +3539,13 @@ impl PlatformExprStrategy {
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})?;
|
||||
}) else { return Ok(None); };
|
||||
let gross_amount = fill.price * fill.quantity as f64;
|
||||
let net_cash = self.sell_net_cash(date, gross_amount);
|
||||
projected
|
||||
.position_mut(symbol)
|
||||
.sell(fill.quantity, fill.price)
|
||||
.ok()?;
|
||||
.map_err(BacktestError::Execution)?;
|
||||
projected
|
||||
.apply_cash_delta(net_cash)
|
||||
.expect("projected sell cash must fit fixed-point ledger");
|
||||
@@ -3552,7 +3557,7 @@ impl PlatformExprStrategy {
|
||||
.execution_cursors
|
||||
.insert(symbol.to_string(), fill.next_cursor);
|
||||
projected.prune_flat_positions();
|
||||
Some(fill.quantity)
|
||||
Ok(Some(fill.quantity))
|
||||
}
|
||||
|
||||
fn project_target_value(
|
||||
@@ -3563,34 +3568,35 @@ impl PlatformExprStrategy {
|
||||
symbol: &str,
|
||||
target_value: f64,
|
||||
execution_state: &mut ProjectedExecutionState,
|
||||
) -> Option<u32> {
|
||||
let current_qty = projected.position(symbol)?.quantity;
|
||||
) -> Result<Option<u32>, BacktestError> {
|
||||
let Some(position) = projected.position(symbol) else { return Ok(None); };
|
||||
let current_qty = position.quantity;
|
||||
if current_qty == 0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
if target_value <= f64::EPSILON {
|
||||
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 =
|
||||
self.projected_target_value_current_position_value(ctx, projected, date, symbol);
|
||||
if !current_value.is_finite() || current_value <= 0.0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let cash_delta = target_value.max(0.0) - current_value;
|
||||
if cash_delta.abs() <= f64::EPSILON {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
if cash_delta > 0.0 {
|
||||
let result =
|
||||
self.project_order_value(ctx, projected, date, symbol, cash_delta, execution_state);
|
||||
return (result.filled_quantity > 0).then_some(result.filled_quantity);
|
||||
self.project_order_value(ctx, projected, date, symbol, cash_delta, execution_state)?;
|
||||
return Ok((result.filled_quantity > 0).then_some(result.filled_quantity));
|
||||
}
|
||||
|
||||
if !Self::defer_projection_execution_risk(ctx, date)
|
||||
&& !self.can_sell_position(ctx, date, symbol)
|
||||
{
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let sizing_price = self
|
||||
.scheduled_quote(ctx, date, symbol)
|
||||
@@ -3603,17 +3609,17 @@ impl PlatformExprStrategy {
|
||||
})
|
||||
.unwrap_or_else(|| self.projected_execution_price(market, OrderSide::Sell));
|
||||
if !sizing_price.is_finite() || sizing_price <= 0.0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let round_lot = self.projected_round_lot(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 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()) {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
if sellable_qty == 0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let requested_qty = self
|
||||
.round_lot_quantity(
|
||||
@@ -3624,9 +3630,9 @@ impl PlatformExprStrategy {
|
||||
.min(current_qty)
|
||||
.min(sellable_qty);
|
||||
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,
|
||||
date,
|
||||
symbol,
|
||||
@@ -3639,13 +3645,13 @@ impl PlatformExprStrategy {
|
||||
None,
|
||||
None,
|
||||
execution_state,
|
||||
)?;
|
||||
)? else { return Ok(None); };
|
||||
let gross_amount = fill.price * fill.quantity as f64;
|
||||
let net_cash = self.sell_net_cash(date, gross_amount);
|
||||
projected
|
||||
.position_mut(symbol)
|
||||
.sell(fill.quantity, fill.price)
|
||||
.ok()?;
|
||||
.map_err(BacktestError::Execution)?;
|
||||
projected
|
||||
.apply_cash_delta(net_cash)
|
||||
.expect("projected sell cash must fit fixed-point ledger");
|
||||
@@ -3657,7 +3663,7 @@ impl PlatformExprStrategy {
|
||||
.execution_cursors
|
||||
.insert(symbol.to_string(), fill.next_cursor);
|
||||
projected.prune_flat_positions();
|
||||
Some(fill.quantity)
|
||||
Ok(Some(fill.quantity))
|
||||
}
|
||||
|
||||
fn projected_position_is_flat(projected: &PortfolioState, symbol: &str) -> bool {
|
||||
@@ -3945,7 +3951,7 @@ impl PlatformExprStrategy {
|
||||
symbol,
|
||||
buy_cash,
|
||||
projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
if order_result.was_submitted() {
|
||||
order_intents.push(OrderIntent::Value {
|
||||
symbol: symbol.clone(),
|
||||
@@ -4046,32 +4052,30 @@ impl PlatformExprStrategy {
|
||||
symbol: &str,
|
||||
order_value: f64,
|
||||
execution_state: &mut ProjectedExecutionState,
|
||||
) -> ProjectedOrderValueResult {
|
||||
) -> Result<ProjectedOrderValueResult, BacktestError> {
|
||||
if order_value <= 0.0 {
|
||||
return ProjectedOrderValueResult::not_submitted();
|
||||
return Ok(ProjectedOrderValueResult::not_submitted());
|
||||
}
|
||||
let round_lot = self.projected_round_lot(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 market = match ctx.data.market(date, symbol) {
|
||||
Some(market) => market,
|
||||
None => return ProjectedOrderValueResult::not_submitted(),
|
||||
None => return Ok(ProjectedOrderValueResult::not_submitted()),
|
||||
};
|
||||
let stock = match self.stock_state(ctx, date, symbol) {
|
||||
Ok(stock) => stock,
|
||||
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)
|
||||
&& self
|
||||
.buy_rejection_reason(ctx, date, symbol, &stock)
|
||||
.ok()
|
||||
.flatten()
|
||||
.buy_rejection_reason(ctx, date, symbol, &stock)?
|
||||
.is_some()
|
||||
{
|
||||
return ProjectedOrderValueResult::not_submitted();
|
||||
return Ok(ProjectedOrderValueResult::not_submitted());
|
||||
}
|
||||
let raw_sizing_price = if self.uses_intraday_execution_quotes() {
|
||||
self.scheduled_last_price(ctx, date, symbol)
|
||||
@@ -4080,9 +4084,9 @@ impl PlatformExprStrategy {
|
||||
self.projected_execution_price(market, OrderSide::Buy)
|
||||
};
|
||||
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 {
|
||||
return ProjectedOrderValueResult::not_submitted();
|
||||
return Ok(ProjectedOrderValueResult::not_submitted());
|
||||
}
|
||||
let snapshot_requested_qty = self.value_buy_quantity(
|
||||
projected.cash().min(order_value),
|
||||
@@ -4112,7 +4116,7 @@ impl PlatformExprStrategy {
|
||||
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
|
||||
}
|
||||
if quantity == 0 {
|
||||
return ProjectedOrderValueResult::not_submitted();
|
||||
return Ok(ProjectedOrderValueResult::not_submitted());
|
||||
}
|
||||
let submitted_quantity = quantity;
|
||||
let defer_projection_execution_risk = Self::defer_projection_execution_risk(ctx, date);
|
||||
@@ -4130,7 +4134,7 @@ impl PlatformExprStrategy {
|
||||
Some(cash_limit),
|
||||
gross_limit,
|
||||
execution_state,
|
||||
)
|
||||
)?
|
||||
.or_else(|| {
|
||||
if !defer_projection_execution_risk
|
||||
&& ctx.data.has_execution_quotes_on_date(date)
|
||||
@@ -4172,12 +4176,12 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
});
|
||||
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 cash_out = self.buy_cash_out(gross_amount);
|
||||
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
|
||||
.apply_cash_delta(-cash_out)
|
||||
@@ -4192,7 +4196,7 @@ impl PlatformExprStrategy {
|
||||
execution_state
|
||||
.execution_cursors
|
||||
.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 {
|
||||
@@ -13032,7 +13036,7 @@ impl PlatformExprStrategy {
|
||||
&symbol,
|
||||
&mut projected_execution_state,
|
||||
Some(delayed_limit_exit_time),
|
||||
)
|
||||
)?
|
||||
.is_some()
|
||||
&& Self::projected_position_is_flat(&projected, &symbol)
|
||||
};
|
||||
@@ -13188,7 +13192,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some();
|
||||
if close_submitted {
|
||||
self.refresh_available_cash_after_projected_sell(
|
||||
@@ -13288,7 +13292,7 @@ impl PlatformExprStrategy {
|
||||
&symbol,
|
||||
&mut projected_execution_state,
|
||||
Some(risk_level_forced_exit_time),
|
||||
)
|
||||
)?
|
||||
.is_some();
|
||||
if close_submitted {
|
||||
self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected);
|
||||
@@ -13343,7 +13347,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
symbol,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
} else {
|
||||
let current_value = self.projected_position_value_at_execution_price(
|
||||
ctx,
|
||||
@@ -13360,7 +13364,7 @@ impl PlatformExprStrategy {
|
||||
symbol,
|
||||
target_value,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
}
|
||||
self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected);
|
||||
if Self::projected_position_is_flat(&projected, symbol) {
|
||||
@@ -13514,7 +13518,7 @@ impl PlatformExprStrategy {
|
||||
&position.symbol,
|
||||
target_value,
|
||||
&mut trial_execution_state,
|
||||
);
|
||||
)?;
|
||||
let after_qty = trial_projected
|
||||
.position(&position.symbol)
|
||||
.map(|projected_position| projected_position.quantity)
|
||||
@@ -13609,7 +13613,7 @@ impl PlatformExprStrategy {
|
||||
&symbol,
|
||||
target_value,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
let after_qty = projected
|
||||
.position(&symbol)
|
||||
.map(|position| position.quantity)
|
||||
@@ -13660,7 +13664,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some();
|
||||
if close_submitted {
|
||||
self.refresh_available_cash_after_projected_sell(
|
||||
@@ -13771,7 +13775,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some();
|
||||
if close_submitted {
|
||||
self.refresh_available_cash_after_projected_sell(
|
||||
@@ -13859,7 +13863,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some();
|
||||
if close_submitted {
|
||||
self.refresh_available_cash_after_projected_sell(
|
||||
@@ -13941,7 +13945,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some();
|
||||
if close_submitted {
|
||||
self.refresh_available_cash_after_projected_sell(
|
||||
@@ -14046,7 +14050,7 @@ impl PlatformExprStrategy {
|
||||
&symbol,
|
||||
target_value,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
} else {
|
||||
self.project_order_value(
|
||||
ctx,
|
||||
@@ -14055,7 +14059,7 @@ impl PlatformExprStrategy {
|
||||
&symbol,
|
||||
target_value,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
intraday_attempted_buys.insert(symbol.clone());
|
||||
self.remember_position_entry_date(symbol, signal_date);
|
||||
}
|
||||
@@ -14122,7 +14126,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some()
|
||||
&& Self::projected_position_is_flat(&projected, symbol)
|
||||
{
|
||||
@@ -14182,7 +14186,7 @@ impl PlatformExprStrategy {
|
||||
symbol,
|
||||
target_value,
|
||||
&mut trial_execution_state,
|
||||
);
|
||||
)?;
|
||||
let after_qty = trial_projected
|
||||
.position(symbol)
|
||||
.map(|position| position.quantity)
|
||||
@@ -14245,7 +14249,7 @@ impl PlatformExprStrategy {
|
||||
symbol,
|
||||
target_value,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
order_intents.push(OrderIntent::TargetValue {
|
||||
symbol: symbol.clone(),
|
||||
target_value,
|
||||
@@ -15014,6 +15018,31 @@ mod tests {
|
||||
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]
|
||||
fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() {
|
||||
use std::sync::Mutex;
|
||||
@@ -16049,7 +16078,7 @@ mod tests {
|
||||
symbol,
|
||||
3_410.0,
|
||||
&mut execution_state,
|
||||
),
|
||||
).unwrap(),
|
||||
Some(200)
|
||||
);
|
||||
assert_eq!(projected.position(symbol).unwrap().quantity, 300);
|
||||
@@ -17729,7 +17758,7 @@ mod tests {
|
||||
symbol,
|
||||
125_000.0,
|
||||
&mut execution_state,
|
||||
);
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(result.filled_quantity, 24_400);
|
||||
let position = projected.position(symbol).expect("position");
|
||||
@@ -17863,7 +17892,7 @@ mod tests {
|
||||
symbol,
|
||||
125_000.0,
|
||||
&mut execution_state,
|
||||
);
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(result.filled_quantity, 24_400);
|
||||
}
|
||||
@@ -18095,7 +18124,7 @@ mod tests {
|
||||
symbol,
|
||||
target_value,
|
||||
&mut execution_state,
|
||||
);
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(filled, Some(500));
|
||||
assert_eq!(projected.position(symbol).unwrap().quantity, 19_100);
|
||||
@@ -24609,7 +24638,7 @@ mod tests {
|
||||
symbol,
|
||||
target_value,
|
||||
&mut execution_state,
|
||||
)
|
||||
).unwrap()
|
||||
.expect("target adjustment should buy");
|
||||
|
||||
assert_eq!(filled, 200);
|
||||
@@ -33053,7 +33082,7 @@ mod tests {
|
||||
let mut execution_state = super::ProjectedExecutionState::default();
|
||||
|
||||
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!(
|
||||
@@ -33197,7 +33226,7 @@ mod tests {
|
||||
symbol,
|
||||
10_000.0,
|
||||
&mut execution_state,
|
||||
);
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(result.filled_quantity, 0);
|
||||
assert_eq!(
|
||||
@@ -33347,7 +33376,7 @@ mod tests {
|
||||
symbol,
|
||||
10_000.0,
|
||||
&mut execution_state,
|
||||
);
|
||||
).unwrap();
|
||||
|
||||
assert!(result.filled_quantity > 0);
|
||||
assert_eq!(
|
||||
@@ -33467,7 +33496,7 @@ mod tests {
|
||||
decision_date,
|
||||
symbol,
|
||||
&mut execution_state,
|
||||
);
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(filled, Some(1_000));
|
||||
assert!(projected.position(symbol).is_none());
|
||||
@@ -33571,7 +33600,7 @@ mod tests {
|
||||
let mut execution_state = super::ProjectedExecutionState::default();
|
||||
|
||||
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!(projected.cash(), portfolio.cash());
|
||||
@@ -33677,7 +33706,7 @@ mod tests {
|
||||
let mut execution_state = super::ProjectedExecutionState::default();
|
||||
|
||||
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!(projected.cash(), portfolio.cash());
|
||||
|
||||
@@ -1530,7 +1530,6 @@ fn normalize_slippage_model_name(value: &str) -> String {
|
||||
| "price_rate"
|
||||
| "price_ratio_slippage"
|
||||
| "priceratioslippage" => "price_ratio".to_string(),
|
||||
"dynamic_volume_volatility" => "dynamic".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -1575,11 +1574,13 @@ fn parse_slippage_model(
|
||||
impact_coefficient: Option<f64>,
|
||||
volatility_coefficient: Option<f64>,
|
||||
max_value: Option<f64>,
|
||||
) -> Option<SlippageModel> {
|
||||
let value = valid_non_negative(value);
|
||||
let impact_coefficient = valid_non_negative(impact_coefficient);
|
||||
let volatility_coefficient = valid_non_negative(volatility_coefficient);
|
||||
let max_value = valid_non_negative(max_value);
|
||||
) -> Result<SlippageModel, String> {
|
||||
for (name, parameter) in [("slippageValue", value), ("slippageImpactCoefficient", impact_coefficient),
|
||||
("slippageVolatilityCoefficient", volatility_coefficient), ("slippageMaxValue", max_value)] {
|
||||
if parameter.is_some_and(|number| !number.is_finite() || number < 0.0) {
|
||||
return Err(format!("{name} must be finite and non-negative"));
|
||||
}
|
||||
}
|
||||
let model = model
|
||||
.map(normalize_slippage_model_name)
|
||||
.filter(|item| !item.is_empty())
|
||||
@@ -1592,16 +1593,23 @@ fn parse_slippage_model(
|
||||
});
|
||||
|
||||
match model.as_str() {
|
||||
"none" => Some(SlippageModel::None),
|
||||
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
|
||||
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))),
|
||||
"limit_price" => Some(SlippageModel::LimitPrice),
|
||||
"dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
|
||||
impact_coefficient.unwrap_or(0.5),
|
||||
volatility_coefficient.unwrap_or(0.3),
|
||||
max_value.or(value).unwrap_or(0.01),
|
||||
))),
|
||||
_ => None,
|
||||
"none" => Ok(SlippageModel::None),
|
||||
"price_ratio" => Ok(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
|
||||
"tick_size" => Ok(SlippageModel::TickSize(value.unwrap_or(0.0))),
|
||||
"limit_price" => Ok(SlippageModel::LimitPrice),
|
||||
"historical_volume_volatility" => {
|
||||
let max_ratio = max_value.or(value).unwrap_or(0.01);
|
||||
if max_ratio >= 1.0 {
|
||||
return Err("historical slippage maximum must be less than 1".into());
|
||||
}
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1632,15 +1640,13 @@ fn apply_execution_behavior_overrides(
|
||||
|| slippage_volatility_coefficient.is_some()
|
||||
|| slippage_max_value.is_some()
|
||||
{
|
||||
if let Some(parsed) = parse_slippage_model(
|
||||
cfg.slippage_model = parse_slippage_model(
|
||||
slippage_model,
|
||||
slippage_value,
|
||||
slippage_impact_coefficient,
|
||||
slippage_volatility_coefficient,
|
||||
slippage_max_value,
|
||||
) {
|
||||
cfg.slippage_model = parsed;
|
||||
}
|
||||
)?;
|
||||
}
|
||||
if strict_value_budget == Some(false) {
|
||||
return Err("strictValueBudget=false is not supported".to_string());
|
||||
@@ -4366,10 +4372,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_dynamic_slippage_into_platform_config() {
|
||||
fn parses_explicit_historical_slippage_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
"execution": {
|
||||
"slippageModel": "dynamic",
|
||||
"slippageModel": "historical_volume_volatility",
|
||||
"slippageImpactCoefficient": 0.6,
|
||||
"slippageVolatilityCoefficient": 0.2,
|
||||
"slippageMaxValue": 0.015
|
||||
@@ -4380,10 +4386,20 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
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]
|
||||
fn engine_stock_ma_filter_generates_price_and_volume_expr() {
|
||||
let spec = serde_json::json!({
|
||||
|
||||
@@ -1740,8 +1740,9 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
|
||||
}
|
||||
|
||||
#[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 previous_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
@@ -1752,20 +1753,20 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
[previous_date, date].into_iter().map(|day| DailyMarketSnapshot {
|
||||
date: day,
|
||||
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,
|
||||
open: 10.0,
|
||||
high: 10.1,
|
||||
low: 9.9,
|
||||
close: 10.0,
|
||||
high: if day == previous_date { 10.1 } else { 10.9 },
|
||||
low: if day == previous_date { 9.9 } else { 9.1 },
|
||||
close: if day == previous_date { 10.0 } else { 10.8 },
|
||||
last_price: 10.0,
|
||||
bid1: 9.99,
|
||||
ask1: 10.01,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
volume: if day == previous_date { 100_000 } else { 2_000_000 },
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 80_000,
|
||||
ask1_volume: 80_000,
|
||||
@@ -1774,7 +1775,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
}).collect(),
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
@@ -1786,8 +1787,8 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
[previous_date, date].into_iter().map(|day| CandidateEligibility {
|
||||
date: day,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
@@ -1798,15 +1799,15 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
}).collect(),
|
||||
[previous_date, date].into_iter().map(|day| BenchmarkSnapshot {
|
||||
date: day,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
}).collect(),
|
||||
)
|
||||
.expect("dataset");
|
||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||
@@ -1815,7 +1816,9 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
||||
ChinaEquityRuleHooks::default(),
|
||||
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,
|
||||
)));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user