支持日期化仓位调整回放
This commit is contained in:
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
|
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
|
||||||
|
|
||||||
|
## Runtime position exposure schedule
|
||||||
|
|
||||||
|
`strategy_spec.runtimeExpressions.risk.positionExposureSchedule` accepts dated
|
||||||
|
`effectiveDate` plus `targetExposureBps` points. The platform expression strategy
|
||||||
|
uses the latest point whose date is not later than the current execution date and
|
||||||
|
otherwise keeps the strategy's normal `exposureExpr`. This contract is intended for
|
||||||
|
audited runtime controls replayed by paper/live shadow reconciliation; it is not a
|
||||||
|
market-data signal and does not change selection, pricing, fees, or execution-day
|
||||||
|
risk checks.
|
||||||
|
|
||||||
## 当前能力
|
## 当前能力
|
||||||
|
|
||||||
- 日频和分钟执行价策略生命周期与确定性回放。
|
- 日频和分钟执行价策略生命周期与确定性回放。
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub stock_filter_expr: String,
|
pub stock_filter_expr: String,
|
||||||
pub buy_scale_expr: String,
|
pub buy_scale_expr: String,
|
||||||
pub exposure_expr: String,
|
pub exposure_expr: String,
|
||||||
|
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
||||||
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
||||||
pub stop_loss_expr: String,
|
pub stop_loss_expr: String,
|
||||||
pub take_profit_expr: String,
|
pub take_profit_expr: String,
|
||||||
@@ -460,6 +461,7 @@ impl PlatformExprStrategyConfig {
|
|||||||
stock_filter_expr: String::new(),
|
stock_filter_expr: String::new(),
|
||||||
buy_scale_expr: "1.0".to_string(),
|
buy_scale_expr: "1.0".to_string(),
|
||||||
exposure_expr: "1.0".to_string(),
|
exposure_expr: "1.0".to_string(),
|
||||||
|
position_exposure_schedule: BTreeMap::new(),
|
||||||
portfolio_drawdown_control: None,
|
portfolio_drawdown_control: None,
|
||||||
stop_loss_expr: String::new(),
|
stop_loss_expr: String::new(),
|
||||||
take_profit_expr: String::new(),
|
take_profit_expr: String::new(),
|
||||||
@@ -555,6 +557,16 @@ fn band_low(index_close) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scheduled_position_exposure(
|
||||||
|
schedule: &BTreeMap<NaiveDate, f64>,
|
||||||
|
decision_date: NaiveDate,
|
||||||
|
) -> Option<f64> {
|
||||||
|
schedule
|
||||||
|
.range(..=decision_date)
|
||||||
|
.next_back()
|
||||||
|
.map(|(_, exposure)| exposure.clamp(0.0, 1.0))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
struct ProjectedExecutionState {
|
struct ProjectedExecutionState {
|
||||||
execution_cursors: BTreeMap<String, NaiveDateTime>,
|
execution_cursors: BTreeMap<String, NaiveDateTime>,
|
||||||
@@ -7588,9 +7600,15 @@ impl PlatformExprStrategy {
|
|||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
day: &DayExpressionState,
|
day: &DayExpressionState,
|
||||||
) -> Result<f64, BacktestError> {
|
) -> Result<f64, BacktestError> {
|
||||||
let risk_on_exposure = self
|
let strategy_exposure = self
|
||||||
.eval_float(ctx, &self.config.exposure_expr, day, None, None)?
|
.eval_float(ctx, &self.config.exposure_expr, day, None, None)?
|
||||||
.clamp(0.0, 1.0);
|
.clamp(0.0, 1.0);
|
||||||
|
let risk_on_exposure = scheduled_position_exposure(
|
||||||
|
&self.config.position_exposure_schedule,
|
||||||
|
ctx.execution_date,
|
||||||
|
)
|
||||||
|
.unwrap_or(strategy_exposure)
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
let Some(controller) = self.portfolio_drawdown_controller.as_mut() else {
|
let Some(controller) = self.portfolio_drawdown_controller.as_mut() else {
|
||||||
return Ok(risk_on_exposure);
|
return Ok(risk_on_exposure);
|
||||||
};
|
};
|
||||||
@@ -12488,6 +12506,7 @@ mod tests {
|
|||||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode,
|
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode,
|
||||||
PlatformTradeAction, PlatformUniverseActionKind, RuntimeHelperResolution,
|
PlatformTradeAction, PlatformUniverseActionKind, RuntimeHelperResolution,
|
||||||
SelectionRiskDeferral, StockFilterQuoteUsage, framework_stock_rolling_factor_requirement,
|
SelectionRiskDeferral, StockFilterQuoteUsage, framework_stock_rolling_factor_requirement,
|
||||||
|
scheduled_position_exposure,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
|
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
|
||||||
@@ -12503,6 +12522,23 @@ mod tests {
|
|||||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dated_position_exposure_uses_the_latest_effective_point() {
|
||||||
|
let schedule = BTreeMap::from([
|
||||||
|
(d(2026, 8, 14), 0.6451),
|
||||||
|
(d(2026, 8, 20), 0.3225),
|
||||||
|
]);
|
||||||
|
assert_eq!(scheduled_position_exposure(&schedule, d(2026, 8, 13)), None);
|
||||||
|
assert_eq!(
|
||||||
|
scheduled_position_exposure(&schedule, d(2026, 8, 14)),
|
||||||
|
Some(0.6451),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scheduled_position_exposure(&schedule, d(2026, 8, 31)),
|
||||||
|
Some(0.3225),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn universe_exclude_matches_explicit_symbols_and_bjse_alias() {
|
fn universe_exclude_matches_explicit_symbols_and_bjse_alias() {
|
||||||
let excludes = vec![
|
let excludes = vec![
|
||||||
|
|||||||
@@ -902,6 +902,8 @@ pub struct StrategyExpressionAllocationConfig {
|
|||||||
pub struct StrategyExpressionRiskConfig {
|
pub struct StrategyExpressionRiskConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub exposure_expr: Option<String>,
|
pub exposure_expr: Option<String>,
|
||||||
|
#[serde(default, alias = "position_exposure_schedule")]
|
||||||
|
pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -916,6 +918,15 @@ pub struct StrategyExpressionRiskConfig {
|
|||||||
pub stop_take_reference_price_mode: Option<String>,
|
pub stop_take_reference_price_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StrategyPositionExposureSchedulePoint {
|
||||||
|
#[serde(alias = "effective_date")]
|
||||||
|
pub effective_date: String,
|
||||||
|
#[serde(alias = "target_exposure_bps", alias = "effective_bps")]
|
||||||
|
pub target_exposure_bps: i32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyPortfolioDrawdownControlConfig {
|
pub struct StrategyPortfolioDrawdownControlConfig {
|
||||||
@@ -2035,6 +2046,34 @@ pub fn platform_expr_config_from_spec(
|
|||||||
expr.clone()
|
expr.clone()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
for point in &risk.position_exposure_schedule {
|
||||||
|
let effective_date = NaiveDate::parse_from_str(
|
||||||
|
point.effective_date.trim(),
|
||||||
|
"%Y-%m-%d",
|
||||||
|
)
|
||||||
|
.map_err(|_| {
|
||||||
|
"runtimeExpressions.risk.positionExposureSchedule effectiveDate must use YYYY-MM-DD"
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
if !(0..=10_000).contains(&point.target_exposure_bps) {
|
||||||
|
return Err(
|
||||||
|
"runtimeExpressions.risk.positionExposureSchedule targetExposureBps must be between 0 and 10000"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if cfg
|
||||||
|
.position_exposure_schedule
|
||||||
|
.insert(
|
||||||
|
effective_date,
|
||||||
|
f64::from(point.target_exposure_bps) / 10_000.0,
|
||||||
|
)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"runtimeExpressions.risk.positionExposureSchedule contains duplicate date {effective_date}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
|
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
|
||||||
&& control.enabled.unwrap_or(true)
|
&& control.enabled.unwrap_or(true)
|
||||||
{
|
{
|
||||||
@@ -4003,6 +4042,35 @@ mod tests {
|
|||||||
assert_eq!(cfg.stock_long_ma_days, 21);
|
assert_eq!(cfg.stock_long_ma_days, 21);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_expression_parses_dated_position_exposure_schedule() {
|
||||||
|
let spec = serde_json::json!({
|
||||||
|
"runtimeExpressions": {
|
||||||
|
"risk": {
|
||||||
|
"exposureExpr": "1.0",
|
||||||
|
"positionExposureSchedule": [
|
||||||
|
{"effectiveDate": "2026-08-14", "targetExposureBps": 6451},
|
||||||
|
{"effective_date": "2026-08-20", "effective_bps": 3225}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||||
|
assert_eq!(
|
||||||
|
cfg.position_exposure_schedule
|
||||||
|
[&NaiveDate::from_ymd_opt(2026, 8, 14).unwrap()],
|
||||||
|
0.6451,
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.position_exposure_schedule.len(), 2);
|
||||||
|
|
||||||
|
let invalid = serde_json::json!({
|
||||||
|
"runtimeExpressions": {"risk": {"positionExposureSchedule": [
|
||||||
|
{"effectiveDate": "2026-08-14", "targetExposureBps": 10001}
|
||||||
|
]}}
|
||||||
|
});
|
||||||
|
assert!(platform_expr_config_from_value("", "", &invalid).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn index_throttle_uses_signal_ma_not_performance_benchmark_ma() {
|
fn index_throttle_uses_signal_ma_not_performance_benchmark_ma() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
|
|||||||
Reference in New Issue
Block a user