修复调仓卖出失败后的持仓槽位溢出
This commit is contained in:
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user