修复调仓卖出失败后的持仓槽位溢出

This commit is contained in:
boris
2026-07-19 04:51:49 +08:00
parent a77a00c70a
commit 24528ecfeb
+367 -2
View File
@@ -195,6 +195,7 @@ pub struct BrokerSimulator<C, R> {
runtime_decision_date: Cell<Option<NaiveDate>>, runtime_decision_date: Cell<Option<NaiveDate>>,
runtime_order_created_date: Cell<Option<NaiveDate>>, runtime_order_created_date: Cell<Option<NaiveDate>>,
runtime_decision_total_equity: Cell<Option<f64>>, runtime_decision_total_equity: Cell<Option<f64>>,
runtime_target_position_limit: Cell<Option<usize>>,
next_order_id: Cell<u64>, next_order_id: Cell<u64>,
open_orders: RefCell<Vec<OpenOrder>>, open_orders: RefCell<Vec<OpenOrder>>,
} }
@@ -225,6 +226,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_decision_date: Cell::new(None), runtime_decision_date: Cell::new(None),
runtime_order_created_date: Cell::new(None), runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None), runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None),
next_order_id: Cell::new(1), next_order_id: Cell::new(1),
open_orders: RefCell::new(Vec::new()), open_orders: RefCell::new(Vec::new()),
} }
@@ -259,6 +261,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_decision_date: Cell::new(None), runtime_decision_date: Cell::new(None),
runtime_order_created_date: Cell::new(None), runtime_order_created_date: Cell::new(None),
runtime_decision_total_equity: Cell::new(None), runtime_decision_total_equity: Cell::new(None),
runtime_target_position_limit: Cell::new(None),
next_order_id: Cell::new(1), next_order_id: Cell::new(1),
open_orders: RefCell::new(Vec::new()), open_orders: RefCell::new(Vec::new()),
} }
@@ -538,6 +541,11 @@ where
target_value, target_value,
.. ..
} }
| OrderIntent::TimedTargetValue {
symbol,
target_value,
..
}
| OrderIntent::LimitTargetValue { | OrderIntent::LimitTargetValue {
symbol, symbol,
target_value, target_value,
@@ -581,6 +589,103 @@ where
} }
} }
fn target_position_intent(intent: &OrderIntent) -> Option<(&str, bool)> {
match intent {
OrderIntent::TargetShares {
symbol,
target_quantity,
..
}
| OrderIntent::LimitTargetShares {
symbol,
target_quantity,
..
} => Some((symbol, *target_quantity > 0)),
OrderIntent::TargetValue {
symbol,
target_value,
..
}
| OrderIntent::TimedTargetValue {
symbol,
target_value,
..
}
| OrderIntent::LimitTargetValue {
symbol,
target_value,
..
} => Some((symbol, target_value.is_finite() && *target_value > 0.0)),
OrderIntent::TargetPercent {
symbol,
target_percent,
..
}
| OrderIntent::LimitTargetPercent {
symbol,
target_percent,
..
} => Some((symbol, target_percent.is_finite() && *target_percent > 0.0)),
_ => None,
}
}
fn infer_target_position_limit(
&self,
portfolio: &PortfolioState,
intents: &[&OrderIntent],
) -> Option<usize> {
if intents.is_empty()
|| intents
.iter()
.any(|intent| Self::target_position_intent(intent).is_none())
{
return None;
}
let held_symbols = portfolio
.positions()
.iter()
.filter(|(_, position)| position.quantity > 0)
.map(|(symbol, _)| symbol.clone())
.collect::<BTreeSet<_>>();
let mut exit_symbols = BTreeSet::new();
let mut entry_symbols = BTreeSet::new();
for intent in intents {
let Some((symbol, has_positive_target)) = Self::target_position_intent(intent) else {
continue;
};
if held_symbols.contains(symbol) && !has_positive_target {
exit_symbols.insert(symbol.to_string());
} else if !held_symbols.contains(symbol) && has_positive_target {
entry_symbols.insert(symbol.to_string());
}
}
for symbol in exit_symbols.clone() {
if entry_symbols.contains(&symbol) {
exit_symbols.remove(&symbol);
entry_symbols.remove(&symbol);
}
}
if exit_symbols.is_empty() || entry_symbols.is_empty() {
return None;
}
Some(
held_symbols
.len()
.saturating_sub(exit_symbols.len())
.saturating_add(entry_symbols.len()),
)
}
fn positive_position_count(portfolio: &PortfolioState) -> usize {
portfolio
.positions()
.values()
.filter(|position| position.quantity > 0)
.count()
}
fn value_order_sizing_price( fn value_order_sizing_price(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -912,8 +1017,11 @@ where
} }
}); });
} }
let previous_target_position_limit = self
.runtime_target_position_limit
.replace(self.infer_target_position_limit(portfolio, &ordered_intents));
for intent in ordered_intents { for intent in ordered_intents {
self.process_order_intent( let result = self.process_order_intent(
date, date,
portfolio, portfolio,
data, data,
@@ -923,8 +1031,15 @@ where
&mut global_execution_cursor, &mut global_execution_cursor,
&mut commission_state, &mut commission_state,
&mut report, &mut report,
)?; );
if let Err(error) = result {
self.runtime_target_position_limit
.set(previous_target_position_limit);
return Err(error);
} }
}
self.runtime_target_position_limit
.set(previous_target_position_limit);
portfolio.prune_flat_positions(); portfolio.prune_flat_positions();
return Ok(report); return Ok(report);
} }
@@ -4929,6 +5044,32 @@ where
algo_request: Option<&AlgoExecutionRequest>, algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport, report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
if portfolio
.position(symbol)
.is_none_or(|position| position.quantity == 0)
&& self
.runtime_target_position_limit
.get()
.is_some_and(|limit| Self::positive_position_count(portfolio) >= limit)
{
let position_count = Self::positive_position_count(portfolio);
let position_limit = self.runtime_target_position_limit.get().unwrap_or(0);
Self::reject_unavailable_order(
report,
date,
order_id,
symbol,
OrderSide::Buy,
requested_qty,
reason,
"target position slot unavailable after failed exit",
emit_creation_events,
);
report.diagnostics.push(format!(
"target_position_slot_rejected symbol={symbol} current_positions={position_count} target_position_limit={position_limit}"
));
return Ok(());
}
let Some(snapshot) = data.market(date, symbol) else { let Some(snapshot) = data.market(date, symbol) else {
let unavailable_reason = self let unavailable_reason = self
.missing_market_execution_risk_rejection_reason(date, data, symbol, OrderSide::Buy) .missing_market_execution_risk_rejection_reason(date, data, symbol, OrderSide::Buy)
@@ -6631,6 +6772,52 @@ mod tests {
} }
} }
fn target_position_slot_test_data(block_exit: bool) -> DataSet {
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
let instruments = symbols
.iter()
.map(|symbol| {
let mut instrument = limit_test_instrument();
instrument.symbol = (*symbol).to_string();
instrument.name = (*symbol).to_string();
instrument
})
.collect::<Vec<_>>();
let snapshots = symbols
.iter()
.map(|symbol| {
let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string();
if block_exit && *symbol == "000001.SZ" {
snapshot.day_open = snapshot.lower_limit;
snapshot.open = snapshot.lower_limit;
snapshot.last_price = snapshot.lower_limit;
snapshot.bid1 = snapshot.lower_limit;
snapshot.ask1 = snapshot.lower_limit;
}
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<_>>();
DataSet::from_components_with_actions_and_quotes(
instruments,
snapshots,
Vec::new(),
candidates,
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid target-position slot dataset")
}
fn dated_limit_test_snapshot(date: chrono::NaiveDate) -> DailyMarketSnapshot { fn dated_limit_test_snapshot(date: chrono::NaiveDate) -> DailyMarketSnapshot {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.date = date; snapshot.date = date;
@@ -8504,6 +8691,184 @@ mod tests {
} }
} }
#[test]
fn failed_target_exit_blocks_replacement_entry_when_no_position_slot_is_released() {
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 broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_rebalance_cash_mode(RebalanceCashMode::SellThenBuy)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let mut portfolio = PortfolioState::new(20_000.0);
portfolio
.position_mut("000001.SZ")
.buy(prev_date, 1_000, 10.0);
portfolio
.position_mut("000002.SZ")
.buy(prev_date, 1_000, 10.0);
let decision = StrategyDecision {
order_intents: vec![
OrderIntent::TargetValue {
symbol: "000001.SZ".to_string(),
target_value: 0.0,
reason: "replace_exit".to_string(),
},
OrderIntent::TargetValue {
symbol: "000003.SZ".to_string(),
target_value: 9_000.0,
reason: "replace_entry".to_string(),
},
],
..StrategyDecision::default()
};
let report = broker
.execute(
date,
&mut portfolio,
&target_position_slot_test_data(true),
&decision,
)
.expect("failed-exit target batch execution");
assert_eq!(
BrokerSimulator::<ChinaAShareCostModel, ChinaEquityRuleHooks>::positive_position_count(
&portfolio
),
2
);
assert!(portfolio.position("000001.SZ").is_some());
assert!(portfolio.position("000003.SZ").is_none());
assert!(report.order_events.iter().any(|event| {
event.symbol == "000001.SZ"
&& event.side == OrderSide::Sell
&& event.status == OrderStatus::Canceled
}));
assert!(report.order_events.iter().any(|event| {
event.symbol == "000003.SZ"
&& event.side == OrderSide::Buy
&& event
.reason
.contains("target position slot unavailable after failed exit")
}));
}
#[test]
fn successful_target_exit_releases_position_slot_for_replacement_entry() {
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 broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_rebalance_cash_mode(RebalanceCashMode::SellThenBuy)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let mut portfolio = PortfolioState::new(20_000.0);
portfolio
.position_mut("000001.SZ")
.buy(prev_date, 1_000, 10.0);
portfolio
.position_mut("000002.SZ")
.buy(prev_date, 1_000, 10.0);
let decision = StrategyDecision {
order_intents: vec![
OrderIntent::TargetValue {
symbol: "000003.SZ".to_string(),
target_value: 9_000.0,
reason: "replace_entry".to_string(),
},
OrderIntent::TargetValue {
symbol: "000001.SZ".to_string(),
target_value: 0.0,
reason: "replace_exit".to_string(),
},
],
..StrategyDecision::default()
};
let report = broker
.execute(
date,
&mut portfolio,
&target_position_slot_test_data(false),
&decision,
)
.expect("successful-exit target batch execution");
assert_eq!(
BrokerSimulator::<ChinaAShareCostModel, ChinaEquityRuleHooks>::positive_position_count(
&portfolio
),
2
);
assert!(portfolio.position("000001.SZ").is_none());
assert!(
portfolio
.position("000003.SZ")
.is_some_and(|position| position.quantity > 0)
);
assert!(report.order_events.iter().all(|event| {
!event
.reason
.contains("target position slot unavailable after failed exit")
}));
}
#[test]
fn independent_target_entry_is_not_subject_to_replacement_slot_limit() {
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 broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let mut portfolio = PortfolioState::new(20_000.0);
portfolio
.position_mut("000001.SZ")
.buy(prev_date, 1_000, 10.0);
let decision = StrategyDecision {
order_intents: vec![OrderIntent::TargetValue {
symbol: "000003.SZ".to_string(),
target_value: 9_000.0,
reason: "independent_entry".to_string(),
}],
..StrategyDecision::default()
};
broker
.execute(
date,
&mut portfolio,
&target_position_slot_test_data(false),
&decision,
)
.expect("independent target entry");
assert_eq!(
BrokerSimulator::<ChinaAShareCostModel, ChinaEquityRuleHooks>::positive_position_count(
&portfolio
),
2
);
assert!(
portfolio
.position("000003.SZ")
.is_some_and(|position| position.quantity > 0)
);
}
#[test] #[test]
fn target_portfolio_smart_open_auction_uses_day_open_for_valuation() { fn target_portfolio_smart_open_auction_uses_day_open_for_valuation() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");