修正显式目标单批次撮合顺序
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user