refactor: isolate historical slippage calibration and propagate pricing errors
This commit is contained in:
+137
-74
@@ -300,37 +300,63 @@ impl DynamicSlippageConfig {
|
||||
|
||||
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);
|
||||
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
|
||||
{
|
||||
((snapshot.high - snapshot.low).abs() / volatility_base).max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let ratio =
|
||||
impact_ratio * self.impact_coefficient + volatility * self.volatility_coefficient;
|
||||
let ratio = impact_ratio * self.impact_coefficient
|
||||
+ calibration.range_ratio * self.volatility_coefficient;
|
||||
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()
|
||||
{
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DynamicSlippageConfig {
|
||||
fn default() -> Self {
|
||||
Self::new(0.5, 0.3, 0.01)
|
||||
@@ -343,7 +369,7 @@ pub enum SlippageModel {
|
||||
PriceRatio(f64),
|
||||
TickSize(f64),
|
||||
LimitPrice,
|
||||
Dynamic(DynamicSlippageConfig),
|
||||
HistoricalVolumeVolatility(DynamicSlippageConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -1109,12 +1135,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 +1226,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 +1259,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 +1278,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 +1313,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 +1625,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 +3433,7 @@ where
|
||||
price,
|
||||
minimum_order_quantity,
|
||||
order_step_size,
|
||||
))
|
||||
)?)
|
||||
} else {
|
||||
self.round_buy_quantity(
|
||||
(target_value / price).floor() as u32,
|
||||
@@ -3441,15 +3489,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 +3829,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 +4333,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 +4345,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 +4760,7 @@ where
|
||||
None,
|
||||
algo_request,
|
||||
limit_price,
|
||||
);
|
||||
)?;
|
||||
let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) =
|
||||
fill
|
||||
{
|
||||
@@ -4724,7 +4774,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 +6488,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 +6502,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 +6544,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 +7136,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 +7148,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 +7161,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 +7449,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 +7483,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 +7502,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 +7519,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 +7530,10 @@ where
|
||||
end_cursor,
|
||||
matching_type == MatchingType::MinuteLast && start_cursor.is_some(),
|
||||
)),
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn empty_intraday_quote_reason(
|
||||
@@ -7542,7 +7597,9 @@ where
|
||||
gross_limit,
|
||||
limit_price,
|
||||
&IntradayExecutionLedger::default(),
|
||||
None,
|
||||
)
|
||||
.expect("test quote selection without historical calibration")
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -7564,9 +7621,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 +7772,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 +7792,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 +7844,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 +7902,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 +7910,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 +7939,7 @@ where
|
||||
} else {
|
||||
None
|
||||
},
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
fn quote_has_executable_liquidity(
|
||||
@@ -8442,6 +8500,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 +8515,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 +8528,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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3007,13 +3007,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 {
|
||||
@@ -3033,8 +3034,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),
|
||||
@@ -3047,7 +3051,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(
|
||||
@@ -3246,7 +3250,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,
|
||||
@@ -3280,11 +3284,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,
|
||||
@@ -3340,7 +3344,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()
|
||||
@@ -3351,11 +3355,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()
|
||||
@@ -3389,7 +3394,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()
|
||||
@@ -3405,13 +3410,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(
|
||||
@@ -3442,7 +3447,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)
|
||||
}
|
||||
|
||||
@@ -3454,27 +3459,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,
|
||||
@@ -3489,7 +3494,7 @@ impl PlatformExprStrategy {
|
||||
None,
|
||||
execution_state,
|
||||
execution_time,
|
||||
)
|
||||
)?
|
||||
.or_else(|| {
|
||||
if self.uses_intraday_execution_quotes()
|
||||
&& !Self::defer_projection_execution_risk(ctx, date)
|
||||
@@ -3530,13 +3535,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");
|
||||
@@ -3548,7 +3553,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(
|
||||
@@ -3559,34 +3564,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)
|
||||
@@ -3599,17 +3605,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(
|
||||
@@ -3620,9 +3626,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,
|
||||
@@ -3635,13 +3641,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");
|
||||
@@ -3653,7 +3659,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 {
|
||||
@@ -3941,7 +3947,7 @@ impl PlatformExprStrategy {
|
||||
symbol,
|
||||
buy_cash,
|
||||
projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
if order_result.was_submitted() {
|
||||
order_intents.push(OrderIntent::Value {
|
||||
symbol: symbol.clone(),
|
||||
@@ -4042,32 +4048,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)
|
||||
@@ -4076,9 +4080,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),
|
||||
@@ -4108,7 +4112,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);
|
||||
@@ -4126,7 +4130,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)
|
||||
@@ -4168,12 +4172,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)
|
||||
@@ -4188,7 +4192,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 {
|
||||
@@ -13022,7 +13026,7 @@ impl PlatformExprStrategy {
|
||||
&symbol,
|
||||
&mut projected_execution_state,
|
||||
Some(delayed_limit_exit_time),
|
||||
)
|
||||
)?
|
||||
.is_some()
|
||||
&& Self::projected_position_is_flat(&projected, &symbol)
|
||||
};
|
||||
@@ -13178,7 +13182,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some();
|
||||
if close_submitted {
|
||||
self.refresh_available_cash_after_projected_sell(
|
||||
@@ -13278,7 +13282,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);
|
||||
@@ -13333,7 +13337,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
symbol,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
} else {
|
||||
let current_value = self.projected_position_value_at_execution_price(
|
||||
ctx,
|
||||
@@ -13350,7 +13354,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) {
|
||||
@@ -13504,7 +13508,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)
|
||||
@@ -13599,7 +13603,7 @@ impl PlatformExprStrategy {
|
||||
&symbol,
|
||||
target_value,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
let after_qty = projected
|
||||
.position(&symbol)
|
||||
.map(|position| position.quantity)
|
||||
@@ -13650,7 +13654,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some();
|
||||
if close_submitted {
|
||||
self.refresh_available_cash_after_projected_sell(
|
||||
@@ -13761,7 +13765,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some();
|
||||
if close_submitted {
|
||||
self.refresh_available_cash_after_projected_sell(
|
||||
@@ -13849,7 +13853,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some();
|
||||
if close_submitted {
|
||||
self.refresh_available_cash_after_projected_sell(
|
||||
@@ -13931,7 +13935,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some();
|
||||
if close_submitted {
|
||||
self.refresh_available_cash_after_projected_sell(
|
||||
@@ -14036,7 +14040,7 @@ impl PlatformExprStrategy {
|
||||
&symbol,
|
||||
target_value,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
} else {
|
||||
self.project_order_value(
|
||||
ctx,
|
||||
@@ -14045,7 +14049,7 @@ impl PlatformExprStrategy {
|
||||
&symbol,
|
||||
target_value,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
intraday_attempted_buys.insert(symbol.clone());
|
||||
self.remember_position_entry_date(symbol, signal_date);
|
||||
}
|
||||
@@ -14112,7 +14116,7 @@ impl PlatformExprStrategy {
|
||||
projection_date,
|
||||
symbol,
|
||||
&mut projected_execution_state,
|
||||
)
|
||||
)?
|
||||
.is_some()
|
||||
&& Self::projected_position_is_flat(&projected, symbol)
|
||||
{
|
||||
@@ -14172,7 +14176,7 @@ impl PlatformExprStrategy {
|
||||
symbol,
|
||||
target_value,
|
||||
&mut trial_execution_state,
|
||||
);
|
||||
)?;
|
||||
let after_qty = trial_projected
|
||||
.position(symbol)
|
||||
.map(|position| position.quantity)
|
||||
@@ -14235,7 +14239,7 @@ impl PlatformExprStrategy {
|
||||
symbol,
|
||||
target_value,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
)?;
|
||||
order_intents.push(OrderIntent::TargetValue {
|
||||
symbol: symbol.clone(),
|
||||
target_value,
|
||||
|
||||
@@ -1528,7 +1528,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(),
|
||||
}
|
||||
}
|
||||
@@ -1573,7 +1572,13 @@ fn parse_slippage_model(
|
||||
impact_coefficient: Option<f64>,
|
||||
volatility_coefficient: Option<f64>,
|
||||
max_value: Option<f64>,
|
||||
) -> Option<SlippageModel> {
|
||||
) -> 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 value = valid_non_negative(value);
|
||||
let impact_coefficient = valid_non_negative(impact_coefficient);
|
||||
let volatility_coefficient = valid_non_negative(volatility_coefficient);
|
||||
@@ -1590,16 +1595,19 @@ 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(
|
||||
"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" => Ok(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
|
||||
impact_coefficient.unwrap_or(0.5),
|
||||
volatility_coefficient.unwrap_or(0.3),
|
||||
max_value.or(value).unwrap_or(0.01),
|
||||
))),
|
||||
_ => None,
|
||||
"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 +1638,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());
|
||||
@@ -4337,10 +4343,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
|
||||
@@ -4351,10 +4357,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!({
|
||||
|
||||
Reference in New Issue
Block a user