修正显式目标单批次撮合顺序

This commit is contained in:
boris
2026-07-17 23:43:04 +08:00
parent af4cd25f47
commit 755fffda0f
+226 -1
View File
@@ -472,6 +472,115 @@ where
}
}
fn cash_rebalance_intent_side(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
intent: &OrderIntent,
) -> Option<OrderSide> {
let target_value_side = |symbol: &str, target_value: f64| {
let current_qty = portfolio
.position(symbol)
.map(|position| position.quantity)
.unwrap_or(0);
let current_value = data
.market(date, symbol)
.map(|snapshot| {
self.target_value_valuation_price(date, data, symbol, snapshot)
* current_qty as f64
})
.unwrap_or(0.0);
if target_value.max(0.0) + f64::EPSILON < current_value {
OrderSide::Sell
} else {
OrderSide::Buy
}
};
match intent {
OrderIntent::Shares { quantity, .. } | OrderIntent::LimitShares { quantity, .. } => {
Some(if *quantity < 0 {
OrderSide::Sell
} else {
OrderSide::Buy
})
}
OrderIntent::Lots { lots, .. } | OrderIntent::LimitLots { lots, .. } => {
Some(if *lots < 0 {
OrderSide::Sell
} else {
OrderSide::Buy
})
}
OrderIntent::TargetShares {
symbol,
target_quantity,
..
}
| OrderIntent::LimitTargetShares {
symbol,
target_quantity,
..
} => {
let current_qty = portfolio
.position(symbol)
.map(|position| position.quantity as i64)
.unwrap_or(0);
Some(if i64::from(*target_quantity).max(0) < current_qty {
OrderSide::Sell
} else {
OrderSide::Buy
})
}
OrderIntent::TargetValue {
symbol,
target_value,
..
}
| OrderIntent::LimitTargetValue {
symbol,
target_value,
..
} => Some(target_value_side(symbol, *target_value)),
OrderIntent::Value { value, .. } | OrderIntent::LimitValue { value, .. } => {
Some(if *value < 0.0 {
OrderSide::Sell
} else {
OrderSide::Buy
})
}
OrderIntent::Percent { percent, .. } | OrderIntent::LimitPercent { percent, .. } => {
Some(if *percent < 0.0 {
OrderSide::Sell
} else {
OrderSide::Buy
})
}
OrderIntent::TargetPercent {
symbol,
target_percent,
..
}
| OrderIntent::LimitTargetPercent {
symbol,
target_percent,
..
} => {
let total_equity = self
.runtime_decision_total_equity
.get()
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or_else(|| portfolio.total_equity());
Some(target_value_side(
symbol,
total_equity * target_percent.max(0.0),
))
}
_ => None,
}
}
fn value_order_sizing_price(
&self,
date: NaiveDate,
@@ -786,7 +895,24 @@ where
&mut report,
)?;
if !decision.order_intents.is_empty() {
for intent in &decision.order_intents {
let mut ordered_intents = decision.order_intents.iter().collect::<Vec<_>>();
if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash
&& ordered_intents.iter().all(|intent| {
self.cash_rebalance_intent_side(date, portfolio, data, intent)
.is_some()
})
{
ordered_intents.sort_by_key(|intent| {
if self.cash_rebalance_intent_side(date, portfolio, data, intent)
== Some(OrderSide::Sell)
{
0
} else {
1
}
});
}
for intent in ordered_intents {
self.process_order_intent(
date,
portfolio,
@@ -8279,6 +8405,105 @@ mod tests {
);
}
#[test]
fn target_value_batch_executes_reductions_before_increases_when_sell_cash_is_reusable() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date");
let symbols = ["000001.SZ", "000002.SZ"];
let instruments = symbols
.iter()
.map(|symbol| Instrument {
symbol: (*symbol).to_string(),
name: (*symbol).to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
})
.collect::<Vec<_>>();
let snapshots = symbols
.iter()
.map(|symbol| {
let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string();
snapshot
})
.collect::<Vec<_>>();
let candidates = symbols
.iter()
.map(|symbol| {
let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string();
candidate
})
.collect::<Vec<_>>();
let data = DataSet::from_components_with_actions_and_quotes(
instruments,
snapshots,
Vec::new(),
candidates,
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let decision = StrategyDecision {
order_intents: vec![
OrderIntent::TargetValue {
symbol: "000002.SZ".to_string(),
target_value: 9_000.0,
reason: "buy_submitted_first".to_string(),
},
OrderIntent::TargetValue {
symbol: "000001.SZ".to_string(),
target_value: 0.0,
reason: "sell_submitted_second".to_string(),
},
],
..StrategyDecision::default()
};
for mode in [
RebalanceCashMode::SamePointNet,
RebalanceCashMode::SellThenBuy,
] {
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_aiquant_execution_rules(true)
.with_rebalance_cash_mode(mode)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let mut portfolio = PortfolioState::new(0.0);
portfolio
.position_mut("000001.SZ")
.buy(prev_date, 1_000, 10.0);
let report = broker
.execute(date, &mut portfolio, &data, &decision)
.expect("target-value batch execution");
assert!(portfolio.position("000001.SZ").is_none(), "{mode:?}");
assert!(
portfolio
.position("000002.SZ")
.is_some_and(|position| position.quantity > 0),
"mode={mode:?} fills={:?}",
report.fill_events
);
assert_eq!(
report.fill_events.first().map(|fill| fill.side),
Some(OrderSide::Sell),
"mode={mode:?} fills={:?}",
report.fill_events
);
}
}
#[test]
fn target_portfolio_smart_open_auction_uses_day_open_for_valuation() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");