修复next-open涨跌停风控价格口径
This commit is contained in:
@@ -297,6 +297,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
|
|
||||||
pub fn with_matching_type(mut self, matching_type: MatchingType) -> Self {
|
pub fn with_matching_type(mut self, matching_type: MatchingType) -> Self {
|
||||||
self.matching_type = matching_type;
|
self.matching_type = matching_type;
|
||||||
|
self.execution_price_field = execution_price_field_from_matching_type(matching_type);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5801,6 +5802,19 @@ fn matching_type_from_price_field(field: PriceField) -> MatchingType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn execution_price_field_from_matching_type(matching_type: MatchingType) -> PriceField {
|
||||||
|
match matching_type {
|
||||||
|
MatchingType::OpenAuction => PriceField::DayOpen,
|
||||||
|
MatchingType::CurrentBarClose => PriceField::Close,
|
||||||
|
MatchingType::NextBarOpen => PriceField::Open,
|
||||||
|
MatchingType::MinuteLast
|
||||||
|
| MatchingType::MinuteBestOwn
|
||||||
|
| MatchingType::MinuteBestCounterparty
|
||||||
|
| MatchingType::Vwap
|
||||||
|
| MatchingType::Twap => PriceField::Last,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn merge_partial_fill_reason(current: Option<String>, next: Option<&str>) -> Option<String> {
|
fn merge_partial_fill_reason(current: Option<String>, next: Option<&str>) -> Option<String> {
|
||||||
match (current, next) {
|
match (current, next) {
|
||||||
(Some(existing), Some(next_reason)) if !existing.contains(next_reason) => {
|
(Some(existing), Some(next_reason)) if !existing.contains(next_reason) => {
|
||||||
@@ -6153,6 +6167,119 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_open_buy_limit_risk_uses_open_not_close() {
|
||||||
|
let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date");
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Close,
|
||||||
|
)
|
||||||
|
.with_matching_type(MatchingType::NextBarOpen)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false);
|
||||||
|
let mut execution_snapshot = dated_limit_test_snapshot(execution_date);
|
||||||
|
execution_snapshot.open = 10.0;
|
||||||
|
execution_snapshot.day_open = 10.0;
|
||||||
|
execution_snapshot.close = execution_snapshot.upper_limit;
|
||||||
|
execution_snapshot.last_price = execution_snapshot.upper_limit;
|
||||||
|
let data = DataSet::from_components_with_actions_and_quotes(
|
||||||
|
vec![limit_test_instrument()],
|
||||||
|
vec![execution_snapshot],
|
||||||
|
Vec::new(),
|
||||||
|
vec![dated_limit_test_candidate(
|
||||||
|
execution_date,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)],
|
||||||
|
vec![dated_limit_test_benchmark(execution_date)],
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.expect("valid dataset");
|
||||||
|
let mut portfolio = PortfolioState::new(20_000.0);
|
||||||
|
|
||||||
|
let report = broker
|
||||||
|
.execute(
|
||||||
|
execution_date,
|
||||||
|
&mut portfolio,
|
||||||
|
&data,
|
||||||
|
&next_open_buy_decision(),
|
||||||
|
)
|
||||||
|
.expect("execute next open buy");
|
||||||
|
|
||||||
|
assert_eq!(report.fill_events.len(), 1);
|
||||||
|
assert_eq!(report.fill_events[0].price, 10.0);
|
||||||
|
assert_eq!(
|
||||||
|
portfolio
|
||||||
|
.position("000001.SZ")
|
||||||
|
.map(|position| position.quantity),
|
||||||
|
Some(100)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_open_buy_limit_risk_rejects_open_upper_limit_even_when_close_is_clean() {
|
||||||
|
let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date");
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Close,
|
||||||
|
)
|
||||||
|
.with_matching_type(MatchingType::NextBarOpen)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false);
|
||||||
|
let mut execution_snapshot = dated_limit_test_snapshot(execution_date);
|
||||||
|
execution_snapshot.open = execution_snapshot.upper_limit;
|
||||||
|
execution_snapshot.day_open = execution_snapshot.upper_limit;
|
||||||
|
execution_snapshot.close = 10.0;
|
||||||
|
execution_snapshot.last_price = 10.0;
|
||||||
|
let data = DataSet::from_components_with_actions_and_quotes(
|
||||||
|
vec![limit_test_instrument()],
|
||||||
|
vec![execution_snapshot],
|
||||||
|
Vec::new(),
|
||||||
|
vec![dated_limit_test_candidate(
|
||||||
|
execution_date,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)],
|
||||||
|
vec![dated_limit_test_benchmark(execution_date)],
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.expect("valid dataset");
|
||||||
|
let mut portfolio = PortfolioState::new(20_000.0);
|
||||||
|
|
||||||
|
let report = broker
|
||||||
|
.execute(
|
||||||
|
execution_date,
|
||||||
|
&mut portfolio,
|
||||||
|
&data,
|
||||||
|
&next_open_buy_decision(),
|
||||||
|
)
|
||||||
|
.expect("execute next open buy");
|
||||||
|
|
||||||
|
assert!(report.fill_events.is_empty());
|
||||||
|
assert!(portfolio.position("000001.SZ").is_none());
|
||||||
|
let rejected_order = report
|
||||||
|
.order_events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.side == OrderSide::Buy)
|
||||||
|
.expect("buy order event");
|
||||||
|
assert_eq!(rejected_order.status, OrderStatus::Canceled);
|
||||||
|
assert!(
|
||||||
|
rejected_order
|
||||||
|
.reason
|
||||||
|
.contains("open at or above upper limit"),
|
||||||
|
"{}",
|
||||||
|
rejected_order.reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_open_sell_risk_uses_execution_date_not_signal_date() {
|
fn next_open_sell_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");
|
||||||
@@ -6267,6 +6394,127 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_open_sell_limit_risk_uses_open_not_close() {
|
||||||
|
let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date");
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Close,
|
||||||
|
)
|
||||||
|
.with_matching_type(MatchingType::NextBarOpen)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false);
|
||||||
|
let mut execution_snapshot = dated_limit_test_snapshot(execution_date);
|
||||||
|
execution_snapshot.open = 10.0;
|
||||||
|
execution_snapshot.day_open = 10.0;
|
||||||
|
execution_snapshot.close = execution_snapshot.lower_limit;
|
||||||
|
execution_snapshot.last_price = execution_snapshot.lower_limit;
|
||||||
|
let data = DataSet::from_components_with_actions_and_quotes(
|
||||||
|
vec![limit_test_instrument()],
|
||||||
|
vec![execution_snapshot],
|
||||||
|
Vec::new(),
|
||||||
|
vec![dated_limit_test_candidate(
|
||||||
|
execution_date,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)],
|
||||||
|
vec![dated_limit_test_benchmark(execution_date)],
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.expect("valid dataset");
|
||||||
|
let mut portfolio = PortfolioState::new(20_000.0);
|
||||||
|
portfolio
|
||||||
|
.position_mut("000001.SZ")
|
||||||
|
.buy(signal_date, 100, 10.0);
|
||||||
|
|
||||||
|
let report = broker
|
||||||
|
.execute(
|
||||||
|
execution_date,
|
||||||
|
&mut portfolio,
|
||||||
|
&data,
|
||||||
|
&next_open_sell_decision(),
|
||||||
|
)
|
||||||
|
.expect("execute next open sell");
|
||||||
|
|
||||||
|
assert_eq!(report.fill_events.len(), 1);
|
||||||
|
assert_eq!(report.fill_events[0].price, 10.0);
|
||||||
|
assert!(portfolio.position("000001.SZ").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_open_sell_limit_risk_rejects_open_lower_limit_even_when_close_is_clean() {
|
||||||
|
let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date");
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Close,
|
||||||
|
)
|
||||||
|
.with_matching_type(MatchingType::NextBarOpen)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false);
|
||||||
|
let mut execution_snapshot = dated_limit_test_snapshot(execution_date);
|
||||||
|
execution_snapshot.open = execution_snapshot.lower_limit;
|
||||||
|
execution_snapshot.day_open = execution_snapshot.lower_limit;
|
||||||
|
execution_snapshot.close = 10.0;
|
||||||
|
execution_snapshot.last_price = 10.0;
|
||||||
|
let data = DataSet::from_components_with_actions_and_quotes(
|
||||||
|
vec![limit_test_instrument()],
|
||||||
|
vec![execution_snapshot],
|
||||||
|
Vec::new(),
|
||||||
|
vec![dated_limit_test_candidate(
|
||||||
|
execution_date,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)],
|
||||||
|
vec![dated_limit_test_benchmark(execution_date)],
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.expect("valid dataset");
|
||||||
|
let mut portfolio = PortfolioState::new(20_000.0);
|
||||||
|
portfolio
|
||||||
|
.position_mut("000001.SZ")
|
||||||
|
.buy(signal_date, 100, 10.0);
|
||||||
|
|
||||||
|
let report = broker
|
||||||
|
.execute(
|
||||||
|
execution_date,
|
||||||
|
&mut portfolio,
|
||||||
|
&data,
|
||||||
|
&next_open_sell_decision(),
|
||||||
|
)
|
||||||
|
.expect("execute next open sell");
|
||||||
|
|
||||||
|
assert!(report.fill_events.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
portfolio
|
||||||
|
.position("000001.SZ")
|
||||||
|
.map(|position| position.quantity),
|
||||||
|
Some(100)
|
||||||
|
);
|
||||||
|
let rejected_order = report
|
||||||
|
.order_events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.side == OrderSide::Sell)
|
||||||
|
.expect("sell order event");
|
||||||
|
assert_eq!(rejected_order.status, OrderStatus::Canceled);
|
||||||
|
assert!(
|
||||||
|
rejected_order
|
||||||
|
.reason
|
||||||
|
.contains("open at or below lower limit"),
|
||||||
|
"{}",
|
||||||
|
rejected_order.reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_bar_close_volume_limit_uses_daily_volume_when_minute_volume_missing() {
|
fn current_bar_close_volume_limit_uses_daily_volume_when_minute_volume_missing() {
|
||||||
let mut snapshot = limit_test_snapshot();
|
let mut snapshot = limit_test_snapshot();
|
||||||
|
|||||||
Reference in New Issue
Block a user