From 1272e427a17ff4780a281d2c2f16cc3769a87519 Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 10 Jul 2026 11:37:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=9B=AE=E6=A0=87=E7=BB=84?= =?UTF-8?q?=E5=90=88=E7=8E=B0=E9=87=91=E5=AE=89=E5=85=A8=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 142 ++++++++++++++++++++++++++------- 1 file changed, 114 insertions(+), 28 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 071c5d8..affa73e 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -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; } } - - if safety <= 0.0 { - break; + 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); + } } - 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::>(); + let snapshots = symbols + .iter() + .map(|symbol| { + let mut snapshot = limit_test_snapshot(); + snapshot.symbol = (*symbol).to_string(); + snapshot + }) + .collect::>(); + let candidates = symbols + .iter() + .map(|symbol| { + let mut candidate = limit_test_candidate(true, true); + candidate.symbol = (*symbol).to_string(); + candidate + }) + .collect::>(); + 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");