修正目标组合现金安全搜索
This commit is contained in:
+116
-30
@@ -62,8 +62,6 @@ struct TargetConstraint {
|
||||
symbol: String,
|
||||
current_qty: u32,
|
||||
desired_qty: u32,
|
||||
min_target_qty: u32,
|
||||
max_target_qty: u32,
|
||||
provisional_target_qty: u32,
|
||||
price: f64,
|
||||
minimum_order_quantity: u32,
|
||||
@@ -2133,8 +2131,6 @@ where
|
||||
symbol: symbol.clone(),
|
||||
current_qty,
|
||||
desired_qty,
|
||||
min_target_qty,
|
||||
max_target_qty,
|
||||
provisional_target_qty,
|
||||
price,
|
||||
minimum_order_quantity,
|
||||
@@ -2162,24 +2158,22 @@ where
|
||||
|
||||
let mut best_targets = targets.clone();
|
||||
let mut best_proportion_diff = f64::INFINITY;
|
||||
let initial_safety = 1.0;
|
||||
let mut safety = initial_safety;
|
||||
loop {
|
||||
let mut best_safety = f64::NEG_INFINITY;
|
||||
let mut record_candidate = |safety_value: f64| -> bool {
|
||||
let safety_value = safety_value.clamp(0.0, 1.0);
|
||||
let mut candidate_targets = targets.clone();
|
||||
let mut buy_cash_out = 0.0;
|
||||
for constraint in &buy_constraints {
|
||||
let scaled_desired_qty = ((constraint.desired_qty as f64) * safety).floor() as u32;
|
||||
let mut target_qty = self
|
||||
let scaled_desired_qty =
|
||||
((constraint.desired_qty as f64) * safety_value).floor() as u32;
|
||||
let target_qty = self
|
||||
.round_buy_quantity(
|
||||
scaled_desired_qty,
|
||||
constraint.minimum_order_quantity,
|
||||
constraint.order_step_size,
|
||||
)
|
||||
.clamp(constraint.min_target_qty, constraint.max_target_qty)
|
||||
.max(constraint.current_qty);
|
||||
if target_qty < constraint.current_qty {
|
||||
target_qty = constraint.current_qty;
|
||||
}
|
||||
.max(constraint.current_qty)
|
||||
.min(constraint.provisional_target_qty);
|
||||
if target_qty > constraint.current_qty {
|
||||
buy_cash_out += self.estimated_buy_cash_out(
|
||||
date,
|
||||
@@ -2208,29 +2202,46 @@ where
|
||||
0.0
|
||||
};
|
||||
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_proportion_diff = proportion_diff;
|
||||
} else if best_proportion_diff.is_finite() {
|
||||
break;
|
||||
best_safety = safety_value;
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
if best_safety.is_finite() && best_safety < 1.0 && diagnostics.len() < 16 {
|
||||
diagnostics.push(format!(
|
||||
"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]
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user