支持日期化仓位调整回放
This commit is contained in:
@@ -388,6 +388,7 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub stock_filter_expr: String,
|
||||
pub buy_scale_expr: String,
|
||||
pub exposure_expr: String,
|
||||
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
||||
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
||||
pub stop_loss_expr: String,
|
||||
pub take_profit_expr: String,
|
||||
@@ -460,6 +461,7 @@ impl PlatformExprStrategyConfig {
|
||||
stock_filter_expr: String::new(),
|
||||
buy_scale_expr: "1.0".to_string(),
|
||||
exposure_expr: "1.0".to_string(),
|
||||
position_exposure_schedule: BTreeMap::new(),
|
||||
portfolio_drawdown_control: None,
|
||||
stop_loss_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)]
|
||||
struct ProjectedExecutionState {
|
||||
execution_cursors: BTreeMap<String, NaiveDateTime>,
|
||||
@@ -7588,9 +7600,15 @@ impl PlatformExprStrategy {
|
||||
ctx: &StrategyContext<'_>,
|
||||
day: &DayExpressionState,
|
||||
) -> Result<f64, BacktestError> {
|
||||
let risk_on_exposure = self
|
||||
let strategy_exposure = self
|
||||
.eval_float(ctx, &self.config.exposure_expr, day, None, None)?
|
||||
.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 {
|
||||
return Ok(risk_on_exposure);
|
||||
};
|
||||
@@ -12488,6 +12506,7 @@ mod tests {
|
||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode,
|
||||
PlatformTradeAction, PlatformUniverseActionKind, RuntimeHelperResolution,
|
||||
SelectionRiskDeferral, StockFilterQuoteUsage, framework_stock_rolling_factor_requirement,
|
||||
scheduled_position_exposure,
|
||||
};
|
||||
use crate::{
|
||||
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
|
||||
@@ -12503,6 +12522,23 @@ mod tests {
|
||||
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]
|
||||
fn universe_exclude_matches_explicit_symbols_and_bjse_alias() {
|
||||
let excludes = vec![
|
||||
|
||||
@@ -902,6 +902,8 @@ pub struct StrategyExpressionAllocationConfig {
|
||||
pub struct StrategyExpressionRiskConfig {
|
||||
#[serde(default)]
|
||||
pub exposure_expr: Option<String>,
|
||||
#[serde(default, alias = "position_exposure_schedule")]
|
||||
pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>,
|
||||
#[serde(default)]
|
||||
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
||||
#[serde(default)]
|
||||
@@ -916,6 +918,15 @@ pub struct StrategyExpressionRiskConfig {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyPortfolioDrawdownControlConfig {
|
||||
@@ -2035,6 +2046,34 @@ pub fn platform_expr_config_from_spec(
|
||||
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()
|
||||
&& control.enabled.unwrap_or(true)
|
||||
{
|
||||
@@ -4003,6 +4042,35 @@ mod tests {
|
||||
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]
|
||||
fn index_throttle_uses_signal_ma_not_performance_benchmark_ma() {
|
||||
let spec = serde_json::json!({
|
||||
|
||||
Reference in New Issue
Block a user