diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 0026eac..274eeeb 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -26,6 +26,7 @@ pub mod platform_runtime_schema; pub mod platform_strategy_spec; pub mod portfolio; pub mod portfolio_loss; +pub mod position_exposure; pub mod risk_control; pub mod rules; pub mod scheduler; diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 1f9d471..66ac3c9 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -652,6 +652,7 @@ pub struct PlatformExprStrategyConfig { pub buy_scale_expr: String, pub exposure_expr: String, pub position_exposure_schedule: BTreeMap, + pub position_exposure_timeline: crate::position_exposure::PositionExposureTimeline, pub portfolio_drawdown_control: Option, pub portfolio_loss_control: Option, pub stop_loss_expr: String, @@ -741,6 +742,7 @@ impl PlatformExprStrategyConfig { buy_scale_expr: "1.0".to_string(), exposure_expr: "1.0".to_string(), position_exposure_schedule: BTreeMap::new(), + position_exposure_timeline: crate::position_exposure::PositionExposureTimeline::default(), portfolio_drawdown_control: None, portfolio_loss_control: None, stop_loss_expr: String::new(), @@ -846,6 +848,7 @@ fn band_low(index_close) { } } +#[cfg(test)] fn scheduled_position_exposure( schedule: &BTreeMap, decision_date: NaiveDate, @@ -8651,9 +8654,9 @@ impl PlatformExprStrategy { 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, + let risk_on_exposure = self.config.position_exposure_timeline.exposure_at( + portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule, + strategy_exposure, ) .unwrap_or(strategy_exposure) .clamp(0.0, 1.0); @@ -9981,6 +9984,12 @@ impl PlatformExprStrategy { } } } + if let Some(bps)=self.config.position_exposure_timeline.scale_at(portfolio_loss_decision_at(ctx)) { + let before=intents.len(); + intents=intents.into_iter().map(|intent|crate::position_exposure::scale_explicit_intent(intent,bps,ctx.open_orders)) + .collect::,_>>().map_err(BacktestError::Execution)?.into_iter().flatten().collect(); + diagnostics.push(format!("position_override_scale requested_bps={bps} original_intents={before} emitted_intents={}",intents.len())); + } Ok((intents, diagnostics)) } diff --git a/crates/fidc-core/src/platform_stock_pool.rs b/crates/fidc-core/src/platform_stock_pool.rs index 964d5ea..ef78255 100644 --- a/crates/fidc-core/src/platform_stock_pool.rs +++ b/crates/fidc-core/src/platform_stock_pool.rs @@ -204,12 +204,10 @@ impl PlatformExprStrategy { let (base_ratio, reserve_cash) = pool::stock_pool_funding_from_configuration(&program.allocation_policy) .map_err(BacktestError::Execution)?; - let ratio = self - .config - .position_exposure_schedule - .range(..=ctx.decision_date) - .next_back() - .map(|(_, value)| (*value * 10000.).round() as i64) + let ratio = self.config.position_exposure_timeline + .exposure_at(portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule, + f64::from(base_ratio)/10000.) + .map(|value| (value * 10000.).round() as i64) .unwrap_or(i64::from(base_ratio)); let invest_ratio_bps = i32::try_from(ratio) .ok() diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index c3d3a5d..ac9511e 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -949,6 +949,8 @@ pub struct StrategyExpressionRiskConfig { pub exposure_expr: Option, #[serde(default, alias = "position_exposure_schedule")] pub position_exposure_schedule: Vec, + #[serde(default, alias = "position_exposure_events")] + pub position_exposure_events: Vec, #[serde(default)] pub portfolio_drawdown_control: Option, #[serde(default)] @@ -2228,6 +2230,7 @@ pub fn platform_expr_config_from_spec( expr.clone() }; } + cfg.position_exposure_timeline = crate::position_exposure::PositionExposureTimeline::from_events(&risk.position_exposure_events)?; for point in &risk.position_exposure_schedule { let effective_date = NaiveDate::parse_from_str( point.effective_date.trim(), diff --git a/crates/fidc-core/src/position_exposure.rs b/crates/fidc-core/src/position_exposure.rs new file mode 100644 index 0000000..6301a41 --- /dev/null +++ b/crates/fidc-core/src/position_exposure.rs @@ -0,0 +1,428 @@ +//! Dated manual adjustments are ordered facts; restoring is not a 100% target. +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum PositionExposureAction { + Scale { + #[serde(rename = "requestedBps", alias = "requested_bps")] + requested_bps: i32, + }, + Set { + #[serde(rename = "targetExposureBps", alias = "target_exposure_bps")] + target_exposure_bps: i32, + }, + Restore, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PositionExposureEvent { + #[serde(alias = "event_id")] + pub event_id: String, + pub sequence: u64, + #[serde(alias = "effective_at")] + pub effective_at: DateTime, + #[serde(flatten)] + pub action: PositionExposureAction, +} + +#[derive(Debug, Clone, Default)] +pub struct PositionExposureTimeline { + events: BTreeMap<(DateTime, u64), PositionExposureAction>, +} + +impl PositionExposureTimeline { + pub fn from_events(events: &[PositionExposureEvent]) -> Result { + let mut result = Self::default(); + let mut ids = BTreeSet::new(); + let mut sequences = BTreeSet::new(); + for event in events { + if event.event_id.trim().is_empty() || !ids.insert(event.event_id.as_str()) { + return Err("position exposure event id is missing or duplicated".into()); + } + if event.sequence == 0 || !sequences.insert(event.sequence) { + return Err("position exposure event sequence must be positive and unique".into()); + } + if let PositionExposureAction::Scale { requested_bps } = event.action + && !(0..=10000).contains(&requested_bps) + { + return Err("position exposure scale must be between 0 and 10000 bps".into()); + } + if let PositionExposureAction::Set { + target_exposure_bps, + } = event.action + && !(0..=10_000).contains(&target_exposure_bps) + { + return Err("position exposure target must be between 0 and 10000 bps".into()); + } + result + .events + .insert((event.effective_at, event.sequence), event.action.clone()); + } + Ok(result) + } + + /// Legacy day-level contracts remain day-level; never invent intraday times. + pub fn exposure_at( + &self, + at: DateTime, + execution_date: NaiveDate, + legacy: &BTreeMap, + strategy_exposure: f64, + ) -> Option { + match self + .events + .range(..=(at, u64::MAX)) + .next_back() + .map(|(_, action)| action) + { + Some(PositionExposureAction::Scale { requested_bps }) => { + Some(strategy_exposure * f64::from(*requested_bps) / 10000.) + } + Some(PositionExposureAction::Set { + target_exposure_bps, + }) => Some(f64::from(*target_exposure_bps) / 10_000.), + Some(PositionExposureAction::Restore) => None, + None => legacy + .range(..=execution_date) + .next_back() + .map(|(_, value)| *value), + } + } + + pub fn scale_at(&self, at: DateTime) -> Option { + match self + .events + .range(..=(at, u64::MAX)) + .next_back() + .map(|(_, action)| action) + { + Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps), + _ => None, + } + } +} + +/// Scale new buys and desired targets without weakening sell/reduction or +/// cancellation instructions. Prices, subscriptions and cash flows are intact. +pub fn scale_explicit_intent( + mut intent: crate::OrderIntent, + bps: i32, + open_orders: &[crate::OpenOrderView], +) -> Result, String> { + use crate::OrderIntent as I; + if !(0..=10000).contains(&bps) { + return Err("position scale out of range".into()); + } + if bps == 10000 { + return Ok(Some(intent)); + } + if let I::WithTimeInForce { + intent: inner, + time_in_force, + } = intent + { + return Ok( + scale_explicit_intent(*inner, bps, open_orders)?.map(|intent| I::WithTimeInForce { + intent: Box::new(intent), + time_in_force, + }), + ); + } + let integer = |value: i32| ((i64::from(value) * i64::from(bps)) / 10000) as i32; + let amount = |value: f64, target: bool| -> Result { + if !value.is_finite() || (target && value < 0.) { + return Err("position override received an invalid original amount".into()); + } + Ok(if value > 0. { + value * f64::from(bps) / 10000. + } else { + value + }) + }; + match &mut intent { + I::Shares { quantity, .. } + | I::LimitShares { quantity, .. } + | I::Lots { lots: quantity, .. } + | I::LimitLots { lots: quantity, .. } => { + if *quantity > 0 { + *quantity = integer(*quantity); + if *quantity == 0 { + return Ok(None); + } + } + } + I::TargetShares { + target_quantity, .. + } + | I::LimitTargetShares { + target_quantity, .. + } => { + if *target_quantity < 0 { + return Err("position override received a negative target quantity".into()); + } + *target_quantity = integer(*target_quantity); + } + I::Value { value, .. } + | I::LimitValue { value, .. } + | I::AlgoValue { value, .. } + | I::Percent { percent: value, .. } + | I::LimitPercent { percent: value, .. } + | I::AlgoPercent { percent: value, .. } => { + *value = amount(*value, false)?; + if *value == 0. { + return Ok(None); + } + } + I::TargetValue { target_value, .. } + | I::LimitTargetValue { target_value, .. } + | I::TimedTargetValue { target_value, .. } + | I::TargetPercent { + target_percent: target_value, + .. + } + | I::LimitTargetPercent { + target_percent: target_value, + .. + } => { + *target_value = amount(*target_value, true)?; + } + I::TargetPortfolioSmart { target_weights, .. } => { + for value in target_weights.values_mut() { + *value = amount(*value, true)?; + } + } + I::ModifyOrder { + order_id, + new_total_quantity: Some(quantity), + .. + } => { + let order = open_orders + .iter() + .find(|order| order.order_id == *order_id) + .ok_or("position override cannot resolve the order being modified")?; + if order.side == crate::OrderSide::Buy && *quantity > order.requested_quantity { + let extra = u64::from(*quantity - order.requested_quantity) * bps as u64 / 10000; + *quantity = order.requested_quantity + extra as u32; + } + } + I::Futures { .. } | I::StockPool { .. } => { + return Err("manual equity scaling cannot transform this intent kind".into()); + } + I::ModifyOrder { .. } + | I::CancelOrder { .. } + | I::CancelSymbol { .. } + | I::CancelAll { .. } + | I::UpdateUniverse { .. } + | I::Subscribe { .. } + | I::Unsubscribe { .. } + | I::DepositWithdraw { .. } + | I::FinanceRepay { .. } + | I::SetManagementFeeRate { .. } => {} + I::WithTimeInForce { .. } => unreachable!("wrapper handled first"), + } + Ok(Some(intent)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn scalar_preserves_strategy_risk_off_and_restore_keeps_original_exposure() { + let at = DateTime::parse_from_rfc3339("2026-01-05T09:30:00+08:00") + .unwrap() + .with_timezone(&Utc); + let event = PositionExposureEvent { + event_id: "scale".into(), + sequence: 1, + effective_at: at, + action: PositionExposureAction::Scale { + requested_bps: 5000, + }, + }; + let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap(); + assert_eq!( + timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.), + Some(0.) + ); + assert_eq!( + timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.2), + Some(0.1) + ); + let restored = PositionExposureEvent { + event_id: "restore".into(), + sequence: 2, + effective_at: at, + action: PositionExposureAction::Restore, + }; + let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap(); + assert_eq!( + timeline + .exposure_at( + at, + at.date_naive(), + &BTreeMap::from([(at.date_naive(), 1.)]), + 0.2 + ) + .unwrap_or(0.2), + 0.2 + ); + } + + #[test] + fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() { + use crate::OrderIntent as I; + let symbol = "000001.SZ".to_string(); + let reason = "fixture".to_string(); + for bps in [0, 3000, 5000, 10000] { + let ratio = f64::from(bps) / 10000.; + let buy = I::LimitShares { + symbol: symbol.clone(), + quantity: 1000, + limit_price: 12.345, + reason: reason.clone(), + }; + let scaled = scale_explicit_intent(buy, bps, &[]).unwrap(); + if bps == 0 { + assert!(scaled.is_none()) + } else if let Some(I::LimitShares { + quantity, + limit_price, + .. + }) = scaled + { + assert_eq!(quantity, (1000. * ratio) as i32); + assert_eq!(limit_price, 12.345); + } else { + panic!("wrong intent") + } + let sell = I::Shares { + symbol: symbol.clone(), + quantity: -1000, + reason: reason.clone(), + }; + assert!(matches!( + scale_explicit_intent(sell, bps, &[]).unwrap(), + Some(I::Shares { + quantity: -1000, + .. + }) + )); + let clear = I::TargetShares { + symbol: symbol.clone(), + target_quantity: 0, + reason: reason.clone(), + }; + assert!(matches!( + scale_explicit_intent(clear, bps, &[]).unwrap(), + Some(I::TargetShares { + target_quantity: 0, + .. + }) + )); + let target = I::TargetPercent { + symbol: symbol.clone(), + target_percent: 0.2, + reason: reason.clone(), + }; + if let Some(I::TargetPercent { target_percent, .. }) = + scale_explicit_intent(target, bps, &[]).unwrap() + { + assert!((target_percent - 0.2 * ratio).abs() < 1e-12) + } else { + panic!("wrong target") + } + let deposit = I::DepositWithdraw { + amount: 123.456, + receiving_days: 2, + reason: reason.clone(), + }; + assert!(matches!( + scale_explicit_intent(deposit, bps, &[]).unwrap(), + Some(I::DepositWithdraw { + amount: 123.456, + receiving_days: 2, + .. + }) + )); + } + assert!( + scale_explicit_intent( + I::TargetValue { + symbol, + target_value: f64::NAN, + reason + }, + 0, + &[] + ) + .is_err() + ); + } + + #[test] + fn same_day_adjustments_restore_and_future_events_keep_their_own_times() { + let events: Vec = serde_json::from_value(json!([ + {"eventId":"first","sequence":1,"effectiveAt":"2026-09-10T10:00:00+08:00","action":"set","targetExposureBps":0}, + {"eventId":"second","sequence":2,"effectiveAt":"2026-09-10T13:00:00+08:00","action":"set","targetExposureBps":5000}, + {"eventId":"restore","sequence":3,"effectiveAt":"2026-09-10T14:00:00+08:00","action":"restore"}, + {"eventId":"future","sequence":4,"effectiveAt":"2026-09-11T10:00:00+08:00","action":"set","targetExposureBps":1000} + ])).unwrap(); + let timeline = PositionExposureTimeline::from_events(&events).unwrap(); + let date = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap(); + let legacy = BTreeMap::from([(date.pred_opt().unwrap(), 0.8)]); + for (time, expected) in [ + ("09:30:00", Some(0.8)), + ("10:00:00", Some(0.)), + ("12:59:59", Some(0.)), + ("13:00:00", Some(0.5)), + ("14:00:00", None), + ("15:00:00", None), + ] { + let at = DateTime::parse_from_rfc3339(&format!("2026-09-10T{time}+08:00")) + .unwrap() + .with_timezone(&Utc); + assert_eq!( + timeline.exposure_at(at, date, &legacy, 0.2), + expected, + "{time}" + ); + } + let next_open = DateTime::parse_from_rfc3339("2026-09-11T09:30:00+08:00") + .unwrap() + .with_timezone(&Utc); + assert_eq!( + timeline.exposure_at(next_open, date.succ_opt().unwrap(), &legacy, 0.2), + None + ); + } + + #[test] + fn rejects_unknown_actions_duplicate_identity_and_invalid_bps() { + let valid = json!({"eventId":"one","sequence":1,"effectiveAt":"2026-09-10T09:30:00+08:00","action":"set","targetExposureBps":5000}); + for (key, value) in [ + ("action", json!("other")), + ("effectiveAt", json!("2026-09-10 09:30:00")), + ("targetExposureBps", json!(null)), + ] { + let mut invalid = valid.clone(); + invalid[key] = value; + assert!(serde_json::from_value::(invalid).is_err()); + } + let event: PositionExposureEvent = serde_json::from_value(valid).unwrap(); + assert!(PositionExposureTimeline::from_events(&[event.clone(), event.clone()]).is_err()); + let mut invalid = event.clone(); + invalid.action = PositionExposureAction::Set { + target_exposure_bps: 10001, + }; + assert!(PositionExposureTimeline::from_events(&[invalid]).is_err()); + let mut duplicate = event.clone(); + duplicate.event_id = "two".into(); + assert!(PositionExposureTimeline::from_events(&[event, duplicate]).is_err()); + } +} diff --git a/crates/fidc-core/tests/stock_pool_execution_contract.rs b/crates/fidc-core/tests/stock_pool_execution_contract.rs index b9f027d..93c38fc 100644 --- a/crates/fidc-core/tests/stock_pool_execution_contract.rs +++ b/crates/fidc-core/tests/stock_pool_execution_contract.rs @@ -628,6 +628,37 @@ fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translat } } +#[test] +fn pool_position_adjustments_use_execution_clock_and_restore_original_twenty_percent() { + for timed in [false,true] { + let program=StockPoolProgram { schema_version:1,pool_id:"position-clock".into(),version_id:"v1".into(), + members:contract(day(2),1,false).members,exit_signals:vec![], + allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":2000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":true}}), + timing_policy:serde_json::json!({"auto_execute":true,"pricing_mode":"first_tick"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into() }; + let risk=if timed {serde_json::json!({"positionExposureEvents":[ + {"eventId":"zero","sequence":1,"effectiveAt":"2026-01-05T09:30:00+08:00","action":"set","targetExposureBps":0}, + {"eventId":"restore","sequence":2,"effectiveAt":"2026-01-06T09:30:00+08:00","action":"restore"} + ]})}else{serde_json::json!({"positionExposureSchedule":[{"effectiveDate":"2026-01-05","targetExposureBps":1000}]})}; + let mut config=platform_expr_config_from_value("position-clock",&code(1),&serde_json::json!({ + "stockPool":program,"signalSymbol":code(1),"benchmark":{"instrumentId":"000300.SH"},"universe":{"include":[code(1),code(2)]}, + "runtimeExpressions":{"risk":risk} + })).unwrap(); + config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1.0e12".into(); + config.stock_filter_expr="true".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr="0".into(); + config.matching_type=MatchingType::NextBarOpen; + let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false),BacktestConfig { + // The raw engine retains its first signal day as a cash baseline; + // Jan 2's signal executes Jan 5, across the fixture weekend. + initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)), + decision_lag_trading_days:1,execution_price_field:PriceField::Open, + }).run().unwrap(); + assert_eq!(result.fills.len(),1,"timed={timed}, fills={:?}",result.fills); + assert_eq!(result.fills[0].symbol,code(1)); + assert_eq!(result.fills[0].quantity,if timed {300}else{100}); + assert_eq!(result.fills[0].date,if timed {day(6)}else{day(5)}); + } +} + #[test] fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() { for (ordinary, risk, quote, sold) in [ diff --git a/docs/position-exposure-events-20260913.md b/docs/position-exposure-events-20260913.md new file mode 100644 index 0000000..2da0e81 --- /dev/null +++ b/docs/position-exposure-events-20260913.md @@ -0,0 +1,13 @@ +# 仓位事件执行合同 + +2026-09-13。`runtimeExpressions.risk.positionExposureEvents` 使用带eventId、严格唯一sequence、UTC有效时点的事件;必须明确指定set、scale或restore。缺失动作、重复身份、非法比例和无时区日期均拒绝。 + +- scale用于人工比例乘数:普通轮动仍先计算策略自身仓位,0%指数择时不会被人工100%覆盖。显式权益买入和目标类委托,以及SignalBook产生的意图,同样按比例处理;不修改原SignalBook。 +- 卖出/减仓增量、零目标清仓、取消、订阅、现金流和价格不被缩量。对已有买单增加数量只缩放增加部分;无法确定被改单身份时拒绝。期货等未定义类型不静默转换。 +- set用于股票池投入比例等明确绝对目标;restore恢复原策略/池规则,不转换成100%。旧日期级positionExposureSchedule保留原粒度,新的恢复事件不再回落到旧人工值。 +- 比例按实际执行时点读取;股票池不再用信号日读取覆盖值。原引擎首信号日现金基线和next-open调度合同不改变。 +- 不改变OHLCV、费用、价格精度、证券生命周期或成交量容量合同。 + +验证覆盖同日多次调整、未来事件隔离、0/30/50/100%、20%原策略恢复、显式委托与现金流、以及原始引擎跨周末的股票池回放:1月2日信号在1月5日执行,1月5日覆盖在该日生效,1月6日恢复20%而不是100%。测试行情明确是隔离夹具,不代表真实历史或券商成交验收。 + +交易侧用不可变操作审计提供事件,保留运行任务/账户绑定和原始请求。此模块不自己下单或创建新的回测,不读取用户资金账户。未完成的独立人工调仓命令与逐笔人工交易影子回放仍需另行验收,不能据时间线通过声明所有调仓路径完成。