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