修复策略投影成交量约束

This commit is contained in:
boris
2026-07-05 17:01:04 +08:00
parent 7189998699
commit c557656040
2 changed files with 281 additions and 21 deletions
+236 -16
View File
@@ -1519,6 +1519,89 @@ impl PlatformExprStrategy {
} }
} }
#[allow(clippy::too_many_arguments)]
fn projected_market_fillable_quantity(
&self,
market: &DailyMarketSnapshot,
quote: Option<&crate::data::IntradayExecutionQuote>,
symbol: &str,
side: OrderSide,
requested_qty: u32,
round_lot: u32,
minimum_order_quantity: u32,
order_step_size: u32,
allow_odd_lot_sell: bool,
current_fill_quantity: u32,
execution_state: &ProjectedExecutionState,
) -> Option<u32> {
if requested_qty == 0 {
return Some(0);
}
let constraints = self.config.risk_config.trading_constraints;
let mut max_fill = requested_qty;
if self.config.quote_quantity_limit || constraints.liquidity_limit_enabled {
let top_level_liquidity = match (quote, side) {
(Some(quote), OrderSide::Buy) => quote
.ask1_volume
.saturating_mul(round_lot.max(1) as u64)
.min(u32::MAX as u64) as u32,
(Some(quote), OrderSide::Sell) => quote
.bid1_volume
.saturating_mul(round_lot.max(1) as u64)
.min(u32::MAX as u64) as u32,
(None, OrderSide::Buy) => market.liquidity_for_buy().min(u32::MAX as u64) as u32,
(None, OrderSide::Sell) => market.liquidity_for_sell().min(u32::MAX as u64) as u32,
};
if top_level_liquidity == 0 {
return None;
}
let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
top_level_liquidity
} else {
self.round_lot_quantity(
top_level_liquidity,
minimum_order_quantity,
order_step_size,
)
};
if liquidity_limited == 0 {
return None;
}
max_fill = max_fill.min(liquidity_limited);
}
if constraints.volume_limit_enabled {
if market.minute_volume == 0 {
return None;
}
let consumed_turnover = execution_state
.intraday_turnover
.get(symbol)
.copied()
.unwrap_or(0)
.saturating_add(current_fill_quantity);
let raw_limit = ((market.minute_volume as f64) * constraints.volume_percent).floor()
as i64
- consumed_turnover as i64;
if raw_limit <= 0 {
return None;
}
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
raw_limit as u32
} else {
self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size)
};
if volume_limited == 0 {
return None;
}
max_fill = max_fill.min(volume_limited);
}
Some(max_fill)
}
fn projected_select_execution_fill( fn projected_select_execution_fill(
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
@@ -1595,24 +1678,28 @@ impl PlatformExprStrategy {
let Some(raw_quote_price) = self.projected_quote_raw_price(quote, side) else { let Some(raw_quote_price) = self.projected_quote_raw_price(quote, side) else {
continue; continue;
}; };
let available_qty = if self.config.quote_quantity_limit {
match side {
OrderSide::Buy => quote.ask1_volume,
OrderSide::Sell => quote.bid1_volume,
}
.saturating_mul(round_lot.max(1) as u64)
.min(u32::MAX as u64) as u32
} else {
requested_qty.saturating_sub(filled_qty)
};
if available_qty == 0 {
continue;
}
let remaining_qty = requested_qty.saturating_sub(filled_qty); let remaining_qty = requested_qty.saturating_sub(filled_qty);
if remaining_qty == 0 { if remaining_qty == 0 {
break; break;
} }
let available_qty = self
.projected_market_fillable_quantity(
market,
Some(quote),
symbol,
side,
remaining_qty,
round_lot,
minimum_order_quantity,
order_step_size,
allow_odd_lot_sell,
filled_qty,
execution_state,
)
.unwrap_or(0);
if available_qty == 0 {
break;
}
let mut take_qty = remaining_qty.min(available_qty); let mut take_qty = remaining_qty.min(available_qty);
if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) {
take_qty = take_qty =
@@ -1780,9 +1867,25 @@ impl PlatformExprStrategy {
execution_time, execution_time,
) && ctx.data.execution_quotes_on(date, symbol).is_empty() ) && ctx.data.execution_quotes_on(date, symbol).is_empty()
{ {
let fallback_quantity = self.projected_market_fillable_quantity(
market,
None,
symbol,
OrderSide::Sell,
quantity,
round_lot,
minimum_order_quantity,
order_step_size,
sellable_qty >= current_qty,
0,
execution_state,
)?;
if fallback_quantity == 0 {
return None;
}
Some(ProjectedExecutionFill { Some(ProjectedExecutionFill {
price: self.projected_execution_price(market, OrderSide::Sell), price: self.projected_execution_price(market, OrderSide::Sell),
quantity, quantity: fallback_quantity,
next_cursor: date.and_time( next_cursor: date.and_time(
execution_time.unwrap_or_else(|| self.intraday_execution_start_time()), execution_time.unwrap_or_else(|| self.intraday_execution_start_time()),
) + Duration::seconds(1), ) + Duration::seconds(1),
@@ -2144,9 +2247,25 @@ impl PlatformExprStrategy {
None, None,
) && ctx.data.execution_quotes_on(date, symbol).is_empty() ) && ctx.data.execution_quotes_on(date, symbol).is_empty()
{ {
let fallback_quantity = self.projected_market_fillable_quantity(
ctx.data.market(date, symbol)?,
None,
symbol,
OrderSide::Buy,
quantity,
round_lot,
minimum_order_quantity,
order_step_size,
false,
0,
execution_state,
)?;
if fallback_quantity == 0 {
return None;
}
Some(ProjectedExecutionFill { Some(ProjectedExecutionFill {
price: sizing_price, price: sizing_price,
quantity, quantity: fallback_quantity,
next_cursor: date.and_time(self.intraday_execution_start_time()) next_cursor: date.and_time(self.intraday_execution_start_time())
+ Duration::seconds(1), + Duration::seconds(1),
}) })
@@ -9880,6 +9999,8 @@ mod tests {
cfg.slippage_model = SlippageModel::PriceRatio(0.002); cfg.slippage_model = SlippageModel::PriceRatio(0.002);
cfg.matching_type = MatchingType::MinuteLast; cfg.matching_type = MatchingType::MinuteLast;
cfg.quote_quantity_limit = false; cfg.quote_quantity_limit = false;
cfg.risk_config.trading_constraints.volume_limit_enabled = false;
cfg.risk_config.trading_constraints.liquidity_limit_enabled = false;
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 40, 0).expect("time")); cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 40, 0).expect("time"));
let strategy = PlatformExprStrategy::new(cfg); let strategy = PlatformExprStrategy::new(cfg);
let quote = ctx let quote = ctx
@@ -9910,6 +10031,92 @@ mod tests {
assert!((position.average_cost - 5.12022).abs() < 1e-9); assert!((position.average_cost - 5.12022).abs() < 1e-9);
} }
#[test]
fn platform_projected_market_fill_caps_volume_limit() {
let date = d(2023, 5, 12);
let symbol = "603176.SH";
let market = DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
timestamp: None,
day_open: 5.10,
open: 5.10,
high: 5.20,
low: 5.00,
close: 5.20,
last_price: 5.11,
bid1: 5.11,
ask1: 5.12,
prev_close: 5.00,
volume: 1_000_000,
minute_volume: 10_000,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 5.50,
lower_limit: 4.50,
price_tick: 0.01,
};
let quote = IntradayExecutionQuote {
date,
symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
last_price: 5.11,
bid1: 5.11,
ask1: 5.12,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 51_100.0,
trading_phase: Some("continuous".to_string()),
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.quote_quantity_limit = false;
cfg.risk_config.trading_constraints.volume_limit_enabled = true;
cfg.risk_config.trading_constraints.liquidity_limit_enabled = false;
cfg.risk_config.trading_constraints.volume_percent = 0.25;
let strategy = PlatformExprStrategy::new(cfg);
let mut execution_state = super::ProjectedExecutionState::default();
assert_eq!(
strategy.projected_market_fillable_quantity(
&market,
Some(&quote),
symbol,
OrderSide::Buy,
50_000,
100,
100,
100,
false,
0,
&execution_state,
),
Some(2_500)
);
execution_state
.intraday_turnover
.insert(symbol.to_string(), 2_400);
assert_eq!(
strategy.projected_market_fillable_quantity(
&market,
Some(&quote),
symbol,
OrderSide::Buy,
50_000,
100,
100,
100,
false,
0,
&execution_state,
),
Some(100)
);
}
#[test] #[test]
fn platform_aiquant_target_value_uses_scheduled_mark_for_delta() { fn platform_aiquant_target_value_uses_scheduled_mark_for_delta() {
let prev_date = d(2023, 5, 11); let prev_date = d(2023, 5, 11);
@@ -10482,6 +10689,8 @@ mod tests {
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
cfg.stop_loss_expr.clear(); cfg.stop_loss_expr.clear();
cfg.take_profit_expr = "1.07".to_string(); cfg.take_profit_expr = "1.07".to_string();
cfg.risk_config.trading_constraints.volume_limit_enabled = false;
cfg.risk_config.trading_constraints.liquidity_limit_enabled = false;
let mut strategy = PlatformExprStrategy::new(cfg); let mut strategy = PlatformExprStrategy::new(cfg);
let decision = strategy.on_day(&ctx).expect("platform decision"); let decision = strategy.on_day(&ctx).expect("platform decision");
@@ -10691,6 +10900,8 @@ mod tests {
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
cfg.stop_loss_expr.clear(); cfg.stop_loss_expr.clear();
cfg.take_profit_expr = "1.07".to_string(); cfg.take_profit_expr = "1.07".to_string();
cfg.risk_config.trading_constraints.volume_limit_enabled = false;
cfg.risk_config.trading_constraints.liquidity_limit_enabled = false;
let mut strategy = PlatformExprStrategy::new(cfg); let mut strategy = PlatformExprStrategy::new(cfg);
let first_decision = strategy.on_day(&first_ctx).expect("first decision"); let first_decision = strategy.on_day(&first_ctx).expect("first decision");
@@ -10983,6 +11194,8 @@ mod tests {
cfg.delayed_limit_open_exit_enabled = true; cfg.delayed_limit_open_exit_enabled = true;
cfg.delayed_limit_open_exit_time = Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap()); cfg.delayed_limit_open_exit_time = Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap());
cfg.skip_month_day_ranges = vec![(Some(2024), 1, 15, 31)]; cfg.skip_month_day_ranges = vec![(Some(2024), 1, 15, 31)];
cfg.risk_config.trading_constraints.volume_limit_enabled = false;
cfg.risk_config.trading_constraints.liquidity_limit_enabled = false;
let mut strategy = PlatformExprStrategy::new(cfg); let mut strategy = PlatformExprStrategy::new(cfg);
strategy strategy
.pending_highlimit_holdings .pending_highlimit_holdings
@@ -14864,6 +15077,8 @@ mod tests {
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 40, 0).unwrap()); cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 40, 0).unwrap());
cfg.commission_rate = Some(0.0003); cfg.commission_rate = Some(0.0003);
cfg.minimum_commission = Some(5.0); cfg.minimum_commission = Some(5.0);
cfg.risk_config.trading_constraints.volume_limit_enabled = false;
cfg.risk_config.trading_constraints.liquidity_limit_enabled = false;
let strategy = PlatformExprStrategy::new(cfg); let strategy = PlatformExprStrategy::new(cfg);
let mut projected = portfolio.clone(); let mut projected = portfolio.clone();
let mut execution_state = super::ProjectedExecutionState::default(); let mut execution_state = super::ProjectedExecutionState::default();
@@ -17217,6 +17432,9 @@ mod tests {
.to_string(); .to_string();
cfg.exposure_expr = "signal_close > 0 ? 1.0 : 0.5".to_string(); cfg.exposure_expr = "signal_close > 0 ? 1.0 : 0.5".to_string();
cfg.buy_scale_expr = "touched_upper_limit ? 1.0 : 0.5".to_string(); cfg.buy_scale_expr = "touched_upper_limit ? 1.0 : 0.5".to_string();
cfg.quote_quantity_limit = false;
cfg.risk_config.trading_constraints.volume_limit_enabled = false;
cfg.risk_config.trading_constraints.liquidity_limit_enabled = false;
cfg.rank_expr = concat!( cfg.rank_expr = concat!(
"(close / rolling_mean(\"close\", 2)) * 0.5", "(close / rolling_mean(\"close\", 2)) * 0.5",
" + pct_change(\"close\", 1) * boost" " + pct_change(\"close\", 1) * boost"
@@ -20291,6 +20509,8 @@ mod tests {
cfg.aiquant_transaction_cost = true; cfg.aiquant_transaction_cost = true;
cfg.strict_value_budget = true; cfg.strict_value_budget = true;
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()); cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap());
cfg.risk_config.trading_constraints.volume_limit_enabled = false;
cfg.risk_config.trading_constraints.liquidity_limit_enabled = false;
let mut strategy = PlatformExprStrategy::new(cfg); let mut strategy = PlatformExprStrategy::new(cfg);
strategy.rebalance_day_counter = 20; strategy.rebalance_day_counter = 20;
+45 -5
View File
@@ -2034,7 +2034,6 @@ impl OmniMicroCapStrategy {
Some(fill.quantity) Some(fill.quantity)
} }
#[allow(dead_code)]
fn projected_market_fillable_quantity( fn projected_market_fillable_quantity(
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
@@ -2046,18 +2045,19 @@ impl OmniMicroCapStrategy {
minimum_order_quantity: u32, minimum_order_quantity: u32,
order_step_size: u32, order_step_size: u32,
allow_odd_lot_sell: bool, allow_odd_lot_sell: bool,
current_fill_quantity: u32,
execution_state: &ProjectedExecutionState, execution_state: &ProjectedExecutionState,
) -> Option<u32> { ) -> Option<u32> {
if requested_qty == 0 { if requested_qty == 0 {
return Some(0); return Some(0);
} }
let snapshot = ctx.data.market(date, symbol)?; let snapshot = ctx.data.market(date, symbol)?;
if snapshot.minute_volume == 0 { let constraints = self.config.risk_config.trading_constraints;
if constraints.volume_limit_enabled && snapshot.minute_volume == 0 {
return None; return None;
} }
let mut max_fill = requested_qty; let mut max_fill = requested_qty;
let constraints = self.config.risk_config.trading_constraints;
if constraints.liquidity_limit_enabled { if constraints.liquidity_limit_enabled {
let top_level_liquidity = match side { let top_level_liquidity = match side {
OrderSide::Buy => snapshot.liquidity_for_buy(), OrderSide::Buy => snapshot.liquidity_for_buy(),
@@ -2079,7 +2079,12 @@ impl OmniMicroCapStrategy {
max_fill = max_fill.min(liquidity_limited); max_fill = max_fill.min(liquidity_limited);
} }
let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0); let consumed_turnover = execution_state
.intraday_turnover
.get(symbol)
.copied()
.unwrap_or(0)
.saturating_add(current_fill_quantity);
if constraints.volume_limit_enabled { if constraints.volume_limit_enabled {
let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).floor() let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).floor()
as i64 as i64
@@ -2129,6 +2134,23 @@ impl OmniMicroCapStrategy {
return None; return None;
} }
let requested_qty = self.projected_market_fillable_quantity(
ctx,
date,
symbol,
side,
requested_qty,
round_lot,
minimum_order_quantity,
order_step_size,
allow_odd_lot_sell,
0,
execution_state,
)?;
if requested_qty == 0 {
return None;
}
if let Some(market) = ctx.data.market(date, symbol) { if let Some(market) = ctx.data.market(date, symbol) {
let execution_price = self.projected_execution_price(market, side); let execution_price = self.projected_execution_price(market, side);
if execution_price.is_finite() && execution_price > 0.0 { if execution_price.is_finite() && execution_price > 0.0 {
@@ -2222,7 +2244,25 @@ impl OmniMicroCapStrategy {
if remaining_qty == 0 { if remaining_qty == 0 {
break; break;
} }
let mut take_qty = remaining_qty.min(available_qty); let market_fillable_qty = self
.projected_market_fillable_quantity(
ctx,
date,
symbol,
side,
remaining_qty,
round_lot,
minimum_order_quantity,
order_step_size,
allow_odd_lot_sell,
filled_qty,
execution_state,
)
.unwrap_or(0);
if market_fillable_qty == 0 {
break;
}
let mut take_qty = remaining_qty.min(available_qty).min(market_fillable_qty);
if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) {
take_qty = take_qty =
self.round_lot_quantity(take_qty, minimum_order_quantity, order_step_size); self.round_lot_quantity(take_qty, minimum_order_quantity, order_step_size);