Compare commits

..

2 Commits

4 changed files with 120 additions and 44 deletions
+42
View File
@@ -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);
+2 -1
View File
@@ -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,
+74 -43
View File
@@ -10,6 +10,77 @@ use crate::portfolio::HoldingSummary;
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
const MONTHS_PER_YEAR: f64 = 12.0;
/// 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_eq!(stats.sharpe.unwrap(),annualized_sharpe(&returns,&rates,252.0));
assert_eq!(stats.sortino.unwrap(),annualized_sortino(&returns,&rates,252.0));
assert_eq!(stats.downside_volatility.unwrap(),annualized_downside_risk(&returns,&rates,252.0));
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")]
pub struct RiskFreeRateObservation {
@@ -471,21 +542,7 @@ fn effective_annual_risk_free_rate(daily_rates: &[f64], periods_per_year: f64) -
}
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()
}
risk_adjusted_statistics(returns,daily_risk_free_rates,periods_per_year).ok().and_then(|stats|stats.sharpe).unwrap_or(0.0)
}
fn annualized_sortino(
@@ -493,24 +550,7 @@ fn annualized_sortino(
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()
}
risk_adjusted_statistics(returns,daily_risk_free_rates,periods_per_year).ok().and_then(|stats|stats.sortino).unwrap_or(0.0)
}
fn annualized_downside_risk(
@@ -518,16 +558,7 @@ fn annualized_downside_risk(
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()
risk_adjusted_statistics(returns,daily_risk_free_rates,periods_per_year).ok().and_then(|stats|stats.downside_volatility).unwrap_or(0.0)
}
fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 {
@@ -9,3 +9,5 @@
执行状态新增 `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`为准。