merge: integrate causal capacity model with current order clocks and intent planning

This commit is contained in:
boris
2026-09-12 08:52:37 +08:00
13 changed files with 332 additions and 196 deletions
+97 -100
View File
@@ -7,7 +7,7 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::cost::CostModel;
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
use crate::engine::BacktestError;
use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeObservation, VolumeObservationKind};
use crate::execution_capacity::{CapacityAuditSummary, CapacityError, ParticipationRate, SessionCapacityAudit, VolumeCapacityMode, VolumeObservation, VolumeObservationKind};
use crate::execution_schedule::TwapSchedule;
use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
@@ -432,6 +432,7 @@ pub struct BrokerSimulator<C, R> {
volume_percent: f64,
volume_rate: Result<ParticipationRate, CapacityError>,
volume_limit: bool,
volume_capacity_mode: VolumeCapacityMode,
inactive_limit: bool,
liquidity_limit: bool,
strict_value_budget: bool,
@@ -469,6 +470,7 @@ impl<C, R> BrokerSimulator<C, R> {
volume_percent: 0.25,
volume_rate: ParticipationRate::new(0.25),
volume_limit: true,
volume_capacity_mode: VolumeCapacityMode::ExecutionObservation,
inactive_limit: true,
liquidity_limit: true,
strict_value_budget: true,
@@ -510,6 +512,7 @@ impl<C, R> BrokerSimulator<C, R> {
volume_percent: 0.25,
volume_rate: ParticipationRate::new(0.25),
volume_limit: true,
volume_capacity_mode: VolumeCapacityMode::ExecutionObservation,
inactive_limit: true,
liquidity_limit: true,
strict_value_budget: true,
@@ -541,6 +544,29 @@ impl<C, R> BrokerSimulator<C, R> {
self
}
pub fn with_volume_capacity_mode(mut self, mode: VolumeCapacityMode) -> Self {
self.volume_capacity_mode = mode;
self
}
pub fn capacity_audit_summary(&self) -> CapacityAuditSummary {
CapacityAuditSummary { mode: self.volume_capacity_mode, enabled: self.volume_limit,
participation_rate: self.volume_percent, ..Default::default() }
}
pub fn audit_completed_session_capacity(&self, date: NaiveDate, data: &DataSet) -> Result<Vec<SessionCapacityAudit>, BacktestError> {
if !self.volume_limit || self.volume_capacity_mode != VolumeCapacityMode::SessionCapacityAudit {
return Ok(Vec::new());
}
let session = self.execution_session.borrow();
if session.date != Some(date) { return Ok(Vec::new()); }
let rate = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?;
session.intraday_turnover.iter().filter(|(_, quantity)| **quantity > 0).map(|(symbol, quantity)| {
let market = data.market(date, symbol).ok_or_else(|| BacktestError::MissingPrice { date, symbol: symbol.clone(), field: "session capacity audit" })?;
Ok(SessionCapacityAudit::new(date, symbol.clone(), u64::from(*quantity), market.volume, rate))
}).collect()
}
pub fn with_inactive_limit(mut self, enabled: bool) -> Self {
self.inactive_limit = enabled;
self
@@ -3587,8 +3613,6 @@ where
data,
&symbol,
current_qty,
minimum_order_quantity,
order_step_size,
)
{
diagnostics.push(format!(
@@ -3605,8 +3629,6 @@ where
data,
&symbol,
current_qty,
minimum_order_quantity,
order_step_size,
)
{
diagnostics.push(format!(
@@ -4017,8 +4039,6 @@ where
data,
symbol,
current_qty,
minimum_order_quantity,
order_step_size,
) else {
continue;
};
@@ -4289,8 +4309,6 @@ where
data: &DataSet,
symbol: &str,
current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> Option<String> {
if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) {
return Some(reason.clone());
@@ -4319,12 +4337,8 @@ where
.saturating_sub(self.reserved_open_sell_quantity(symbol, None));
match self.market_fillable_quantity(
snapshot,
OrderSide::Sell,
sellable.min(current_qty),
minimum_order_quantity,
order_step_size,
0,
sellable >= current_qty,
false,
) {
Ok(quantity) => {
let quantity = quantity.min(sellable).min(current_qty);
@@ -4345,8 +4359,6 @@ where
data: &DataSet,
symbol: &str,
current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> Option<String> {
let snapshot = data.require_market(date, symbol).ok()?;
let candidate = data.require_candidate(date, symbol).ok()?;
@@ -4367,11 +4379,7 @@ where
}
match self.market_fillable_quantity(
snapshot,
OrderSide::Buy,
u32::MAX,
minimum_order_quantity,
order_step_size,
0,
false,
) {
Ok(quantity) => {
@@ -4641,14 +4649,12 @@ where
} else {
None
};
self.volume_capacity_mode.validate(self.volume_limit, algo_request.is_some() || self.matching_type_uses_intraday_quotes())
.map_err(|error| BacktestError::Execution(error.to_string()))?;
let market_limited_qty = self.market_fillable_quantity(
snapshot,
OrderSide::Sell,
requested_qty.min(sellable),
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
requested_qty >= position.quantity && sellable >= position.quantity,
algo_request.is_some(),
);
let fillable_qty = match market_limited_qty {
Ok(quantity) => {
@@ -6471,14 +6477,12 @@ where
}
let mut partial_fill_reason = None;
self.volume_capacity_mode.validate(self.volume_limit, algo_request.is_some() || self.matching_type_uses_intraday_quotes())
.map_err(|error| BacktestError::Execution(error.to_string()))?;
let market_limited_qty = self.market_fillable_quantity(
snapshot,
OrderSide::Buy,
requested_qty,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
false,
algo_request.is_some(),
);
let constrained_qty = match market_limited_qty {
Ok(quantity) => {
@@ -7389,68 +7393,19 @@ where
fn market_fillable_quantity(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
requested_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
consumed_turnover: u32,
allow_odd_lot_sell: bool,
algorithmic_order: bool,
) -> Result<u32, String> {
if requested_qty == 0 {
return Ok(0);
}
let uses_intraday_quantity = self.matching_type_uses_intraday_quotes();
let available_market_volume = if uses_intraday_quantity {
snapshot.minute_volume
} else {
snapshot.volume
};
let no_volume_reason = if uses_intraday_quantity {
"minute no volume"
} else {
"daily no volume"
};
let volume_limit_reason = if uses_intraday_quantity {
"minute volume limit"
} else {
"daily volume limit"
};
let mut max_fill = requested_qty;
if self.inactive_limit
&& (snapshot.paused || (!uses_intraday_quantity && available_market_volume == 0))
{
return Err(if snapshot.paused {
"paused".to_string()
} else {
no_volume_reason.to_string()
});
}
if uses_intraday_quantity {
return Ok(max_fill);
}
if self.volume_limit {
let raw_limit = self.volume_rate.map_err(|error| error.to_string())?
.remaining(available_market_volume, u64::from(consumed_turnover), requested_qty);
if raw_limit == 0 {
return Err(volume_limit_reason.to_string());
}
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
raw_limit
} else {
self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size)
};
if volume_limited == 0 {
return Err(volume_limit_reason.to_string());
}
max_fill = max_fill.min(volume_limited);
}
Ok(max_fill)
if self.inactive_limit && snapshot.paused { return Err("paused".into()); }
self.volume_capacity_mode.validate(self.volume_limit, algorithmic_order || self.matching_type_uses_intraday_quotes())
.map_err(|error| error.to_string())?;
// Per-observation limits are applied to each actual quote below. The
// session-audit model must never size this order from the day's total.
Ok(requested_qty)
}
fn price_satisfies_limit(
@@ -7864,7 +7819,7 @@ where
} else {
remaining_qty
};
if self.volume_limit {
if self.volume_limit && self.volume_capacity_mode.limits_execution_quantity() {
let consumed = execution_ledger
.volume_consumed(symbol, quote.timestamp)
.saturating_add(
@@ -7901,7 +7856,7 @@ where
} else {
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 =
self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size);
}
@@ -8012,7 +7967,7 @@ where
.saturating_add(take_qty)
.min(state.displayed_quantity);
}
if self.volume_limit {
if self.volume_limit && self.volume_capacity_mode.limits_execution_quantity() {
let consumed = pending_volume_consumption
.entry(quote.timestamp)
.or_default();
@@ -8026,7 +7981,7 @@ where
depth_price_bits,
displayed_quantity,
consume_depth,
consume_volume: self.volume_limit,
consume_volume: self.volume_limit && self.volume_capacity_mode.limits_execution_quantity(),
quantity: take_qty,
});
}
@@ -8580,6 +8535,7 @@ mod tests {
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)],
).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose);
let mut portfolio = PortfolioState::new(100_000.0);
broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap();
@@ -8606,6 +8562,7 @@ mod tests {
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose);
broker.upsert_open_order(test_open_order(99));
let mut decision = StrategyDecision::default();
@@ -8633,6 +8590,7 @@ mod tests {
dated_limit_test_candidate(second, false, false, true, true)],
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::NextBarOpen);
let mut portfolio = PortfolioState::new(1_000_000.0);
let mut initial = StrategyDecision::default();
@@ -9655,7 +9613,42 @@ mod tests {
}
#[test]
fn current_bar_close_volume_limit_uses_daily_volume_when_minute_volume_missing() {
fn daily_session_volume_changes_only_audit_not_opening_fills() {
use crate::execution_capacity::VolumeCapacityMode;
let run = |volume: u64, mode: VolumeCapacityMode| {
let mut market = limit_test_snapshot();
market.volume = volume;
let date = market.date;
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()], vec![market], vec![],
vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], vec![], vec![],
).unwrap();
let mut portfolio = PortfolioState::new(100_000.0);
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(mode).with_liquidity_limit(false);
let decision = StrategyDecision { order_intents: vec![OrderIntent::Shares {
symbol: "000001.SZ".into(), quantity: 1_000, reason: "capacity_test".into(),
}], ..StrategyDecision::default() };
let before = portfolio.cash();
let outcome = broker.execute(date, &mut portfolio, &data, &decision);
if outcome.is_err() { assert_eq!(portfolio.cash(), before); }
let audit = broker.audit_completed_session_capacity(date, &data).unwrap();
(outcome, portfolio.cash(), audit)
};
let (strict, _, _) = run(1_000_000, VolumeCapacityMode::ExecutionObservation);
assert!(strict.unwrap_err().to_string().contains("execution-time capacity is missing"));
let (a, cash_a, audit_a) = run(100, VolumeCapacityMode::SessionCapacityAudit);
let (b, cash_b, audit_b) = run(1_000_000, VolumeCapacityMode::SessionCapacityAudit);
let a = a.unwrap(); let b = b.unwrap();
assert_eq!(a.fill_events.len(), 1);
assert_eq!(serde_json::to_value(&a.fill_events).unwrap(), serde_json::to_value(&b.fill_events).unwrap());
assert_eq!(cash_a, cash_b);
assert_eq!(audit_a[0].filled_shares, 1_000);
assert!(!audit_a[0].passed); assert!(audit_b[0].passed);
}
#[test]
fn daily_capacity_requires_a_timed_observation_instead_of_falling_back_to_total_volume() {
let mut snapshot = limit_test_snapshot();
snapshot.minute_volume = 0;
snapshot.volume = 1_000_000;
@@ -9671,13 +9664,13 @@ mod tests {
.with_liquidity_limit(true);
let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false);
broker.market_fillable_quantity(&snapshot, 5_000, false);
assert_eq!(fillable, Ok(5_000));
assert!(fillable.unwrap_err().contains("daily session volume cannot size an earlier fill"));
}
#[test]
fn volume_limit_uses_floor_for_odd_lot_sell() {
fn session_capacity_audit_never_caps_an_early_odd_lot_sell() {
let mut snapshot = limit_test_snapshot();
snapshot.minute_volume = 0;
snapshot.volume = 3;
@@ -9687,18 +9680,19 @@ mod tests {
PriceField::Close,
)
.with_matching_type(MatchingType::CurrentBarClose)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true)
.with_volume_percent(0.5)
.with_liquidity_limit(false);
let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Sell, 10, 100, 100, 0, true);
broker.market_fillable_quantity(&snapshot, 10, false);
assert_eq!(fillable, Ok(1));
assert_eq!(fillable, Ok(10));
}
#[test]
fn current_bar_close_volume_limit_rejects_daily_zero_volume() {
fn session_audit_does_not_infer_an_opening_suspension_from_future_zero_volume() {
let mut snapshot = limit_test_snapshot();
snapshot.minute_volume = 0;
snapshot.volume = 0;
@@ -9708,13 +9702,16 @@ mod tests {
PriceField::Close,
)
.with_matching_type(MatchingType::CurrentBarClose)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true)
.with_liquidity_limit(false);
let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false);
broker.market_fillable_quantity(&snapshot, 5_000, false);
assert_eq!(fillable, Err("daily no volume".to_string()));
assert_eq!(fillable, Ok(5_000));
snapshot.paused = true;
assert_eq!(broker.market_fillable_quantity(&snapshot, 5_000, false), Err("paused".into()));
}
#[test]
@@ -9735,7 +9732,7 @@ mod tests {
.with_liquidity_limit(false);
let fillable =
broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false);
broker.market_fillable_quantity(&snapshot, 5_000, false);
assert_eq!(fillable, Ok(5_000));
}
+21 -2
View File
@@ -122,6 +122,7 @@ impl DailyEquityPoint {
#[derive(Debug, Clone)]
pub struct BacktestResult {
pub capacity_audit: crate::execution_capacity::CapacityAuditSummary,
pub strategy_name: String,
pub equity_curve: Vec<DailyEquityPoint>,
pub benchmark_series: Vec<BenchmarkSnapshot>,
@@ -280,6 +281,7 @@ pub struct AnalyzerRiskSummary {
#[derive(Debug, Clone, Serialize)]
pub struct AnalyzerReport {
pub capacity_audit: crate::execution_capacity::CapacityAuditSummary,
pub strategy_name: String,
pub trades: Vec<AnalyzerTradeRow>,
pub positions: Vec<AnalyzerPositionRow>,
@@ -294,6 +296,7 @@ pub struct AnalyzerReport {
impl BacktestResult {
pub fn analyzer_report(&self) -> AnalyzerReport {
AnalyzerReport {
capacity_audit: self.capacity_audit.clone(),
strategy_name: self.strategy_name.clone(),
trades: self
.fills
@@ -2102,6 +2105,7 @@ where
.map(|(execution_date, _)| *execution_date)
.collect::<Vec<_>>();
let mut result = BacktestResult {
capacity_audit: self.broker.capacity_audit_summary(),
strategy_name: self.strategy.name().to_string(),
benchmark_series: self
.data
@@ -3423,6 +3427,16 @@ where
execution_date,
);
let daily_fill_count = result.fills.len() - day_fill_start;
for audit in self.broker.audit_completed_session_capacity(execution_date, &self.data)? {
result.capacity_audit.observe(&audit);
// Keep every audit in the durable event store, independent of
// debug phase retention. It never changes earlier executions.
result.process_events.push(ProcessEvent {
date: execution_date, kind: ProcessEventKind::SessionCapacityAudit,
order_id: None, symbol: Some(audit.symbol.clone()), side: None,
detail: serde_json::to_string(&audit).map_err(|error| BacktestError::Execution(error.to_string()))?,
});
}
let daily_order_count = result.order_events.len() - day_order_start;
let execution_risk_decisions =
risk_decisions_from_order_events(&result.order_events[day_order_start..]);
@@ -7038,6 +7052,7 @@ mod tests {
let third = d(2025, 1, 6);
let fourth = d(2025, 1, 7);
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true)
.with_volume_percent(0.25);
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
@@ -7065,12 +7080,13 @@ mod tests {
}
#[test]
fn next_bar_open_sell_volume_limit_rejects_execution_day_zero_volume() {
fn next_bar_open_session_audit_flags_zero_volume_without_rewriting_fills() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let third = d(2025, 1, 6);
let fourth = d(2025, 1, 7);
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_volume_limit(true)
.with_volume_percent(0.25);
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
@@ -7091,7 +7107,10 @@ mod tests {
broker,
);
assert_round_trip_sell_canceled_with_reason(&result, "daily volume limit");
assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Sell && fill.date == fourth));
assert_eq!(result.capacity_audit.audit_passed, Some(false));
assert_eq!(result.capacity_audit.failed_symbol_sessions, 1);
assert!(result.process_events.iter().any(|event| event.kind == crate::ProcessEventKind::SessionCapacityAudit));
}
#[test]
+3
View File
@@ -317,6 +317,7 @@ pub enum ProcessEventKind {
AccountDepositWithdraw,
AccountFinanceRepay,
AccountManagementFee,
SessionCapacityAudit,
}
impl ProcessEventKind {
@@ -362,6 +363,7 @@ impl ProcessEventKind {
Self::AccountDepositWithdraw => "account_deposit_withdraw",
Self::AccountFinanceRepay => "account_finance_repay",
Self::AccountManagementFee => "account_management_fee",
Self::SessionCapacityAudit => "session_capacity_audit",
}
}
@@ -393,6 +395,7 @@ impl ProcessEventKind {
| Self::AccountDepositWithdraw
| Self::AccountFinanceRepay
| Self::AccountManagementFee
| Self::SessionCapacityAudit
| Self::Settlement
)
}
@@ -12,6 +12,19 @@ pub enum VolumeCapacityMode {
SessionCapacityAudit,
}
impl VolumeCapacityMode {
pub fn validate(self, enabled: bool, has_execution_observations: bool) -> Result<(), CapacityError> {
if !enabled { return Ok(()); }
match self {
Self::ExecutionObservation if !has_execution_observations => Err(CapacityError::MissingObservation),
Self::CompletedBar => Err(CapacityError::MissingCompletedBar),
_ => Ok(()),
}
}
pub fn limits_execution_quantity(self) -> bool { self != Self::SessionCapacityAudit }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum CapacityError {
#[error("execution capacity ratio must be finite and in (0, 1]")]
@@ -26,6 +39,28 @@ pub enum CapacityError {
WrongSession,
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
MissingObservation,
#[error("completed_bar capacity requires declared bar end and availability; an undated daily total is not a completed observation")]
MissingCompletedBar,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CapacityAuditSummary {
pub mode: VolumeCapacityMode,
pub enabled: bool,
pub participation_rate: f64,
pub audited_symbol_sessions: usize,
pub failed_symbol_sessions: usize,
pub audit_passed: Option<bool>,
pub execution_time_capacity_proven: bool,
}
impl CapacityAuditSummary {
pub fn observe(&mut self, audit: &SessionCapacityAudit) {
self.audited_symbol_sessions += 1;
self.failed_symbol_sessions += usize::from(!audit.passed);
self.audit_passed = Some(self.failed_symbol_sessions == 0);
}
}
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
+55 -4
View File
@@ -17,7 +17,7 @@ use crate::data::{
decision_market_cap_bn,
};
use crate::engine::BacktestError;
use crate::execution_capacity::{CapacityError, ParticipationRate};
use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeCapacityMode};
use crate::events::{OrderSide, ProcessEvent, ProcessEventKind};
use crate::fixed_point::FixedMoney;
use crate::futures::{
@@ -689,6 +689,7 @@ pub struct PlatformExprStrategyConfig {
pub rebalance_cash_mode: RebalanceCashMode,
pub sell_then_buy_delay_slippage_rate: f64,
pub risk_config: FidcRiskControlConfig,
pub volume_capacity_mode: VolumeCapacityMode,
pub slippage_model: SlippageModel,
pub matching_type: MatchingType,
pub quote_quantity_limit: bool,
@@ -777,6 +778,7 @@ impl PlatformExprStrategyConfig {
rebalance_cash_mode: RebalanceCashMode::default(),
sell_then_buy_delay_slippage_rate: 0.0,
risk_config: FidcRiskControlConfig::default(),
volume_capacity_mode: VolumeCapacityMode::ExecutionObservation,
slippage_model: SlippageModel::None,
matching_type: MatchingType::CurrentBarClose,
quote_quantity_limit: true,
@@ -1378,6 +1380,9 @@ enum RuntimeHelperResolution {
}
pub struct PlatformExprStrategy {
// Internal service boundary, never a strategy-spec/risk switch. A planner
// returns intentions; only the broker/matcher can establish actual capacity.
intent_planning_only: bool,
protection_fill_count: usize,
protection_last_buys: BTreeMap<String, NaiveDate>,
protection_last_sells: BTreeMap<String, NaiveDate>,
@@ -1498,6 +1503,9 @@ fn completed_session_factor_date(
}
impl PlatformExprStrategy {
pub fn new_intent_planner(config: PlatformExprStrategyConfig) -> Self {
Self { intent_planning_only: true, ..Self::new(config) }
}
pub fn portfolio_loss_state(&self) -> Option<&PortfolioLossState> {
self.portfolio_loss_state.as_ref()
}
@@ -1799,6 +1807,7 @@ impl PlatformExprStrategy {
.map(PlatformPortfolioDrawdownController::new);
Self {
volume_rate: ParticipationRate::new(config.risk_config.trading_constraints.volume_percent),
intent_planning_only: false,
config,
engine,
protection_fill_count: 0,
@@ -3155,10 +3164,16 @@ impl PlatformExprStrategy {
allow_odd_lot_sell: bool,
current_fill_quantity: u32,
execution_state: &ProjectedExecutionState,
future_execution: bool,
) -> Result<Option<u32>, BacktestError> {
if requested_qty == 0 {
return Ok(Some(0));
}
if future_execution {
// A decision-day estimate cannot use tomorrow's liquidity to
// change the orders created today.
return Ok(Some(requested_qty));
}
let constraints = self.config.risk_config.trading_constraints;
let mut max_fill = requested_qty;
@@ -3201,11 +3216,14 @@ impl PlatformExprStrategy {
}
}
if constraints.volume_limit_enabled {
if constraints.volume_limit_enabled && self.config.volume_capacity_mode.limits_execution_quantity() {
let volume_basis = match quote {
Some(quote) => quote.volume_delta,
None if market.minute_volume > 0 => market.minute_volume,
None => market.volume,
// Preserve the intent budget, without inventing a fillable
// volume from a daily total. The receiving paper/live service
// still applies its unchanged execution risk to actual quotes.
None if self.intent_planning_only => return Ok(Some(max_fill)),
None => return Err(BacktestError::Execution(CapacityError::MissingObservation.to_string())),
};
if volume_basis == 0 {
return Ok(None);
@@ -3332,6 +3350,7 @@ impl PlatformExprStrategy {
allow_odd_lot_sell,
filled_qty,
execution_state,
Self::defer_projection_execution_risk(ctx, date),
)?
.unwrap_or(0);
if available_qty == 0 {
@@ -3520,6 +3539,7 @@ impl PlatformExprStrategy {
sellable_qty >= current_qty,
0,
execution_state,
Self::defer_projection_execution_risk(ctx, date),
)?.filter(|quantity| *quantity > 0)
{
fill = Some(ProjectedExecutionFill {
@@ -4152,6 +4172,7 @@ impl PlatformExprStrategy {
false,
0,
execution_state,
Self::defer_projection_execution_risk(ctx, date),
)?.filter(|quantity| *quantity > 0)
{
fill = Some(ProjectedExecutionFill {
@@ -14526,6 +14547,7 @@ mod tests {
active_datetime: None, order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = symbol.to_string();
cfg.max_positions = 1;
cfg.refresh_rate = 1;
@@ -14559,6 +14581,7 @@ mod tests {
active_datetime: None, order_events: &[], fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = symbol.into();
cfg.stock_filter_expr = "close > 0".into();
cfg.hold_until_exit_enabled = true;
@@ -15069,6 +15092,7 @@ mod tests {
order_events:&[],fills:&[],
};
let mut cfg=PlatformExprStrategyConfig::generic();
cfg.risk_config.trading_constraints.volume_limit_enabled=false;
cfg.signal_symbol=symbol.into();
cfg.rotation_enabled=false;
cfg.signal_book=Some(book);
@@ -15174,6 +15198,7 @@ mod tests {
}
let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks).unwrap();
let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.universe_include = Some(BTreeSet::from(["000001.SZ".to_owned()]));
config.signal_symbol = "000001.SZ".to_owned();
config.benchmark_symbol = "000852.SH".to_owned();
@@ -15186,6 +15211,7 @@ mod tests {
let rows = Arc::new(Mutex::new(Vec::new()));
let strategy = Capture { inner: PlatformExprStrategy::new(config), first, rows: Arc::clone(&rows) };
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose);
let mut engine = BacktestEngine::new(data, strategy, broker, BacktestConfig {
initial_cash: 10_000.0, benchmark_code: "000852.SH".to_owned(), start_date: Some(first),
@@ -15418,6 +15444,7 @@ mod tests {
fills: &[],
};
let mut config = PlatformExprStrategyConfig::microcap_rotation();
config.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.signal_symbol = symbol.to_string();
config.refresh_rate = 1;
config.max_positions = 1;
@@ -15669,6 +15696,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 1;
cfg.max_positions = 3;
@@ -18040,6 +18068,7 @@ mod tests {
false,
0,
&execution_state,
false,
).expect("valid volume capacity"),
Some(2_500)
);
@@ -18060,6 +18089,7 @@ mod tests {
false,
0,
&execution_state,
false,
).expect("valid remaining volume capacity"),
Some(100)
);
@@ -22374,6 +22404,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = symbol.to_string();
cfg.exposure_expr = "1.0".to_string();
cfg.selection_limit_expr = "40".to_string();
@@ -22748,6 +22779,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.rotation_enabled = false;
cfg.daily_top_up_enabled = false;
cfg.signal_symbol = symbol.to_string();
@@ -23704,6 +23736,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = symbol.to_string();
cfg.exposure_expr = "0.5".to_string();
cfg.selection_limit_expr = "40".to_string();
@@ -24960,6 +24993,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap());
cfg.signal_symbol = signal.to_string();
cfg.max_positions = 1;
@@ -27427,6 +27461,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99;
cfg.max_positions = 2;
@@ -27568,6 +27603,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99;
cfg.max_positions = 2;
@@ -28358,6 +28394,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99;
cfg.max_positions = 2;
@@ -28677,6 +28714,7 @@ mod tests {
.expect("dataset");
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 20;
cfg.max_positions = 2;
@@ -28725,6 +28763,7 @@ mod tests {
);
let mut dynamic_cfg = PlatformExprStrategyConfig::microcap_rotation();
dynamic_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
dynamic_cfg.signal_symbol = "000001.SZ".to_string();
dynamic_cfg.refresh_rate = 20;
dynamic_cfg.refresh_rate_expr = "year >= 2024 ? 5 : 20".to_string();
@@ -28750,6 +28789,7 @@ mod tests {
);
let mut signal_dates_cfg = PlatformExprStrategyConfig::microcap_rotation();
signal_dates_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
signal_dates_cfg.signal_symbol = "000001.SZ".to_string();
signal_dates_cfg.refresh_rate = 20;
signal_dates_cfg.max_positions = 2;
@@ -28785,6 +28825,7 @@ mod tests {
);
let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation();
no_retry_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
no_retry_cfg.signal_symbol = "000001.SZ".to_string();
no_retry_cfg.refresh_rate = 15;
no_retry_cfg.max_positions = 2;
@@ -28952,6 +28993,7 @@ mod tests {
.expect("dataset");
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 10;
cfg.max_positions = 2;
@@ -29129,6 +29171,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 20;
cfg.max_positions = 2;
@@ -30342,6 +30385,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.rotation_enabled = false;
cfg.hold_until_exit_enabled = true;
cfg.signal_symbol = symbol.to_string();
@@ -31604,6 +31648,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99;
cfg.max_positions = 2;
@@ -31757,6 +31802,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99;
cfg.max_positions = 2;
@@ -31921,6 +31967,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99;
cfg.max_positions = 2;
@@ -33942,6 +33989,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99;
cfg.max_positions = 1;
@@ -34119,6 +34167,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.refresh_rate = 99;
cfg.max_positions = 1;
@@ -34161,6 +34210,7 @@ mod tests {
);
let mut filtered_cfg = PlatformExprStrategyConfig::microcap_rotation();
filtered_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
filtered_cfg.signal_symbol = "000001.SZ".to_string();
filtered_cfg.refresh_rate = 99;
filtered_cfg.max_positions = 1;
@@ -36484,6 +36534,7 @@ mod tests {
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
cfg.signal_symbol = "000001.SZ".to_string();
cfg.rotation_enabled = false;
cfg.benchmark_short_ma_days = 1;
@@ -91,6 +91,8 @@ pub struct StrategyRebalanceSpec {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyExecutionSpec {
#[serde(default, alias = "volume_capacity_mode")]
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
#[serde(default)]
pub frequency: Option<String>,
#[serde(default, alias = "matching_type")]
@@ -164,9 +166,22 @@ pub struct StrategyExecutionSpec {
pub sell_then_buy_delay_slippage_rate: Option<f64>,
}
impl StrategyRuntimeSpec {
pub fn volume_capacity_mode(&self) -> Result<crate::execution_capacity::VolumeCapacityMode, String> {
let engine = self.engine_config.as_ref().and_then(|config| config.volume_capacity_mode);
let execution = self.execution.as_ref().and_then(|config| config.volume_capacity_mode);
if engine.zip(execution).is_some_and(|(a, b)| a != b) {
return Err("conflicting engine/execution volumeCapacityMode".into());
}
Ok(execution.or(engine).unwrap_or_default())
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyEngineConfig {
#[serde(default, alias = "volume_capacity_mode")]
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
#[serde(default)]
pub frequency: Option<String>,
#[serde(default, alias = "template_id")]
@@ -1822,6 +1837,7 @@ pub fn platform_expr_config_from_spec(
strategy_spec: Option<&StrategyRuntimeSpec>,
) -> Result<PlatformExprStrategyConfig, String> {
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.volume_capacity_mode = strategy_spec.map(StrategyRuntimeSpec::volume_capacity_mode).transpose()?.unwrap_or_default();
cfg.strategy_name = strategy_id.to_string();
if !signal_symbol.trim().is_empty() {
cfg.signal_symbol = signal_symbol.trim().to_string();