修复调仓卖出失败后的持仓槽位溢出
This commit is contained in:
@@ -195,6 +195,7 @@ pub struct BrokerSimulator<C, R> {
|
||||
runtime_decision_date: Cell<Option<NaiveDate>>,
|
||||
runtime_order_created_date: Cell<Option<NaiveDate>>,
|
||||
runtime_decision_total_equity: Cell<Option<f64>>,
|
||||
runtime_target_position_limit: Cell<Option<usize>>,
|
||||
next_order_id: Cell<u64>,
|
||||
open_orders: RefCell<Vec<OpenOrder>>,
|
||||
}
|
||||
@@ -225,6 +226,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
runtime_decision_date: Cell::new(None),
|
||||
runtime_order_created_date: Cell::new(None),
|
||||
runtime_decision_total_equity: Cell::new(None),
|
||||
runtime_target_position_limit: Cell::new(None),
|
||||
next_order_id: Cell::new(1),
|
||||
open_orders: RefCell::new(Vec::new()),
|
||||
}
|
||||
@@ -259,6 +261,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
runtime_decision_date: Cell::new(None),
|
||||
runtime_order_created_date: Cell::new(None),
|
||||
runtime_decision_total_equity: Cell::new(None),
|
||||
runtime_target_position_limit: Cell::new(None),
|
||||
next_order_id: Cell::new(1),
|
||||
open_orders: RefCell::new(Vec::new()),
|
||||
}
|
||||
@@ -538,6 +541,11 @@ where
|
||||
target_value,
|
||||
..
|
||||
}
|
||||
| OrderIntent::TimedTargetValue {
|
||||
symbol,
|
||||
target_value,
|
||||
..
|
||||
}
|
||||
| OrderIntent::LimitTargetValue {
|
||||
symbol,
|
||||
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(
|
||||
&self,
|
||||
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 {
|
||||
self.process_order_intent(
|
||||
let result = self.process_order_intent(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
@@ -923,8 +1031,15 @@ where
|
||||
&mut global_execution_cursor,
|
||||
&mut commission_state,
|
||||
&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();
|
||||
return Ok(report);
|
||||
}
|
||||
@@ -4929,6 +5044,32 @@ where
|
||||
algo_request: Option<&AlgoExecutionRequest>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> 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 unavailable_reason = self
|
||||
.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 {
|
||||
let mut snapshot = limit_test_snapshot();
|
||||
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]
|
||||
fn target_portfolio_smart_open_auction_uses_day_open_for_valuation() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||
|
||||
Reference in New Issue
Block a user