Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7485d4ceb9 | |||
| 4a349ca3c7 | |||
| 98732bbc46 | |||
| 0ac07ac0f1 | |||
| 689f93f1f1 | |||
| 4d731f9c14 | |||
| 175b319d47 | |||
| 5924e43f3d |
@@ -8737,6 +8737,10 @@ where
|
||||
| MatchingType::Twap
|
||||
) || (self.matching_type == MatchingType::CurrentBarClose
|
||||
&& self.intraday_execution_start_time.is_some())
|
||||
|| (self.matching_type == MatchingType::NextBarOpen
|
||||
&& self.intraday_execution_start_time.is_some()
|
||||
&& self.volume_limit
|
||||
&& self.volume_capacity_mode == VolumeCapacityMode::ExecutionObservation)
|
||||
}
|
||||
|
||||
pub(crate) fn drives_resting_quote_clock(&self) -> bool {
|
||||
@@ -10349,6 +10353,28 @@ mod tests {
|
||||
assert!(!audit_a[0].passed); assert!(audit_b[0].passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_open_with_observations_uses_opening_volume_not_daily_totals_or_future_quotes() {
|
||||
let market=limit_test_snapshot();
|
||||
let date=market.date;
|
||||
let open=NaiveTime::from_hms_opt(9,30,0).unwrap();
|
||||
let quote=|time:NaiveTime,volume| IntradayExecutionQuote { observation_kind:Default::default(),date,
|
||||
symbol:"000001.SZ".into(),timestamp:date.and_time(time),last_price:market.open,
|
||||
bid1:0.,ask1:0.,bid1_volume:0,ask1_volume:0,volume_delta:volume,amount_delta:market.open*volume as f64,trading_phase:None };
|
||||
let quotes=vec![quote(open,400),quote(NaiveTime::from_hms_opt(9,31,0).unwrap(),1_000_000)];
|
||||
let data=DataSet::from_components_with_actions_and_quotes(vec![limit_test_instrument()],vec![market],vec![],
|
||||
vec![limit_test_candidate(true,true)],vec![limit_test_benchmark()],vec![],quotes).unwrap();
|
||||
let broker=BrokerSimulator::new(ChinaAShareCostModel::default(),ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::NextBarOpen).with_intraday_execution_start_time(open).with_liquidity_limit(false);
|
||||
broker.runtime_intraday_end_time.set(Some(open));
|
||||
broker.runtime_execution_clock.set(Some(open));
|
||||
let mut account=PortfolioState::new(100_000.);
|
||||
let report=broker.execute(date,&mut account,&data,&StrategyDecision { order_intents:vec![OrderIntent::Shares {
|
||||
symbol:"000001.SZ".into(),quantity:1000,reason:"opening capacity regression".into()}],..Default::default() }).unwrap();
|
||||
assert_eq!(report.fill_events.iter().map(|fill|fill.quantity).sum::<u32>(),100);
|
||||
assert!(report.fill_events.iter().all(|fill|fill.execution_timestamp==Some(date.and_time(open))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_capacity_requires_a_timed_observation_instead_of_falling_back_to_total_volume() {
|
||||
let mut snapshot = limit_test_snapshot();
|
||||
|
||||
@@ -320,6 +320,7 @@ fn deferred_etf_batch_failure_keeps_both_targets_and_prior_generation_progress()
|
||||
execute_on: Some(date),
|
||||
target_value: 1000.into(),
|
||||
target_weight_bps: 5000,
|
||||
target_weight_ratio: None,
|
||||
side: crate::stock_pool_execution::OrderSide::Buy,
|
||||
max_positions: 2,
|
||||
rule: Default::default(),
|
||||
|
||||
@@ -114,7 +114,7 @@ mod successor_protection_tests {
|
||||
broker.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
||||
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)),
|
||||
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 {
|
||||
symbol: new.into(), requested_order: 0, recommendation_reason: String::new(),
|
||||
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)?;
|
||||
constraints.pending_entry_symbols = execution_state.pending_symbols();
|
||||
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.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
|
||||
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 {
|
||||
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,
|
||||
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),
|
||||
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)
|
||||
.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 {
|
||||
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,
|
||||
}]).map_err(BacktestError::Execution)?
|
||||
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?;
|
||||
|
||||
@@ -278,13 +278,13 @@ pub struct AnalyzerRiskSummary {
|
||||
pub annual_return: f64,
|
||||
pub benchmark_cumulative_return: f64,
|
||||
pub excess_cumulative_return: f64,
|
||||
pub alpha: f64,
|
||||
pub beta: f64,
|
||||
pub sharpe: f64,
|
||||
pub sortino: f64,
|
||||
pub information_ratio: f64,
|
||||
pub tracking_error: f64,
|
||||
pub volatility: f64,
|
||||
pub alpha: Option<f64>,
|
||||
pub beta: Option<f64>,
|
||||
pub sharpe: Option<f64>,
|
||||
pub sortino: Option<f64>,
|
||||
pub information_ratio: Option<f64>,
|
||||
pub tracking_error: Option<f64>,
|
||||
pub volatility: Option<f64>,
|
||||
pub max_drawdown: f64,
|
||||
pub max_drawdown_duration_days: usize,
|
||||
pub win_rate: f64,
|
||||
@@ -1298,6 +1298,7 @@ where
|
||||
submission_time,
|
||||
);
|
||||
if self.broker.execution_price_field() != PriceField::Last
|
||||
&& !self.broker.matching_type_uses_intraday_quotes()
|
||||
&& !decision_has_algo_execution(decision)
|
||||
&& post_close_window.is_none()
|
||||
{
|
||||
@@ -8941,6 +8942,47 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_open_observation_loads_quotes_even_when_execution_price_is_open() {
|
||||
use crate::execution_capacity::VolumeCapacityMode;
|
||||
let date = d(2025, 1, 3);
|
||||
let signal = d(2025, 1, 2);
|
||||
let open = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
|
||||
for mode in [VolumeCapacityMode::ExecutionObservation, VolumeCapacityMode::SessionCapacityAudit] {
|
||||
let mut broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open,
|
||||
).with_matching_type(MatchingType::NextBarOpen)
|
||||
.with_volume_limit(true).with_volume_capacity_mode(mode).with_liquidity_limit(false);
|
||||
if mode == VolumeCapacityMode::ExecutionObservation {
|
||||
broker = broker.with_intraday_execution_start_time(open);
|
||||
}
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = calls.clone();
|
||||
let mut engine = BacktestEngine::new(dataset(), BuyWhenDecisionDateStrategy { decision_date: signal }, broker,
|
||||
BacktestConfig { initial_cash: 100_000., benchmark_code: "000852.SH".into(),
|
||||
start_date: Some(signal), end_date: Some(date), decision_lag_trading_days: 1,
|
||||
execution_price_field: PriceField::Open })
|
||||
.with_execution_quote_loader(move |request| {
|
||||
captured.lock().unwrap().push(request.clone());
|
||||
Ok(clock_probe_data(request.date, &[(9,30,10.)]).snapshot_components().execution_quotes)
|
||||
});
|
||||
let decision = StrategyDecision { order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.into(), quantity: 100, reason: "next-open-loader-regression".into(),
|
||||
}], ..Default::default() };
|
||||
engine.ensure_execution_quotes_for_decision(date, signal, &PortfolioState::new(100_000.), &[], &decision, None, None).unwrap();
|
||||
let calls = calls.lock().unwrap();
|
||||
if mode == VolumeCapacityMode::ExecutionObservation {
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].date, date);
|
||||
assert_eq!(calls[0].start_time, Some(open));
|
||||
assert_eq!(calls[0].symbols, BTreeSet::from([SYMBOL.to_string()]));
|
||||
assert_eq!(engine.data.execution_quotes_on(date, SYMBOL).len(), 1);
|
||||
} else {
|
||||
assert!(calls.is_empty(), "daily audit must not silently become an opening-liquidity model");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_minute_coverage_rejects_missing_active_bars_but_allows_paused_or_zero_volume() {
|
||||
let first = d(2025, 1, 2);
|
||||
|
||||
@@ -52,6 +52,7 @@ pub(crate) struct DeferredEtfTarget {
|
||||
pub execute_on: Option<NaiveDate>,
|
||||
pub target_value: Decimal,
|
||||
pub target_weight_bps: i32,
|
||||
pub target_weight_ratio: Option<Decimal>,
|
||||
pub side: crate::stock_pool_execution::OrderSide,
|
||||
pub max_positions: usize,
|
||||
pub rule: std::sync::Arc<crate::stock_pool_execution::StockPoolExecutionRule>,
|
||||
@@ -96,7 +97,7 @@ mod tests {
|
||||
use super::*;
|
||||
fn target(symbol:&str,side:crate::stock_pool_execution::OrderSide,generation:&str)->DeferredEtfTarget {
|
||||
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]
|
||||
fn latest_generation_overwrites_pending_targets_and_preserves_candidate_order() {
|
||||
|
||||
@@ -83,7 +83,8 @@ pub use futures::{
|
||||
};
|
||||
pub use instrument::Instrument;
|
||||
pub use metrics::{
|
||||
BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation, compute_backtest_metrics,
|
||||
BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation, RiskAdjustedStatistics,
|
||||
compute_backtest_metrics, risk_adjusted_statistics,
|
||||
};
|
||||
pub use platform_expr_strategy::{
|
||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
||||
|
||||
+171
-99
@@ -9,6 +9,75 @@ use crate::portfolio::HoldingSummary;
|
||||
|
||||
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
|
||||
const MONTHS_PER_YEAR: f64 = 12.0;
|
||||
pub const RISK_STATISTICS_VERSION: &str = "fidc-risk-statistics/v2";
|
||||
|
||||
/// Shared by historical backtests and observed paper/live account returns.
|
||||
/// Undefined ratios remain None; callers must not invent a risk-free rate.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
pub struct RiskAdjustedStatistics {
|
||||
pub sharpe: Option<f64>,
|
||||
pub sortino: Option<f64>,
|
||||
pub downside_volatility: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn risk_adjusted_statistics(
|
||||
returns: &[f64], rates: &[f64], periods_per_year: f64,
|
||||
) -> Result<RiskAdjustedStatistics, &'static str> {
|
||||
if returns.len() != rates.len() || !periods_per_year.is_finite() || periods_per_year <= 0.0
|
||||
|| returns.iter().chain(rates).any(|value| !value.is_finite()) {
|
||||
return Err("risk-adjusted statistics require finite aligned returns and rates");
|
||||
}
|
||||
if returns.is_empty() { return Ok(RiskAdjustedStatistics::default()); }
|
||||
let adjusted: Vec<_> = returns.iter().zip(rates).map(|(value, rate)| value-rate).collect();
|
||||
if adjusted.iter().any(|value| !value.is_finite()) { return Err("risk-adjusted return overflow"); }
|
||||
let mean_return = mean(&adjusted);
|
||||
let deviation = std_dev(&adjusted);
|
||||
let downside = (adjusted.iter().map(|value| value.min(0.0).powi(2)).sum::<f64>() / adjusted.len() as f64).sqrt();
|
||||
let annual = periods_per_year.sqrt();
|
||||
Ok(RiskAdjustedStatistics {
|
||||
sharpe: (adjusted.len() > 1 && deviation > f64::EPSILON).then_some(mean_return/deviation*annual).filter(|value|value.is_finite()),
|
||||
sortino: (downside > f64::EPSILON).then_some(mean_return/downside*annual).filter(|value|value.is_finite()),
|
||||
downside_volatility: Some(downside*annual).filter(|value|value.is_finite()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod risk_adjusted_contract_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn changing_daily_rates_adjusts_each_return_before_variance_and_downside() {
|
||||
let returns = [0.02, -0.01, 0.005];
|
||||
let rates = [0.0001, 0.0002, 0.0003];
|
||||
let values: Vec<f64> = returns.iter().zip(rates).map(|(r,f)| r-f).collect();
|
||||
let stats = risk_adjusted_statistics(&returns,&rates,252.0).unwrap();
|
||||
let average = values.iter().sum::<f64>()/3.0;
|
||||
let deviation = (values.iter().map(|r|(r-average).powi(2)).sum::<f64>()/2.0).sqrt();
|
||||
let downside = (values.iter().map(|r|r.min(0.0).powi(2)).sum::<f64>()/3.0).sqrt();
|
||||
assert!((stats.sharpe.unwrap()-average/deviation*252.0_f64.sqrt()).abs()<1e-12);
|
||||
assert!((stats.sortino.unwrap()-average/downside*252.0_f64.sqrt()).abs()<1e-12);
|
||||
assert_ne!(stats.sharpe, risk_adjusted_statistics(&returns,&[0.0;3],252.0).unwrap().sharpe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_or_invalid_rates_are_not_zero_rate_observations() {
|
||||
for rates in [vec![],vec![0.0],vec![0.0,f64::NAN],vec![0.0,f64::INFINITY]] {
|
||||
assert!(risk_adjusted_statistics(&[0.01,-0.01],&rates,252.0).is_err());
|
||||
}
|
||||
assert!(risk_adjusted_statistics(&[f64::NAN],&[0.0],252.0).is_err());
|
||||
assert!(risk_adjusted_statistics(&[0.0],&[0.0],0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_deviation_and_single_sample_ratios_remain_undefined() {
|
||||
let flat=risk_adjusted_statistics(&[0.001,0.001],&[0.001,0.001],252.0).unwrap();
|
||||
assert!(flat.sharpe.is_none() && flat.sortino.is_none());
|
||||
assert_eq!(flat.downside_volatility,Some(0.0));
|
||||
let one=risk_adjusted_statistics(&[-0.01],&[0.001],252.0).unwrap();
|
||||
assert_eq!(one.sharpe,None);
|
||||
assert!((one.sortino.unwrap()+252.0_f64.sqrt()).abs()<1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -34,13 +103,15 @@ pub struct RiskFreeRateContract {
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BacktestMetrics {
|
||||
#[serde(default)]
|
||||
pub risk_statistics_version: String,
|
||||
pub total_return: f64,
|
||||
pub annual_return: f64,
|
||||
pub sharpe: f64,
|
||||
pub sharpe: Option<f64>,
|
||||
pub max_drawdown: f64,
|
||||
pub win_rate: f64,
|
||||
pub alpha: f64,
|
||||
pub beta: f64,
|
||||
pub alpha: Option<f64>,
|
||||
pub beta: Option<f64>,
|
||||
pub benchmark_cumulative_return: f64,
|
||||
pub benchmark_net_value: f64,
|
||||
pub risk_free_rate: f64,
|
||||
@@ -49,14 +120,14 @@ pub struct BacktestMetrics {
|
||||
pub excess_annual_return: f64,
|
||||
pub max_drawdown_duration_days: usize,
|
||||
pub total_trade_days: usize,
|
||||
pub sortino: f64,
|
||||
pub downside_risk: f64,
|
||||
pub information_ratio: f64,
|
||||
pub tracking_error: f64,
|
||||
pub volatility: f64,
|
||||
pub sortino: Option<f64>,
|
||||
pub downside_risk: Option<f64>,
|
||||
pub information_ratio: Option<f64>,
|
||||
pub tracking_error: Option<f64>,
|
||||
pub volatility: Option<f64>,
|
||||
pub excess_return: f64,
|
||||
pub excess_sharpe: f64,
|
||||
pub excess_volatility: f64,
|
||||
pub excess_sharpe: Option<f64>,
|
||||
pub excess_volatility: Option<f64>,
|
||||
pub excess_max_drawdown: f64,
|
||||
pub holding_count: usize,
|
||||
pub average_weight: f64,
|
||||
@@ -75,8 +146,8 @@ pub struct BacktestMetrics {
|
||||
#[serde(default)]
|
||||
pub external_cash_flow_total: f64,
|
||||
pub excess_win_rate: f64,
|
||||
pub monthly_sharpe: f64,
|
||||
pub monthly_volatility: f64,
|
||||
pub monthly_sharpe: Option<f64>,
|
||||
pub monthly_volatility: Option<f64>,
|
||||
pub risk_free_rate_contract_version: String,
|
||||
pub risk_free_rate_source: String,
|
||||
pub risk_free_rate_tenor: String,
|
||||
@@ -105,12 +176,14 @@ pub fn compute_backtest_metrics_with_manual(
|
||||
) -> Result<BacktestMetrics, String> {
|
||||
let Some(first_point) = equity_curve.first() else {
|
||||
return Ok(BacktestMetrics {
|
||||
risk_statistics_version: RISK_STATISTICS_VERSION.into(),
|
||||
initial_cash,
|
||||
..BacktestMetrics::default()
|
||||
});
|
||||
};
|
||||
let Some(last_point) = equity_curve.last() else {
|
||||
return Ok(BacktestMetrics {
|
||||
risk_statistics_version: RISK_STATISTICS_VERSION.into(),
|
||||
initial_cash,
|
||||
..BacktestMetrics::default()
|
||||
});
|
||||
@@ -133,6 +206,10 @@ pub fn compute_backtest_metrics_with_manual(
|
||||
flow_neutral_nav_series(equity_curve, account_events, initial_cash)
|
||||
};
|
||||
let mut returns = Vec::with_capacity(portfolio_nav.len());
|
||||
// A zero terminal NAV is a real -100% return. A later return starting
|
||||
// from zero has no denominator and must not become another zero return.
|
||||
let risk_periods_valid = portfolio_nav.iter().all(|nav|nav.is_finite() && *nav >= 0.0)
|
||||
&& portfolio_nav.windows(2).all(|pair| pair[0] > 0.0);
|
||||
if let Some(first_nav) = portfolio_nav.first().copied() {
|
||||
returns.push(pct_change(1.0, first_nav));
|
||||
}
|
||||
@@ -176,24 +253,23 @@ pub fn compute_backtest_metrics_with_manual(
|
||||
aligned_daily_risk_free_rates(equity_curve, risk_free_contract)?;
|
||||
let risk_free_rate =
|
||||
effective_annual_risk_free_rate(&daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let sharpe = annualized_sharpe(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let sortino = annualized_sortino(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let downside_risk =
|
||||
annualized_downside_risk(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let information_ratio = annualized_sharpe(
|
||||
let risk_stats = if risk_periods_valid {
|
||||
risk_adjusted_statistics(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR)?
|
||||
} else { RiskAdjustedStatistics::default() };
|
||||
let sharpe = risk_stats.sharpe;
|
||||
let sortino = risk_stats.sortino;
|
||||
let downside_risk = risk_stats.downside_volatility;
|
||||
let excess_stats = if risk_periods_valid { risk_adjusted_statistics(
|
||||
&excess_returns,
|
||||
&zero_risk_free_rates,
|
||||
TRADING_DAYS_PER_YEAR,
|
||||
);
|
||||
let tracking_error = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
||||
let volatility = annualized_std(&returns, TRADING_DAYS_PER_YEAR);
|
||||
let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
||||
let excess_sharpe = annualized_sharpe(
|
||||
&excess_returns,
|
||||
&zero_risk_free_rates,
|
||||
TRADING_DAYS_PER_YEAR,
|
||||
);
|
||||
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, &daily_risk_free_rates);
|
||||
)? } else { RiskAdjustedStatistics::default() };
|
||||
let information_ratio = excess_stats.sharpe;
|
||||
let tracking_error = risk_periods_valid.then(||annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR)).flatten();
|
||||
let volatility = risk_periods_valid.then(||annualized_std(&returns, TRADING_DAYS_PER_YEAR)).flatten();
|
||||
let excess_volatility = tracking_error;
|
||||
let excess_sharpe = excess_stats.sharpe;
|
||||
let (alpha, beta) = if risk_periods_valid { alpha_beta(&returns, &benchmark_returns, &daily_risk_free_rates) } else { (None,None) };
|
||||
|
||||
let equity_nav = portfolio_nav;
|
||||
let benchmark_nav_series = equity_curve
|
||||
@@ -231,12 +307,12 @@ pub fn compute_backtest_metrics_with_manual(
|
||||
.count(),
|
||||
monthly_excess_returns.len(),
|
||||
);
|
||||
let monthly_sharpe = annualized_sharpe(
|
||||
let monthly_sharpe = if risk_periods_valid { risk_adjusted_statistics(
|
||||
&monthly_portfolio_returns,
|
||||
&monthly_risk_free_returns,
|
||||
MONTHS_PER_YEAR,
|
||||
);
|
||||
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
||||
)?.sharpe } else { None };
|
||||
let monthly_volatility = risk_periods_valid.then(||annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR)).flatten();
|
||||
|
||||
let mut turnover_by_date = fills
|
||||
.iter()
|
||||
@@ -294,6 +370,7 @@ pub fn compute_backtest_metrics_with_manual(
|
||||
let total_trade_days = equity_by_date.len();
|
||||
|
||||
Ok(BacktestMetrics {
|
||||
risk_statistics_version: RISK_STATISTICS_VERSION.into(),
|
||||
total_return,
|
||||
annual_return,
|
||||
sharpe,
|
||||
@@ -470,80 +547,21 @@ fn effective_annual_risk_free_rate(daily_rates: &[f64], periods_per_year: f64) -
|
||||
(mean_log * periods_per_year).exp_m1()
|
||||
}
|
||||
|
||||
fn annualized_sharpe(returns: &[f64], daily_risk_free_rates: &[f64], periods_per_year: f64) -> f64 {
|
||||
if returns.len() < 2 || returns.len() != daily_risk_free_rates.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let adjusted = returns
|
||||
.iter()
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let mean_ret = mean(&adjusted);
|
||||
let std = std_dev(&adjusted);
|
||||
if std <= f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
mean_ret / std * periods_per_year.sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
fn annualized_sortino(
|
||||
returns: &[f64],
|
||||
daily_risk_free_rates: &[f64],
|
||||
periods_per_year: f64,
|
||||
) -> f64 {
|
||||
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let adjusted = returns
|
||||
.iter()
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let downside = adjusted
|
||||
.iter()
|
||||
.map(|value| value.min(0.0).powi(2))
|
||||
.sum::<f64>();
|
||||
let downside_dev = (downside / adjusted.len() as f64).sqrt();
|
||||
if downside_dev <= f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
mean(&adjusted) / downside_dev * periods_per_year.sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
fn annualized_downside_risk(
|
||||
returns: &[f64],
|
||||
daily_risk_free_rates: &[f64],
|
||||
periods_per_year: f64,
|
||||
) -> f64 {
|
||||
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let downside_mean_square = returns
|
||||
.iter()
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| (value - risk_free).min(0.0).powi(2))
|
||||
.sum::<f64>()
|
||||
/ returns.len() as f64;
|
||||
downside_mean_square.sqrt() * periods_per_year.sqrt()
|
||||
}
|
||||
|
||||
fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 {
|
||||
std_dev(values) * periods_per_year.sqrt()
|
||||
fn annualized_std(values: &[f64], periods_per_year: f64) -> Option<f64> {
|
||||
(values.len() > 1).then(|| std_dev(values) * periods_per_year.sqrt())
|
||||
.filter(|value| value.is_finite())
|
||||
}
|
||||
|
||||
fn alpha_beta(
|
||||
returns: &[f64],
|
||||
benchmark_returns: &[f64],
|
||||
daily_risk_free_rates: &[f64],
|
||||
) -> (f64, f64) {
|
||||
) -> (Option<f64>, Option<f64>) {
|
||||
if returns.len() < 2
|
||||
|| returns.len() != benchmark_returns.len()
|
||||
|| returns.len() != daily_risk_free_rates.len()
|
||||
{
|
||||
return (0.0, 0.0);
|
||||
return (None, None);
|
||||
}
|
||||
let strategy_excess = returns
|
||||
.iter()
|
||||
@@ -561,7 +579,7 @@ fn alpha_beta(
|
||||
let mean_raw_benchmark = mean(benchmark_returns);
|
||||
let variance_benchmark = variance(benchmark_returns);
|
||||
if variance_benchmark <= f64::EPSILON {
|
||||
return (0.0, 0.0);
|
||||
return (None, None);
|
||||
}
|
||||
let covariance = returns
|
||||
.iter()
|
||||
@@ -571,7 +589,7 @@ fn alpha_beta(
|
||||
/ (strategy_excess.len() - 1) as f64;
|
||||
let beta = covariance / variance_benchmark;
|
||||
let alpha = (mean_strategy - beta * mean_benchmark) * TRADING_DAYS_PER_YEAR;
|
||||
(alpha, beta)
|
||||
(Some(alpha).filter(|value| value.is_finite()), Some(beta).filter(|value| value.is_finite()))
|
||||
}
|
||||
|
||||
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
||||
@@ -928,12 +946,66 @@ mod tests {
|
||||
/ adjusted.len() as f64)
|
||||
.sqrt();
|
||||
let expected_sortino = mean(&adjusted) / downside * TRADING_DAYS_PER_YEAR.sqrt();
|
||||
assert!((metrics.sharpe - expected_sharpe).abs() < 1e-12);
|
||||
assert!((metrics.sortino - expected_sortino).abs() < 1e-12);
|
||||
assert!((metrics.downside_risk - downside * TRADING_DAYS_PER_YEAR.sqrt()).abs() < 1e-12);
|
||||
assert!((metrics.sharpe.unwrap() - expected_sharpe).abs() < 1e-12);
|
||||
assert!((metrics.sortino.unwrap() - expected_sortino).abs() < 1e-12);
|
||||
assert!((metrics.downside_risk.unwrap() - downside * TRADING_DAYS_PER_YEAR.sqrt()).abs() < 1e-12);
|
||||
assert_eq!(metrics.risk_free_rate_source, "test");
|
||||
assert_eq!(metrics.risk_free_rate_tenor, "3M");
|
||||
assert_eq!(metrics.risk_free_rate_observation_count, 4);
|
||||
assert_ne!(metrics.risk_free_rate, 0.022);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undefined_ratios_remain_null_in_backtest_metrics() {
|
||||
for curve in [vec![], vec![equity_point("2026-01-02", 100.0, 100.0, 100.0)],
|
||||
vec![equity_point("2026-01-02", 100.0, 100.0, 100.0),
|
||||
equity_point("2026-01-05", 100.0, 100.0, 100.0)]] {
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
|
||||
let json = serde_json::to_value(&metrics).unwrap();
|
||||
assert_eq!(json["risk_statistics_version"], RISK_STATISTICS_VERSION);
|
||||
for field in ["sharpe", "sortino", "alpha", "beta", "information_ratio", "excess_sharpe", "monthly_sharpe"] {
|
||||
assert!(json[field].is_null(), "{field}: {json}");
|
||||
}
|
||||
if curve.len() < 2 { assert_eq!(metrics.volatility, None); }
|
||||
else { assert_eq!(metrics.volatility, Some(0.0)); }
|
||||
assert_eq!(metrics.downside_risk, (!curve.is_empty()).then_some(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_defined_zero_ratio_is_not_missing() {
|
||||
let stats = risk_adjusted_statistics(&[0.01,-0.01], &[0.0,0.0], 252.0).unwrap();
|
||||
assert_eq!(stats.sharpe, Some(0.0));
|
||||
assert_eq!(stats.sortino, Some(0.0));
|
||||
let (alpha, beta) = alpha_beta(&[0.0,0.0], &[0.01,-0.01], &[0.0,0.0]);
|
||||
assert_eq!((alpha,beta), (Some(0.0),Some(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gains_without_downside_have_no_sortino_or_monthly_sharpe() {
|
||||
let curve = vec![equity_point("2026-01-02", 101.0, 101.0, 100.0),
|
||||
equity_point("2026-01-05", 103.02, 103.02, 101.0)];
|
||||
let metrics = compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap();
|
||||
assert!(metrics.sharpe.unwrap() > 0.0);
|
||||
assert_eq!(metrics.sortino,None);
|
||||
assert_eq!(metrics.downside_risk,Some(0.0));
|
||||
assert_eq!(metrics.information_ratio,None);
|
||||
assert_eq!(metrics.excess_sharpe,None);
|
||||
assert_eq!(metrics.tracking_error,Some(0.0));
|
||||
assert_eq!(metrics.monthly_sharpe,None);
|
||||
assert_eq!(metrics.monthly_volatility,None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_risk_ratio_is_fabricated_after_nav_has_reached_zero() {
|
||||
let curve = vec![equity_point("2026-01-02", 0.0, 101.0, 100.0),
|
||||
equity_point("2026-01-05", 0.0, 102.0, 101.0)];
|
||||
let metrics=compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap();
|
||||
assert_eq!(metrics.total_return,-1.0);
|
||||
for value in [metrics.sharpe,metrics.sortino,metrics.alpha,metrics.beta,metrics.information_ratio,metrics.volatility,metrics.monthly_sharpe] {
|
||||
assert_eq!(value,None);
|
||||
}
|
||||
let loss=compute_backtest_metrics(&curve[..1],&[],&[],&[],100.0,None).unwrap();
|
||||
assert!(loss.sortino.unwrap()<0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +484,9 @@ pub struct StockPoolDecisionConstraints {
|
||||
pub execution_date: Option<NaiveDate>,
|
||||
pub frozen_positions: BTreeMap<String, FrozenStockPoolPosition>,
|
||||
pub prior_target_weights: BTreeMap<String, i32>,
|
||||
/// Sizing ratios from prior plans; integer bps are display/legacy only.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub prior_target_weight_ratios: BTreeMap<String, Decimal>,
|
||||
pub pending_entry_symbols: BTreeSet<String>,
|
||||
pub next_day_outside_exit_symbols: BTreeSet<String>,
|
||||
pub market_timing_policy: Option<crate::stock_pool_index_policy::MarketTimingPolicy>,
|
||||
@@ -530,6 +533,8 @@ pub struct StockPoolPlanRow {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
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")]
|
||||
pub position_action_bases: BTreeMap<String, Decimal>,
|
||||
pub market_timing: Option<crate::stock_pool_index_policy::MarketTimingEvaluation>,
|
||||
@@ -1096,6 +1101,11 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
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 {
|
||||
if original_final_symbols.contains(symbol) || current.contains_key(symbol) {
|
||||
weights.insert(symbol.clone(), 0);
|
||||
@@ -1235,8 +1245,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
.map(|symbol| {
|
||||
let current_value = current[symbol].0
|
||||
* frozen::valuation(symbol, "e_map, &constraints.frozen_positions)?;
|
||||
let desired =
|
||||
budget * Decimal::from(*weights.get(symbol).unwrap_or(&0)) / Decimal::from(10_000);
|
||||
let desired = budget * sizing_ratio(symbol);
|
||||
if constraints.frozen_positions.contains_key(symbol) {
|
||||
return Ok((symbol.clone(), desired));
|
||||
}
|
||||
@@ -1277,7 +1286,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
let free_desired = weights
|
||||
.iter()
|
||||
.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>();
|
||||
let free_budget = (budget * Decimal::from(requested_weight_total) / Decimal::from(10_000)
|
||||
- protected_values.values().copied().sum::<Decimal>())
|
||||
@@ -1296,7 +1305,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
rows.push(StockPoolPlanRow {
|
||||
symbol: symbol.clone(),
|
||||
target_weight_bps: weight,
|
||||
target_value: budget * Decimal::from(weight) / Decimal::from(10_000),
|
||||
target_value: budget * sizing_ratio(symbol),
|
||||
current_quantity: quantity,
|
||||
target_quantity: quantity,
|
||||
delta_quantity: Decimal::ZERO,
|
||||
@@ -1570,7 +1579,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
let target_value = protected_values
|
||||
.get(symbol)
|
||||
.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);
|
||||
let sizing_price = if target_value >= current_quantity * quote.last_price {
|
||||
quote.buy_sizing_price.unwrap_or(quote.last_price)
|
||||
@@ -1580,7 +1589,17 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
if sizing_price <= Decimal::ZERO {
|
||||
return Err(format!("{symbol} execution sizing price is invalid"));
|
||||
}
|
||||
let raw_target = (target_value / sizing_price).floor();
|
||||
// Existing shares are marked at the observed market price. Only the
|
||||
// new buy leg pays its executable/slippage price; repricing the whole
|
||||
// position would charge fictitious slippage and miss a board lot.
|
||||
let current_value = current_quantity * quote.last_price;
|
||||
let raw_target = if target_value >= current_value {
|
||||
current_quantity + ((target_value - current_value) / sizing_price).floor()
|
||||
} else {
|
||||
// Sale slippage changes proceeds, not the marked shares we must
|
||||
// remove to reach a market-value target.
|
||||
(target_value / quote.last_price).floor()
|
||||
};
|
||||
let (step, minimum_buy) = order_quantity_rules(quote)?;
|
||||
let mut target_quantity = current_quantity;
|
||||
let mut delta = Decimal::ZERO;
|
||||
@@ -1903,7 +1922,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
let cost = |quantity: Decimal| {
|
||||
Ok(quantity * price + fee_for(&row.symbol, OrderSide::Buy, quantity * price)?)
|
||||
};
|
||||
let own_budget = (row.target_value - row.current_quantity * price).max(Decimal::ZERO);
|
||||
let own_budget = (row.target_value - row.current_quantity * quote.last_price).max(Decimal::ZERO);
|
||||
let allocation_quantity = max_affordable_buy_quantity_with_cost(
|
||||
own_budget,
|
||||
row.delta_quantity,
|
||||
@@ -1995,6 +2014,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)))
|
||||
.collect();
|
||||
Ok(StockPoolPlan {
|
||||
target_weight_ratios,
|
||||
position_action_bases,
|
||||
market_timing,
|
||||
rows,
|
||||
|
||||
@@ -1,4 +1,56 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_constraints_do_not_gain_an_empty_precision_field() {
|
||||
let value = serde_json::to_value(StockPoolDecisionConstraints::default()).unwrap();
|
||||
assert!(value.get("prior_target_weight_ratios").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_thirty_seats_use_full_precision_at_a_board_lot_boundary() {
|
||||
for (equity, price, executable, held) in [("999377.147617", "3.07", "3.070307", 2000), ("995624.8819", "6.42", "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(executable.parse().unwrap());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sale_slippage_does_not_prevent_a_marked_value_board_lot_reduction() {
|
||||
let pool = members(1);
|
||||
let mut market = quotes(1);
|
||||
market[0].sell_sizing_price = Some(Decimal::new(99,1));
|
||||
let positions = vec![Position { symbol:symbol(1),quantity:200.into(),closable_quantity:200.into(),average_cost:10.into() }];
|
||||
let plan = build_stock_pool_target_plan_with_constraints(&selection(1,1),&pool,&StockPoolExecutionRule::default(),
|
||||
&AccountSnapshot {total_equity:2000.into(),cash:Decimal::ZERO,frozen_cash:Decimal::ZERO},&positions,&market,
|
||||
5000,Decimal::ZERO,"hold","full_rebalance",&StockPoolDecisionConstraints::default(),"reduce",Decimal::ZERO,Decimal::ZERO,Decimal::ZERO).unwrap();
|
||||
assert_eq!(plan.rows[0].target_value,Decimal::from(1000));
|
||||
assert_eq!(plan.rows[0].delta_quantity,Decimal::from(-100));
|
||||
}
|
||||
use serde_json::json;
|
||||
|
||||
fn symbol(index: usize) -> String {
|
||||
|
||||
@@ -26,9 +26,58 @@ pub(super) fn validate(
|
||||
{
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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(
|
||||
symbol: &str,
|
||||
quotes: &HashMap<String, &MarketSnapshot>,
|
||||
@@ -42,6 +91,48 @@ pub(super) fn valuation(
|
||||
.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<String> = 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(
|
||||
original: &[String],
|
||||
active: &[String],
|
||||
|
||||
@@ -40,6 +40,8 @@ pub struct StockPoolExecutionState {
|
||||
pub entries: BTreeMap<String, StockPoolEntryProgress>,
|
||||
#[serde(default)]
|
||||
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.
|
||||
pub removed_since: BTreeMap<String, NaiveDate>,
|
||||
/// Signal progress, not a fill or holding-period fact. Kept across retries
|
||||
@@ -51,6 +53,7 @@ pub struct StockPoolExecutionState {
|
||||
pub struct StockPoolGoalObservation<'a> {
|
||||
pub symbol: &'a str,
|
||||
pub target_weight_bps: i32,
|
||||
pub target_weight_ratio: Option<Decimal>,
|
||||
pub target_value: Decimal,
|
||||
pub current_quantity: Decimal,
|
||||
pub target_quantity: Decimal,
|
||||
@@ -64,6 +67,7 @@ impl Default for StockPoolExecutionState {
|
||||
last_execution_date: None,
|
||||
entries: BTreeMap::new(),
|
||||
last_target_weights: BTreeMap::new(),
|
||||
last_target_weight_ratios: BTreeMap::new(),
|
||||
removed_since: BTreeMap::new(),
|
||||
position_action_bases: BTreeMap::new(),
|
||||
}
|
||||
@@ -72,7 +76,8 @@ impl Default for StockPoolExecutionState {
|
||||
|
||||
impl StockPoolExecutionState {
|
||||
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.removed_since.len() > 10000
|
||||
|| self.position_action_bases.len() > 10000
|
||||
@@ -84,6 +89,7 @@ impl StockPoolExecutionState {
|
||||
.keys()
|
||||
.chain(self.removed_since.keys())
|
||||
.chain(self.last_target_weights.keys())
|
||||
.chain(self.last_target_weight_ratios.keys())
|
||||
.chain(self.position_action_bases.keys())
|
||||
{
|
||||
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
|
||||
@@ -98,6 +104,9 @@ impl StockPoolExecutionState {
|
||||
{
|
||||
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| {
|
||||
entry.latest_target_value < Decimal::ZERO
|
||||
|| entry.completion_quantity.is_some_and(|quantity| quantity <= Decimal::ZERO)
|
||||
@@ -159,6 +168,7 @@ impl StockPoolExecutionState {
|
||||
});
|
||||
next.last_target_weights
|
||||
.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 {
|
||||
entry.observed_holding |= held.contains(symbol);
|
||||
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 {
|
||||
symbol: &row.symbol,
|
||||
target_weight_bps: row.target_weight_bps,
|
||||
target_weight_ratio: plan.target_weight_ratios.get(&row.symbol).copied(),
|
||||
target_value: row.target_value,
|
||||
current_quantity: row.current_quantity,
|
||||
target_quantity: row.target_quantity,
|
||||
@@ -300,6 +311,12 @@ impl StockPoolExecutionState {
|
||||
if row.target_weight_bps > 0 {
|
||||
next.last_target_weights
|
||||
.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 completion_quantity = (row.status == "READY"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 股票池等权预算精度
|
||||
|
||||
此前按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测试前不得发布。
|
||||
|
||||
后续真实Source回放补充:原始报价门禁打开后,Engine仍因PriceField::Open提前跳过行情加载;不能把此错误标成原始数据缺失。补充按实际撮合是否需要盘中观测判断加载路径,测试验证NextBarOpen加载执行日09:30报价、日终审计保持不加载。实际发布、回放及缺数清单以工作区`docs/fidc/stock-pool-precision-correction-20260919.md`为准。
|
||||
Reference in New Issue
Block a user