Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a349ca3c7 | |||
| 98732bbc46 |
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`为准。
|
||||
|
||||
Reference in New Issue
Block a user