支持盘后固定价格撮合合同

This commit is contained in:
boris
2026-08-27 17:56:41 +08:00
parent c86a0e2339
commit b6f4b05844
3 changed files with 74 additions and 3 deletions
+68 -1
View File
@@ -248,6 +248,7 @@ pub enum MatchingType {
OpenAuction, OpenAuction,
CurrentBarClose, CurrentBarClose,
NextBarOpen, NextBarOpen,
PostCloseFixedPrice,
MinuteLast, MinuteLast,
MinuteBestOwn, MinuteBestOwn,
MinuteBestCounterparty, MinuteBestCounterparty,
@@ -545,6 +546,12 @@ impl<C, R> BrokerSimulator<C, R> {
} }
fn effective_remainder_policy(&self, allow_pending_limit: bool) -> RemainderPolicy { fn effective_remainder_policy(&self, allow_pending_limit: bool) -> RemainderPolicy {
if self.matching_type == MatchingType::PostCloseFixedPrice {
return match self.runtime_time_in_force.get() {
Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill,
_ => RemainderPolicy::Cancel,
};
}
match self.runtime_time_in_force.get() { match self.runtime_time_in_force.get() {
Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill, Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill,
Some(OrderTimeInForce::Gtc) => RemainderPolicy::KeepUntilCanceled, Some(OrderTimeInForce::Gtc) => RemainderPolicy::KeepUntilCanceled,
@@ -983,6 +990,13 @@ where
return self.clamp_execution_price(snapshot, side, raw_price); return self.clamp_execution_price(snapshot, side, raw_price);
} }
// A fixed-price post-close declaration is matched at the official
// close; applying market slippage here would turn it into a different
// order contract. Fees and risk checks still run normally.
if self.matching_type == MatchingType::PostCloseFixedPrice {
return 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));
let mut adjusted = match self.slippage_model { let mut adjusted = match self.slippage_model {
SlippageModel::None => raw_price, SlippageModel::None => raw_price,
@@ -7427,7 +7441,8 @@ where
match matching_type { match matching_type {
MatchingType::OpenAuction MatchingType::OpenAuction
| MatchingType::CurrentBarClose | MatchingType::CurrentBarClose
| MatchingType::NextBarOpen => false, | MatchingType::NextBarOpen
| MatchingType::PostCloseFixedPrice => false,
MatchingType::MinuteLast => self.liquidity_limit, MatchingType::MinuteLast => self.liquidity_limit,
MatchingType::MinuteBestOwn MatchingType::MinuteBestOwn
| MatchingType::MinuteBestCounterparty | MatchingType::MinuteBestCounterparty
@@ -7473,6 +7488,7 @@ fn execution_price_field_from_matching_type(matching_type: MatchingType) -> Pric
MatchingType::OpenAuction => PriceField::DayOpen, MatchingType::OpenAuction => PriceField::DayOpen,
MatchingType::CurrentBarClose => PriceField::Close, MatchingType::CurrentBarClose => PriceField::Close,
MatchingType::NextBarOpen => PriceField::Open, MatchingType::NextBarOpen => PriceField::Open,
MatchingType::PostCloseFixedPrice => PriceField::Close,
MatchingType::MinuteLast MatchingType::MinuteLast
| MatchingType::MinuteBestOwn | MatchingType::MinuteBestOwn
| MatchingType::MinuteBestCounterparty | MatchingType::MinuteBestCounterparty
@@ -7953,6 +7969,57 @@ mod tests {
assert!(broker.quote_quantity_limited(MatchingType::MinuteBestCounterparty)); assert!(broker.quote_quantity_limited(MatchingType::MinuteBestCounterparty));
} }
#[test]
fn post_close_fixed_price_uses_daily_close_without_market_slippage() {
let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date");
let mut snapshot = limit_test_snapshot();
snapshot.date = date;
snapshot.timestamp = Some(format!("{date} 15:00:00"));
snapshot.close = 10.0;
snapshot.last_price = 10.0;
snapshot.bid1 = 10.0;
snapshot.ask1 = 10.0;
let mut candidate = limit_test_candidate(true, true);
candidate.date = date;
let mut benchmark = limit_test_benchmark();
benchmark.date = date;
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![snapshot],
Vec::new(),
vec![candidate],
vec![benchmark],
Vec::new(),
Vec::new(),
)
.expect("valid post-close dataset");
let broker = BrokerSimulator::new(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
)
.with_matching_type(MatchingType::PostCloseFixedPrice)
.with_slippage_model(SlippageModel::PriceRatio(0.25))
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let decision = StrategyDecision {
order_intents: vec![OrderIntent::TargetValue {
symbol: "000001.SZ".to_string(),
target_value: 2_000.0,
reason: "post_close_buy".to_string(),
}],
..StrategyDecision::default()
};
let mut portfolio = PortfolioState::new(20_000.0);
let report = broker
.execute(date, &mut portfolio, &data, &decision)
.expect("post-close execution");
assert_eq!(report.fill_events.len(), 1, "report={report:?}");
assert_eq!(report.fill_events[0].price, 10.0);
assert_eq!(report.fill_events[0].quantity, 100);
assert!(!broker.has_open_orders());
}
#[test] #[test]
fn next_open_buy_risk_uses_execution_date_not_signal_date() { fn next_open_buy_risk_uses_execution_date_not_signal_date() {
let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
@@ -2194,6 +2194,7 @@ impl PlatformExprStrategy {
MatchingType::OpenAuction => PriceField::DayOpen, MatchingType::OpenAuction => PriceField::DayOpen,
MatchingType::CurrentBarClose => PriceField::Close, MatchingType::CurrentBarClose => PriceField::Close,
MatchingType::NextBarOpen => PriceField::Open, MatchingType::NextBarOpen => PriceField::Open,
MatchingType::PostCloseFixedPrice => PriceField::Close,
MatchingType::MinuteLast MatchingType::MinuteLast
| MatchingType::MinuteBestOwn | MatchingType::MinuteBestOwn
| MatchingType::MinuteBestCounterparty | MatchingType::MinuteBestCounterparty
@@ -2256,6 +2257,7 @@ impl PlatformExprStrategy {
let price_field = match self.config.matching_type { let price_field = match self.config.matching_type {
MatchingType::NextBarOpen => PriceField::Open, MatchingType::NextBarOpen => PriceField::Open,
MatchingType::CurrentBarClose => PriceField::Close, MatchingType::CurrentBarClose => PriceField::Close,
MatchingType::PostCloseFixedPrice => PriceField::Close,
MatchingType::OpenAuction MatchingType::OpenAuction
| MatchingType::MinuteLast | MatchingType::MinuteLast
| MatchingType::MinuteBestOwn | MatchingType::MinuteBestOwn
@@ -1345,9 +1345,10 @@ fn parse_matching_type(value: Option<&str>) -> Result<Option<MatchingType>, Stri
match normalize_model_name(raw).as_str() { match normalize_model_name(raw).as_str() {
"current_bar_close" => Ok(Some(MatchingType::CurrentBarClose)), "current_bar_close" => Ok(Some(MatchingType::CurrentBarClose)),
"next_bar_open" => Ok(Some(MatchingType::NextBarOpen)), "next_bar_open" => Ok(Some(MatchingType::NextBarOpen)),
"post_close_fixed_price" => Ok(Some(MatchingType::PostCloseFixedPrice)),
"minute_last" => Ok(Some(MatchingType::MinuteLast)), "minute_last" => Ok(Some(MatchingType::MinuteLast)),
_ => Err(format!( _ => Err(format!(
"matchingType only supports current_bar_close, next_bar_open, minute_last: {raw}" "matchingType only supports current_bar_close, next_bar_open, post_close_fixed_price, minute_last: {raw}"
)), )),
} }
} }
@@ -3660,6 +3661,7 @@ mod tests {
for (raw, expected) in [ for (raw, expected) in [
("current_bar_close", MatchingType::CurrentBarClose), ("current_bar_close", MatchingType::CurrentBarClose),
("next_bar_open", MatchingType::NextBarOpen), ("next_bar_open", MatchingType::NextBarOpen),
("post_close_fixed_price", MatchingType::PostCloseFixedPrice),
("minute_last", MatchingType::MinuteLast), ("minute_last", MatchingType::MinuteLast),
] { ] {
let spec = serde_json::json!({ let spec = serde_json::json!({
@@ -3696,7 +3698,7 @@ mod tests {
assert!( assert!(
err.to_string().contains( err.to_string().contains(
"matchingType only supports current_bar_close, next_bar_open, minute_last" "matchingType only supports current_bar_close, next_bar_open, post_close_fixed_price, minute_last"
), ),
"{err}" "{err}"
); );