修正显式目标单批次撮合顺序
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(
|
fn value_order_sizing_price(
|
||||||
&self,
|
&self,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -786,7 +895,24 @@ where
|
|||||||
&mut report,
|
&mut report,
|
||||||
)?;
|
)?;
|
||||||
if !decision.order_intents.is_empty() {
|
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(
|
self.process_order_intent(
|
||||||
date,
|
date,
|
||||||
portfolio,
|
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]
|
#[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