修正目标组合现金安全搜索

This commit is contained in:
boris
2026-07-10 11:37:12 +08:00
parent e396c895dc
commit 1272e427a1
+116 -30
View File
@@ -62,8 +62,6 @@ struct TargetConstraint {
symbol: String, symbol: String,
current_qty: u32, current_qty: u32,
desired_qty: u32, desired_qty: u32,
min_target_qty: u32,
max_target_qty: u32,
provisional_target_qty: u32, provisional_target_qty: u32,
price: f64, price: f64,
minimum_order_quantity: u32, minimum_order_quantity: u32,
@@ -2133,8 +2131,6 @@ where
symbol: symbol.clone(), symbol: symbol.clone(),
current_qty, current_qty,
desired_qty, desired_qty,
min_target_qty,
max_target_qty,
provisional_target_qty, provisional_target_qty,
price, price,
minimum_order_quantity, minimum_order_quantity,
@@ -2162,24 +2158,22 @@ where
let mut best_targets = targets.clone(); let mut best_targets = targets.clone();
let mut best_proportion_diff = f64::INFINITY; let mut best_proportion_diff = f64::INFINITY;
let initial_safety = 1.0; let mut best_safety = f64::NEG_INFINITY;
let mut safety = initial_safety; let mut record_candidate = |safety_value: f64| -> bool {
loop { let safety_value = safety_value.clamp(0.0, 1.0);
let mut candidate_targets = targets.clone(); let mut candidate_targets = targets.clone();
let mut buy_cash_out = 0.0; let mut buy_cash_out = 0.0;
for constraint in &buy_constraints { for constraint in &buy_constraints {
let scaled_desired_qty = ((constraint.desired_qty as f64) * safety).floor() as u32; let scaled_desired_qty =
let mut target_qty = self ((constraint.desired_qty as f64) * safety_value).floor() as u32;
let target_qty = self
.round_buy_quantity( .round_buy_quantity(
scaled_desired_qty, scaled_desired_qty,
constraint.minimum_order_quantity, constraint.minimum_order_quantity,
constraint.order_step_size, constraint.order_step_size,
) )
.clamp(constraint.min_target_qty, constraint.max_target_qty) .max(constraint.current_qty)
.max(constraint.current_qty); .min(constraint.provisional_target_qty);
if target_qty < constraint.current_qty {
target_qty = constraint.current_qty;
}
if target_qty > constraint.current_qty { if target_qty > constraint.current_qty {
buy_cash_out += self.estimated_buy_cash_out( buy_cash_out += self.estimated_buy_cash_out(
date, date,
@@ -2208,29 +2202,46 @@ where
0.0 0.0
}; };
if buy_cash_out <= projected_cash + 1e-6 { if buy_cash_out <= projected_cash + 1e-6 {
if proportion_diff <= best_proportion_diff + 1e-12 { if proportion_diff < best_proportion_diff - 1e-12
|| ((proportion_diff - best_proportion_diff).abs() <= 1e-12
&& safety_value > best_safety)
{
best_targets = candidate_targets; best_targets = candidate_targets;
best_proportion_diff = proportion_diff; best_proportion_diff = proportion_diff;
} else if best_proportion_diff.is_finite() { best_safety = safety_value;
break; }
return true;
}
false
};
if !record_candidate(1.0) && record_candidate(0.0) {
let mut low = 0.0;
let mut high = 1.0;
for _ in 0..24 {
let mid = (low + high) / 2.0;
if record_candidate(mid) {
low = mid;
} else {
high = mid;
}
}
let high_toward_zero = if high > 0.0 {
f64::from_bits(high.to_bits().saturating_sub(1))
} else {
high
};
for safety in [0.0, low, high_toward_zero, high, 1.0] {
if (0.0..=1.0).contains(&safety) {
record_candidate(safety);
}
} }
} }
if safety <= 0.0 { if best_safety.is_finite() && best_safety < 1.0 && diagnostics.len() < 16 {
break;
}
let step = (proportion_diff / 10.0).clamp(0.0001, 0.002);
let next_safety = (safety - step).max(0.0);
if (next_safety - safety).abs() < f64::EPSILON {
break;
}
safety = next_safety;
}
if safety < initial_safety && diagnostics.len() < 16 {
diagnostics.push(format!( diagnostics.push(format!(
"rebalance_safety_scaled final_safety={:.4} target_weight_sum={:.4} projected_cash={:.2}", "rebalance_safety_scaled final_safety={:.4} target_weight_sum={:.4} projected_cash={:.2}",
safety, target_weight_sum, projected_cash best_safety, target_weight_sum, projected_cash
)); ));
} }
@@ -7239,6 +7250,81 @@ mod tests {
); );
} }
#[test]
fn target_portfolio_smart_scales_buys_when_full_targets_exceed_cash_by_fees() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).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 broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let portfolio = PortfolioState::new(10_000.0);
let mut target_weights = BTreeMap::new();
target_weights.insert("000001.SZ".to_string(), 0.50);
target_weights.insert("000002.SZ".to_string(), 0.50);
let (target_quantities, diagnostics) = broker
.target_quantities(date, &portfolio, &data, &target_weights)
.expect("target quantities");
assert_eq!(target_quantities.get("000001.SZ").copied(), Some(400));
assert_eq!(target_quantities.get("000002.SZ").copied(), Some(400));
assert!(
diagnostics.iter().any(|line| line.contains("rebalance_safety_scaled")),
"{diagnostics:?}"
);
assert!(
diagnostics
.iter()
.any(|line| line.contains("rebalance_buy_reduced")
&& line.contains("provisional=500")
&& line.contains("final=400")),
"{diagnostics:?}"
);
}
#[test] #[test]
fn target_portfolio_smart_pre_open_cash_does_not_spend_same_batch_sell_proceeds() { fn target_portfolio_smart_pre_open_cash_does_not_spend_same_batch_sell_proceeds() {
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");