分离股票池精确预算比例与展示基点并持久化状态
This commit is contained in:
@@ -114,7 +114,7 @@ mod successor_protection_tests {
|
|||||||
broker.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
broker.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
||||||
pool_id: "pool".into(), generation: "latest".into(), symbol: new.into(),
|
pool_id: "pool".into(), generation: "latest".into(), symbol: new.into(),
|
||||||
signal_date: day(14), signal_at: day(14).and_hms_opt(13,0,0).unwrap(), execute_on: Some(day(15)),
|
signal_date: day(14), signal_at: day(14).and_hms_opt(13,0,0).unwrap(), execute_on: Some(day(15)),
|
||||||
target_value: 5000.into(), target_weight_bps: 10000, side: pool::OrderSide::Buy, max_positions: 1,
|
target_value: 5000.into(), target_weight_bps: 10000, target_weight_ratio:None, side: pool::OrderSide::Buy, max_positions: 1,
|
||||||
rule: std::sync::Arc::new(rule), members: std::sync::Arc::new(vec![pool::StockPoolMemberSpec {
|
rule: std::sync::Arc::new(rule), members: std::sync::Arc::new(vec![pool::StockPoolMemberSpec {
|
||||||
symbol: new.into(), requested_order: 0, recommendation_reason: String::new(),
|
symbol: new.into(), requested_order: 0, recommendation_reason: String::new(),
|
||||||
target_weight_bps: None, stop_loss: None, take_profit: None,
|
target_weight_bps: None, stop_loss: None, take_profit: None,
|
||||||
@@ -535,6 +535,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
.map_err(BacktestError::Execution)?;
|
.map_err(BacktestError::Execution)?;
|
||||||
constraints.pending_entry_symbols = execution_state.pending_symbols();
|
constraints.pending_entry_symbols = execution_state.pending_symbols();
|
||||||
constraints.prior_target_weights = execution_state.last_target_weights.clone();
|
constraints.prior_target_weights = execution_state.last_target_weights.clone();
|
||||||
|
constraints.prior_target_weight_ratios = execution_state.last_target_weight_ratios.clone();
|
||||||
constraints.position_action_bases = execution_state.position_action_bases_for(&contract.generation);
|
constraints.position_action_bases = execution_state.position_action_bases_for(&contract.generation);
|
||||||
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
|
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
|
||||||
let account = pool::AccountSnapshot {
|
let account = pool::AccountSnapshot {
|
||||||
@@ -671,7 +672,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
self.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
self.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
||||||
pool_id:contract.pool_id.clone(), generation:contract.generation.clone(), symbol:row.symbol.clone(),
|
pool_id:contract.pool_id.clone(), generation:contract.generation.clone(), symbol:row.symbol.clone(),
|
||||||
signal_date:contract.signal_date, signal_at:at, execute_on:reference.execute_on,
|
signal_date:contract.signal_date, signal_at:at, execute_on:reference.execute_on,
|
||||||
target_value:row.target_value, target_weight_bps:row.target_weight_bps, side,
|
target_value:row.target_value, target_weight_bps:row.target_weight_bps, target_weight_ratio:plan.target_weight_ratios.get(&row.symbol).copied(), side,
|
||||||
max_positions, rule:std::sync::Arc::clone(&deferred.0), members:std::sync::Arc::clone(&deferred.1),
|
max_positions, rule:std::sync::Arc::clone(&deferred.0), members:std::sync::Arc::clone(&deferred.1),
|
||||||
reason:row.source_intent.clone().unwrap_or_else(||"stock_pool_target".into()),
|
reason:row.source_intent.clone().unwrap_or_else(||"stock_pool_target".into()),
|
||||||
});
|
});
|
||||||
@@ -825,7 +826,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
let state = portfolio.stock_pool_execution_state(&target.pool_id)
|
let state = portfolio.stock_pool_execution_state(&target.pool_id)
|
||||||
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?
|
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?
|
||||||
.record_targets(target.signal_date, &target.generation, [crate::stock_pool_state::StockPoolGoalObservation {
|
.record_targets(target.signal_date, &target.generation, [crate::stock_pool_state::StockPoolGoalObservation {
|
||||||
symbol:&target.symbol, target_weight_bps:target.target_weight_bps, target_value:target.target_value,
|
symbol:&target.symbol, target_weight_bps:target.target_weight_bps, target_weight_ratio:target.target_weight_ratio, target_value:target.target_value,
|
||||||
current_quantity:before_quantity.into(), target_quantity:goal_quantity.into(), status,
|
current_quantity:before_quantity.into(), target_quantity:goal_quantity.into(), status,
|
||||||
}]).map_err(BacktestError::Execution)?
|
}]).map_err(BacktestError::Execution)?
|
||||||
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?;
|
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pub(crate) struct DeferredEtfTarget {
|
|||||||
pub execute_on: Option<NaiveDate>,
|
pub execute_on: Option<NaiveDate>,
|
||||||
pub target_value: Decimal,
|
pub target_value: Decimal,
|
||||||
pub target_weight_bps: i32,
|
pub target_weight_bps: i32,
|
||||||
|
pub target_weight_ratio: Option<Decimal>,
|
||||||
pub side: crate::stock_pool_execution::OrderSide,
|
pub side: crate::stock_pool_execution::OrderSide,
|
||||||
pub max_positions: usize,
|
pub max_positions: usize,
|
||||||
pub rule: std::sync::Arc<crate::stock_pool_execution::StockPoolExecutionRule>,
|
pub rule: std::sync::Arc<crate::stock_pool_execution::StockPoolExecutionRule>,
|
||||||
@@ -96,7 +97,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
fn target(symbol:&str,side:crate::stock_pool_execution::OrderSide,generation:&str)->DeferredEtfTarget {
|
fn target(symbol:&str,side:crate::stock_pool_execution::OrderSide,generation:&str)->DeferredEtfTarget {
|
||||||
let date=NaiveDate::from_ymd_opt(2026,1,2).unwrap();
|
let date=NaiveDate::from_ymd_opt(2026,1,2).unwrap();
|
||||||
DeferredEtfTarget {pool_id:"pool".into(),generation:generation.into(),symbol:symbol.into(),signal_date:date,signal_at:date.and_hms_opt(13,0,0).unwrap(),execute_on:NaiveDate::from_ymd_opt(2026,1,5),target_value:1000.into(),target_weight_bps:5000,side,max_positions:2,rule:Default::default(),members:std::sync::Arc::new(vec![]),reason:"fixture".into()}
|
DeferredEtfTarget {pool_id:"pool".into(),generation:generation.into(),symbol:symbol.into(),signal_date:date,signal_at:date.and_hms_opt(13,0,0).unwrap(),execute_on:NaiveDate::from_ymd_opt(2026,1,5),target_value:1000.into(),target_weight_bps:5000,target_weight_ratio:None,side,max_positions:2,rule:Default::default(),members:std::sync::Arc::new(vec![]),reason:"fixture".into()}
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn latest_generation_overwrites_pending_targets_and_preserves_candidate_order() {
|
fn latest_generation_overwrites_pending_targets_and_preserves_candidate_order() {
|
||||||
|
|||||||
@@ -484,6 +484,8 @@ pub struct StockPoolDecisionConstraints {
|
|||||||
pub execution_date: Option<NaiveDate>,
|
pub execution_date: Option<NaiveDate>,
|
||||||
pub frozen_positions: BTreeMap<String, FrozenStockPoolPosition>,
|
pub frozen_positions: BTreeMap<String, FrozenStockPoolPosition>,
|
||||||
pub prior_target_weights: BTreeMap<String, i32>,
|
pub prior_target_weights: BTreeMap<String, i32>,
|
||||||
|
/// Sizing ratios from prior plans; integer bps are display/legacy only.
|
||||||
|
pub prior_target_weight_ratios: BTreeMap<String, Decimal>,
|
||||||
pub pending_entry_symbols: BTreeSet<String>,
|
pub pending_entry_symbols: BTreeSet<String>,
|
||||||
pub next_day_outside_exit_symbols: BTreeSet<String>,
|
pub next_day_outside_exit_symbols: BTreeSet<String>,
|
||||||
pub market_timing_policy: Option<crate::stock_pool_index_policy::MarketTimingPolicy>,
|
pub market_timing_policy: Option<crate::stock_pool_index_policy::MarketTimingPolicy>,
|
||||||
@@ -530,6 +532,8 @@ pub struct StockPoolPlanRow {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct StockPoolPlan {
|
pub struct StockPoolPlan {
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub target_weight_ratios: BTreeMap<String, Decimal>,
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub position_action_bases: BTreeMap<String, Decimal>,
|
pub position_action_bases: BTreeMap<String, Decimal>,
|
||||||
pub market_timing: Option<crate::stock_pool_index_policy::MarketTimingEvaluation>,
|
pub market_timing: Option<crate::stock_pool_index_policy::MarketTimingEvaluation>,
|
||||||
@@ -1096,6 +1100,11 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
target_count,
|
target_count,
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
|
let target_weight_ratios = frozen::sizing_ratios(
|
||||||
|
&original_final_symbols, &active_symbols, &explicit_weights,
|
||||||
|
constraints, reserved_protected_slots, &weights,
|
||||||
|
)?;
|
||||||
|
let sizing_ratio = |symbol: &str| target_weight_ratios.get(symbol).copied().unwrap_or(Decimal::ZERO);
|
||||||
for symbol in &rebuy_exclusions {
|
for symbol in &rebuy_exclusions {
|
||||||
if original_final_symbols.contains(symbol) || current.contains_key(symbol) {
|
if original_final_symbols.contains(symbol) || current.contains_key(symbol) {
|
||||||
weights.insert(symbol.clone(), 0);
|
weights.insert(symbol.clone(), 0);
|
||||||
@@ -1235,8 +1244,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
.map(|symbol| {
|
.map(|symbol| {
|
||||||
let current_value = current[symbol].0
|
let current_value = current[symbol].0
|
||||||
* frozen::valuation(symbol, "e_map, &constraints.frozen_positions)?;
|
* frozen::valuation(symbol, "e_map, &constraints.frozen_positions)?;
|
||||||
let desired =
|
let desired = budget * sizing_ratio(symbol);
|
||||||
budget * Decimal::from(*weights.get(symbol).unwrap_or(&0)) / Decimal::from(10_000);
|
|
||||||
if constraints.frozen_positions.contains_key(symbol) {
|
if constraints.frozen_positions.contains_key(symbol) {
|
||||||
return Ok((symbol.clone(), desired));
|
return Ok((symbol.clone(), desired));
|
||||||
}
|
}
|
||||||
@@ -1277,7 +1285,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
let free_desired = weights
|
let free_desired = weights
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(symbol, _)| !protected_values.contains_key(*symbol))
|
.filter(|(symbol, _)| !protected_values.contains_key(*symbol))
|
||||||
.map(|(_, weight)| budget * Decimal::from(*weight) / Decimal::from(10_000))
|
.map(|(symbol, _)| budget * sizing_ratio(symbol))
|
||||||
.sum::<Decimal>();
|
.sum::<Decimal>();
|
||||||
let free_budget = (budget * Decimal::from(requested_weight_total) / Decimal::from(10_000)
|
let free_budget = (budget * Decimal::from(requested_weight_total) / Decimal::from(10_000)
|
||||||
- protected_values.values().copied().sum::<Decimal>())
|
- protected_values.values().copied().sum::<Decimal>())
|
||||||
@@ -1296,7 +1304,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
rows.push(StockPoolPlanRow {
|
rows.push(StockPoolPlanRow {
|
||||||
symbol: symbol.clone(),
|
symbol: symbol.clone(),
|
||||||
target_weight_bps: weight,
|
target_weight_bps: weight,
|
||||||
target_value: budget * Decimal::from(weight) / Decimal::from(10_000),
|
target_value: budget * sizing_ratio(symbol),
|
||||||
current_quantity: quantity,
|
current_quantity: quantity,
|
||||||
target_quantity: quantity,
|
target_quantity: quantity,
|
||||||
delta_quantity: Decimal::ZERO,
|
delta_quantity: Decimal::ZERO,
|
||||||
@@ -1570,7 +1578,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
let target_value = protected_values
|
let target_value = protected_values
|
||||||
.get(symbol)
|
.get(symbol)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(budget * Decimal::from(weight) / Decimal::from(10_000) * free_scale)
|
.unwrap_or(if weight == 0 { Decimal::ZERO } else { budget * sizing_ratio(symbol) * free_scale })
|
||||||
.round_dp_with_strategy(2, RoundingStrategy::MidpointNearestEven);
|
.round_dp_with_strategy(2, RoundingStrategy::MidpointNearestEven);
|
||||||
let sizing_price = if target_value >= current_quantity * quote.last_price {
|
let sizing_price = if target_value >= current_quantity * quote.last_price {
|
||||||
quote.buy_sizing_price.unwrap_or(quote.last_price)
|
quote.buy_sizing_price.unwrap_or(quote.last_price)
|
||||||
@@ -1995,6 +2003,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
.map(|row| (row.symbol.clone(), constraints.position_action_bases.get(&row.symbol).copied().unwrap_or(row.current_quantity)))
|
.map(|row| (row.symbol.clone(), constraints.position_action_bases.get(&row.symbol).copied().unwrap_or(row.current_quantity)))
|
||||||
.collect();
|
.collect();
|
||||||
Ok(StockPoolPlan {
|
Ok(StockPoolPlan {
|
||||||
|
target_weight_ratios,
|
||||||
position_action_bases,
|
position_action_bases,
|
||||||
market_timing,
|
market_timing,
|
||||||
rows,
|
rows,
|
||||||
|
|||||||
@@ -1,4 +1,37 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equal_thirty_seats_use_full_precision_at_a_board_lot_boundary() {
|
||||||
|
for (equity, price, held) in [("999377.147617", "3.070307", 2000), ("995624.8819", "6.420642", 900)] {
|
||||||
|
let pool = members(30);
|
||||||
|
let mut market = quotes(30);
|
||||||
|
let last = market.last_mut().unwrap();
|
||||||
|
last.last_price = price.parse().unwrap();
|
||||||
|
last.buy_sizing_price = Some(last.last_price);
|
||||||
|
let positions = vec![Position { symbol: last.symbol.clone(), quantity: held.into(), closable_quantity: held.into(), average_cost: last.last_price }];
|
||||||
|
let mut selection = selection(30, 30);
|
||||||
|
let constraints = StockPoolDecisionConstraints { target_holding_count: Some(30), reserve_cash_slots: 1, ..Default::default() };
|
||||||
|
let total: Decimal = equity.parse().unwrap();
|
||||||
|
let account = AccountSnapshot { total_equity: total, cash: total - positions[0].quantity * last.last_price, frozen_cash: Decimal::ZERO };
|
||||||
|
let build = |selection: &StockPoolSelection| build_stock_pool_target_plan_with_constraints(selection, &pool, &StockPoolExecutionRule::default(), &account, &positions, &market,
|
||||||
|
2000, Decimal::ZERO, "hold", "full_rebalance", &constraints, "exact-shares", Decimal::new(2,4), Decimal::ZERO, Decimal::ZERO).unwrap();
|
||||||
|
let plan = build(&selection);
|
||||||
|
let target = plan.rows.iter().find(|row| row.symbol == positions[0].symbol).unwrap();
|
||||||
|
assert_eq!(target.delta_quantity, Decimal::from(100), "{equity}: {target:?}");
|
||||||
|
let expected = (total * Decimal::new(2,1) / Decimal::from(31)).round_dp_with_strategy(2, RoundingStrategy::MidpointNearestEven);
|
||||||
|
assert!(plan.rows.iter().all(|row| row.target_value == expected));
|
||||||
|
selection.final_symbols.reverse();
|
||||||
|
selection.requested_symbols.reverse();
|
||||||
|
assert_eq!(build(&selection).rows.iter().find(|row| row.symbol == positions[0].symbol).unwrap().delta_quantity, Decimal::from(100));
|
||||||
|
let date = selection.trade_date;
|
||||||
|
let state = crate::stock_pool_state::StockPoolExecutionState::default().observe(date,date,&[date],&pool,&positions).unwrap().record_plan(date,"exact-shares",&plan).unwrap();
|
||||||
|
assert_eq!(state.schema_version, 2);
|
||||||
|
assert_eq!(state.last_target_weight_ratios[&positions[0].symbol], Decimal::ONE / Decimal::from(30));
|
||||||
|
let restored: crate::stock_pool_state::StockPoolExecutionState = serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap();
|
||||||
|
restored.validate().unwrap();
|
||||||
|
assert_eq!(state, restored);
|
||||||
|
}
|
||||||
|
}
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn symbol(index: usize) -> String {
|
fn symbol(index: usize) -> String {
|
||||||
|
|||||||
@@ -26,9 +26,58 @@ pub(super) fn validate(
|
|||||||
{
|
{
|
||||||
return Err("stock_pool_prior_target_weights_invalid".into());
|
return Err("stock_pool_prior_target_weights_invalid".into());
|
||||||
}
|
}
|
||||||
|
if constraints.prior_target_weight_ratios.iter().any(|(symbol, ratio)| {
|
||||||
|
normalize_stock_symbol(symbol).as_ref() != Some(symbol) || *ratio < Decimal::ZERO || *ratio > Decimal::ONE
|
||||||
|
}) { return Err("stock_pool_prior_target_weight_ratios_invalid".into()); }
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Never size cash with the rounded display bps. Paused holdings keep the
|
||||||
|
/// actually recorded prior ratio. Legacy bps are preserved, not guessed as 1/N.
|
||||||
|
pub(super) fn sizing_ratios(
|
||||||
|
original: &[String], active: &[String], explicit: &BTreeMap<String, i32>,
|
||||||
|
constraints: &StockPoolDecisionConstraints, reserved_slots: usize,
|
||||||
|
display: &BTreeMap<String, i32>,
|
||||||
|
) -> Result<BTreeMap<String, Decimal>, String> {
|
||||||
|
if !explicit.is_empty() {
|
||||||
|
return Ok(display.iter().map(|(symbol, weight)| (symbol.clone(), Decimal::from(*weight) / Decimal::from(10_000))).collect());
|
||||||
|
}
|
||||||
|
if constraints.frozen_positions.is_empty() {
|
||||||
|
let count = active.len() + reserved_slots;
|
||||||
|
let share = if count == 0 { Decimal::ZERO } else { Decimal::ONE / Decimal::from(count as u64) };
|
||||||
|
return Ok(active.iter().map(|symbol| (symbol.clone(), share)).collect());
|
||||||
|
}
|
||||||
|
let count = original.len() + reserved_slots;
|
||||||
|
let base = if count == 0 { Decimal::ZERO } else { Decimal::ONE / Decimal::from(count as u64) };
|
||||||
|
let mut result = BTreeMap::new();
|
||||||
|
for symbol in constraints.frozen_positions.keys() {
|
||||||
|
let ratio = constraints.prior_target_weight_ratios.get(symbol).copied()
|
||||||
|
.or_else(|| constraints.prior_target_weights.get(symbol).map(|bps| Decimal::from(*bps) / Decimal::from(10_000)))
|
||||||
|
.unwrap_or(base);
|
||||||
|
result.insert(symbol.clone(), ratio);
|
||||||
|
}
|
||||||
|
let frozen_total = result.values().copied().sum::<Decimal>();
|
||||||
|
// Only allow last-digit residue from Decimal division, never a meaningful
|
||||||
|
// over-allocation. All actual cash/fee checks remain downstream.
|
||||||
|
if frozen_total > Decimal::ONE + Decimal::new(1, 24) {
|
||||||
|
return Err("stock_pool_frozen_position_ratios_exceed_budget".into());
|
||||||
|
}
|
||||||
|
let free_original = original.iter().filter(|symbol| !result.contains_key(*symbol)).collect::<BTreeSet<_>>();
|
||||||
|
let free_total = (base * Decimal::from(free_original.len() as u64)).min((Decimal::ONE - frozen_total).max(Decimal::ZERO));
|
||||||
|
let share = if free_original.is_empty() { Decimal::ZERO } else { free_total / Decimal::from(free_original.len() as u64) };
|
||||||
|
let free = active.iter().filter(|symbol| !constraints.frozen_positions.contains_key(*symbol)).collect::<Vec<_>>();
|
||||||
|
let promoted = free.iter().filter(|symbol| !free_original.contains(**symbol)).copied().collect::<Vec<_>>();
|
||||||
|
for symbol in &free { result.insert((*symbol).clone(), if free_original.contains(*symbol) { share } else { Decimal::ZERO }); }
|
||||||
|
let assigned = free.iter().map(|symbol| result[*symbol]).sum::<Decimal>();
|
||||||
|
let missing = (free_total - assigned).max(Decimal::ZERO);
|
||||||
|
let recipients = if promoted.is_empty() { &free } else { &promoted };
|
||||||
|
if !recipients.is_empty() {
|
||||||
|
let addition = missing / Decimal::from(recipients.len() as u64);
|
||||||
|
for symbol in recipients { *result.entry((*symbol).clone()).or_default() += addition; }
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn valuation(
|
pub(super) fn valuation(
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
quotes: &HashMap<String, &MarketSnapshot>,
|
quotes: &HashMap<String, &MarketSnapshot>,
|
||||||
@@ -42,6 +91,48 @@ pub(super) fn valuation(
|
|||||||
.ok_or_else(|| format!("{symbol} confirmed holding valuation missing"))
|
.ok_or_else(|| format!("{symbol} confirmed holding valuation missing"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod ratio_tests {
|
||||||
|
use super::*;
|
||||||
|
fn configuration() -> (Vec<String>, StockPoolDecisionConstraints, BTreeMap<String,i32>) {
|
||||||
|
let symbols = vec!["000001.SZ".into(),"000002.SZ".into(),"000003.SZ".into()];
|
||||||
|
let paused = FrozenStockPoolPosition { trade_date: NaiveDate::from_ymd_opt(2026,9,3).unwrap(), reason:"paused".into(), valuation_price:Decimal::from(10) };
|
||||||
|
let constraints = StockPoolDecisionConstraints { frozen_positions:BTreeMap::from([(symbols[0].clone(),paused)]),
|
||||||
|
prior_target_weights:BTreeMap::from([(symbols[0].clone(),3334)]), ..Default::default() };
|
||||||
|
let display = BTreeMap::from([(symbols[0].clone(),3334),(symbols[1].clone(),3333),(symbols[2].clone(),3333)]);
|
||||||
|
(symbols,constraints,display)
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn precise_paused_budget_survives_replacement_and_zero_targets() {
|
||||||
|
let (symbols,mut constraints,display)=configuration();
|
||||||
|
let third=Decimal::ONE/Decimal::from(3);
|
||||||
|
constraints.prior_target_weight_ratios.insert(symbols[0].clone(),third);
|
||||||
|
let mut active=symbols.clone();active[2]="000004.SZ".into();
|
||||||
|
let ratios=sizing_ratios(&symbols,&active,&BTreeMap::new(),&constraints,0,&display).unwrap();
|
||||||
|
assert_eq!(ratios[&symbols[0]],third);
|
||||||
|
assert_eq!(ratios[&symbols[1]],third);
|
||||||
|
assert!((ratios["000004.SZ"]-third).abs()<Decimal::new(1,24));
|
||||||
|
assert!(!ratios.contains_key(&symbols[2]));
|
||||||
|
assert!((ratios.values().copied().sum::<Decimal>()-Decimal::ONE).abs()<Decimal::new(1,24));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn legacy_paused_and_explicit_partial_budgets_are_not_reinterpreted() {
|
||||||
|
let (symbols,constraints,display)=configuration();
|
||||||
|
let ratios=sizing_ratios(&symbols,&symbols,&BTreeMap::new(),&constraints,0,&display).unwrap();
|
||||||
|
assert_eq!(ratios[&symbols[0]],Decimal::new(3334,4));
|
||||||
|
assert!((ratios[&symbols[1]]-Decimal::new(3333,4)).abs()<Decimal::new(1,24));
|
||||||
|
let partial=BTreeMap::from([(symbols[0].clone(),2000),(symbols[1].clone(),0)]);
|
||||||
|
assert_eq!(sizing_ratios(&symbols,&symbols,&partial,&constraints,0,&partial).unwrap(),
|
||||||
|
BTreeMap::from([(symbols[0].clone(),Decimal::new(2,1)),(symbols[1].clone(),Decimal::ZERO)]));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn precision_is_checked_before_frozen_budget_is_allocated() {
|
||||||
|
let (symbols,mut constraints,display)=configuration();
|
||||||
|
constraints.prior_target_weight_ratios.insert(symbols[0].clone(),Decimal::new(1001,3));
|
||||||
|
assert!(sizing_ratios(&symbols,&symbols,&BTreeMap::new(),&constraints,0,&display).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn weights(
|
pub(super) fn weights(
|
||||||
original: &[String],
|
original: &[String],
|
||||||
active: &[String],
|
active: &[String],
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ pub struct StockPoolExecutionState {
|
|||||||
pub entries: BTreeMap<String, StockPoolEntryProgress>,
|
pub entries: BTreeMap<String, StockPoolEntryProgress>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_target_weights: BTreeMap<String, i32>,
|
pub last_target_weights: BTreeMap<String, i32>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub last_target_weight_ratios: BTreeMap<String, Decimal>,
|
||||||
/// First signal excluding an actually held member; not an acquisition date.
|
/// First signal excluding an actually held member; not an acquisition date.
|
||||||
pub removed_since: BTreeMap<String, NaiveDate>,
|
pub removed_since: BTreeMap<String, NaiveDate>,
|
||||||
/// Signal progress, not a fill or holding-period fact. Kept across retries
|
/// Signal progress, not a fill or holding-period fact. Kept across retries
|
||||||
@@ -51,6 +53,7 @@ pub struct StockPoolExecutionState {
|
|||||||
pub struct StockPoolGoalObservation<'a> {
|
pub struct StockPoolGoalObservation<'a> {
|
||||||
pub symbol: &'a str,
|
pub symbol: &'a str,
|
||||||
pub target_weight_bps: i32,
|
pub target_weight_bps: i32,
|
||||||
|
pub target_weight_ratio: Option<Decimal>,
|
||||||
pub target_value: Decimal,
|
pub target_value: Decimal,
|
||||||
pub current_quantity: Decimal,
|
pub current_quantity: Decimal,
|
||||||
pub target_quantity: Decimal,
|
pub target_quantity: Decimal,
|
||||||
@@ -64,6 +67,7 @@ impl Default for StockPoolExecutionState {
|
|||||||
last_execution_date: None,
|
last_execution_date: None,
|
||||||
entries: BTreeMap::new(),
|
entries: BTreeMap::new(),
|
||||||
last_target_weights: BTreeMap::new(),
|
last_target_weights: BTreeMap::new(),
|
||||||
|
last_target_weight_ratios: BTreeMap::new(),
|
||||||
removed_since: BTreeMap::new(),
|
removed_since: BTreeMap::new(),
|
||||||
position_action_bases: BTreeMap::new(),
|
position_action_bases: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
@@ -72,7 +76,8 @@ impl Default for StockPoolExecutionState {
|
|||||||
|
|
||||||
impl StockPoolExecutionState {
|
impl StockPoolExecutionState {
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
if self.schema_version != 1
|
if !matches!(self.schema_version, 1 | 2)
|
||||||
|
|| (self.schema_version == 1 && !self.last_target_weight_ratios.is_empty())
|
||||||
|| self.entries.len() > 10000
|
|| self.entries.len() > 10000
|
||||||
|| self.removed_since.len() > 10000
|
|| self.removed_since.len() > 10000
|
||||||
|| self.position_action_bases.len() > 10000
|
|| self.position_action_bases.len() > 10000
|
||||||
@@ -84,6 +89,7 @@ impl StockPoolExecutionState {
|
|||||||
.keys()
|
.keys()
|
||||||
.chain(self.removed_since.keys())
|
.chain(self.removed_since.keys())
|
||||||
.chain(self.last_target_weights.keys())
|
.chain(self.last_target_weights.keys())
|
||||||
|
.chain(self.last_target_weight_ratios.keys())
|
||||||
.chain(self.position_action_bases.keys())
|
.chain(self.position_action_bases.keys())
|
||||||
{
|
{
|
||||||
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
|
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
|
||||||
@@ -98,6 +104,9 @@ impl StockPoolExecutionState {
|
|||||||
{
|
{
|
||||||
return Err("stock_pool_execution_state_invalid_weights".into());
|
return Err("stock_pool_execution_state_invalid_weights".into());
|
||||||
}
|
}
|
||||||
|
if self.last_target_weight_ratios.len() > 10000 || self.last_target_weight_ratios.iter().any(|(symbol, ratio)| {
|
||||||
|
*ratio < Decimal::ZERO || *ratio > Decimal::ONE || !self.last_target_weights.contains_key(symbol)
|
||||||
|
}) { return Err("stock_pool_execution_state_invalid_weight_ratios".into()); }
|
||||||
if self.entries.values().any(|entry| {
|
if self.entries.values().any(|entry| {
|
||||||
entry.latest_target_value < Decimal::ZERO
|
entry.latest_target_value < Decimal::ZERO
|
||||||
|| entry.completion_quantity.is_some_and(|quantity| quantity <= Decimal::ZERO)
|
|| entry.completion_quantity.is_some_and(|quantity| quantity <= Decimal::ZERO)
|
||||||
@@ -159,6 +168,7 @@ impl StockPoolExecutionState {
|
|||||||
});
|
});
|
||||||
next.last_target_weights
|
next.last_target_weights
|
||||||
.retain(|symbol, _| members.contains(symbol) || held.contains(symbol));
|
.retain(|symbol, _| members.contains(symbol) || held.contains(symbol));
|
||||||
|
next.last_target_weight_ratios.retain(|symbol, _| members.contains(symbol) || held.contains(symbol));
|
||||||
for (symbol, entry) in &mut next.entries {
|
for (symbol, entry) in &mut next.entries {
|
||||||
entry.observed_holding |= held.contains(symbol);
|
entry.observed_holding |= held.contains(symbol);
|
||||||
if entry.pending
|
if entry.pending
|
||||||
@@ -210,6 +220,7 @@ impl StockPoolExecutionState {
|
|||||||
plan.rows.iter().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).map(|row| StockPoolGoalObservation {
|
plan.rows.iter().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).map(|row| StockPoolGoalObservation {
|
||||||
symbol: &row.symbol,
|
symbol: &row.symbol,
|
||||||
target_weight_bps: row.target_weight_bps,
|
target_weight_bps: row.target_weight_bps,
|
||||||
|
target_weight_ratio: plan.target_weight_ratios.get(&row.symbol).copied(),
|
||||||
target_value: row.target_value,
|
target_value: row.target_value,
|
||||||
current_quantity: row.current_quantity,
|
current_quantity: row.current_quantity,
|
||||||
target_quantity: row.target_quantity,
|
target_quantity: row.target_quantity,
|
||||||
@@ -300,6 +311,12 @@ impl StockPoolExecutionState {
|
|||||||
if row.target_weight_bps > 0 {
|
if row.target_weight_bps > 0 {
|
||||||
next.last_target_weights
|
next.last_target_weights
|
||||||
.insert(row.symbol.into(), row.target_weight_bps);
|
.insert(row.symbol.into(), row.target_weight_bps);
|
||||||
|
if let Some(ratio) = row.target_weight_ratio {
|
||||||
|
next.schema_version = 2;
|
||||||
|
next.last_target_weight_ratios.insert(row.symbol.into(), ratio);
|
||||||
|
} else {
|
||||||
|
next.last_target_weight_ratios.remove(row.symbol);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let eligible = row.target_weight_bps > 0 && row.target_value > Decimal::ZERO;
|
let eligible = row.target_weight_bps > 0 && row.target_value > Decimal::ZERO;
|
||||||
let completion_quantity = (row.status == "READY"
|
let completion_quantity = (row.status == "READY"
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 股票池等权预算精度
|
||||||
|
|
||||||
|
此前按10000整数基点分配等权,然后反算资金。30只股票的333/334基点并不等于1/30,在临界整手处会漏补仓。修复将 `target_weight_bps` 保留为展示/旧数据合同,新增独立 `target_weight_ratios` 计算预算。
|
||||||
|
|
||||||
|
停牌持仓优先保留已记录的高精度比例;旧状态只有整数基点时保留已证明的旧预算,不反猜精确1/N。退出、候补、保护席位、指数仓位、资金预留和显式部分权重保留原规则。实际下单数量仍经过资金/费用、整手、T+1及风控检查。
|
||||||
|
|
||||||
|
执行状态新增 `last_target_weight_ratios`,首次记录精确比例升级schema2。旧schema1可读但不得携带新比例字段;旧消费者应拒绝新状态,回滚不能删除或降精度重写状态。回测、Paper、Live及Strategy Runtime都必须共同消费该比例,ETF顺延目标也携带相同比例。
|
||||||
|
|
||||||
|
新增临界100股补仓、调序不改变等权金额、停牌/候补、显式部分预算、状态序列化回读回归。当前为候选:本机Rust语法检查通过,类型/运行测试受Xcode许可阻断,转177验证;未通过Linux测试前不得发布。
|
||||||
Reference in New Issue
Block a user