merge: integrate causal capacity model with current order clocks and intent planning
This commit is contained in:
+97
-100
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user