diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index af03644..443939d 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -627,8 +627,18 @@ impl BacktestEngine { } } - pub fn with_observed_manual_executions(mut self, replay: crate::manual_execution::ManualExecutionReplay) -> Result { + pub fn with_observed_manual_executions( + mut self, + replay: crate::manual_execution::ManualExecutionReplay, + ) -> Result + where + S: Strategy, + { replay.validate().map_err(BacktestError::Execution)?; + self.strategy.bind_runtime_position_configuration( + &replay.position_exposure_events, + &replay.legacy_position_exposure_bps, + )?; self.manual_execution_source = Some(std::sync::Arc::new(replay)); Ok(self) } @@ -5416,28 +5426,73 @@ mod tests { fn observed_manual_replay(date: NaiveDate, times: &[(u32, u32)]) -> crate::manual_execution::ManualExecutionReplay { use crate::manual_execution::*; - let utc = |local: NaiveDateTime| chrono::DateTime::::from_naive_utc_and_offset(local - chrono::Duration::hours(8), chrono::Utc); - let actions = times.iter().enumerate().map(|(index, &(hour, minute))| { - let observed = date.and_hms_opt(hour, minute, 0).unwrap(); - let executed = if hour < 9 || (hour == 9 && minute < 25) { - date.pred_opt().unwrap().and_hms_opt(15, 20, 0).unwrap() - } else { observed.min(date.and_hms_opt(15, 20, 0).unwrap()) }; - let created = utc(executed - chrono::Duration::seconds(1)); - ManualExecutionAction { action_id: format!("manual-action-{index}"), source: ManualExecutionSource::ManualSecurityTrade, - audit_event_ids: vec![format!("manual-audit-{index}")], confirmed_at: created, confirmation_observed_at: created, - outcome: ManualActionOutcome::OrdersTerminal, orders: vec![ManualExecutionOrder { - order_id: format!("manual-order-{index}"), broker_order_id: Some(format!("broker-order-{index}")), source_adapter: Some("gt-api".into()), - symbol: SYMBOL.into(), side: OrderSide::Buy, quantity: 100, order_created_at: created, - terminal_observed_at: utc(observed), terminal_status: ManualOrderTerminalStatus::Filled, - fills: vec![ManualExecutionFill { trade_id: format!("manual-trade-{index}"), observation_event_id: format!("manual-receipt-{index}"), - observation_sequence: index as u64 + 1, fee_observation_event_id: format!("manual-receipt-{index}"), fee_observation_sequence: index as u64 + 1, - fee_observed_at: utc(observed), trade_date: executed.date(), executed_at: utc(executed), observed_at: utc(observed), - timestamp_precision: ManualTimestampPrecision::Second, quantity: 100, price: 10.into(), commission: Some("1.5".parse().unwrap()), - stamp_tax: None, transfer_fee: None, total_fee: "1.5".parse().unwrap() }], - }] } - }).collect(); - let mut replay = ManualExecutionReplay { schema: MANUAL_REPLAY_SCHEMA.into(), runtime_id: "manual-runtime".into(), account_id: "manual-account".into(), - source_contract_sha256: "a".repeat(64), content_sha256: String::new(), observation_cutoff: utc(date.and_hms_opt(23, 59, 59).unwrap()), actions }; + let utc = |local: NaiveDateTime| { + chrono::DateTime::::from_naive_utc_and_offset( + local - chrono::Duration::hours(8), + chrono::Utc, + ) + }; + let actions = times + .iter() + .enumerate() + .map(|(index, &(hour, minute))| { + let observed = date.and_hms_opt(hour, minute, 0).unwrap(); + let executed = if hour < 9 || (hour == 9 && minute < 25) { + date.pred_opt().unwrap().and_hms_opt(15, 20, 0).unwrap() + } else { + observed.min(date.and_hms_opt(15, 20, 0).unwrap()) + }; + let created = utc(executed - chrono::Duration::seconds(1)); + ManualExecutionAction { + action_id: format!("manual-action-{index}"), + source: ManualExecutionSource::ManualSecurityTrade, + audit_event_ids: vec![format!("manual-audit-{index}")], + confirmed_at: created, + confirmation_observed_at: created, + outcome: ManualActionOutcome::OrdersTerminal, + orders: vec![ManualExecutionOrder { + order_id: format!("manual-order-{index}"), + broker_order_id: Some(format!("broker-order-{index}")), + source_adapter: Some("gt-api".into()), + symbol: SYMBOL.into(), + side: OrderSide::Buy, + quantity: 100, + order_created_at: created, + terminal_observed_at: utc(observed), + terminal_status: ManualOrderTerminalStatus::Filled, + fills: vec![ManualExecutionFill { + trade_id: format!("manual-trade-{index}"), + observation_event_id: format!("manual-receipt-{index}"), + observation_sequence: index as u64 + 1, + fee_observation_event_id: format!("manual-receipt-{index}"), + fee_observation_sequence: index as u64 + 1, + fee_observed_at: utc(observed), + trade_date: executed.date(), + executed_at: utc(executed), + observed_at: utc(observed), + timestamp_precision: ManualTimestampPrecision::Second, + quantity: 100, + price: 10.into(), + commission: Some("1.5".parse().unwrap()), + stamp_tax: None, + transfer_fee: None, + total_fee: "1.5".parse().unwrap(), + }], + }], + } + }) + .collect(); + let mut replay = ManualExecutionReplay { + schema: MANUAL_REPLAY_SCHEMA.into(), + runtime_id: "manual-runtime".into(), + account_id: "manual-account".into(), + source_contract_sha256: "a".repeat(64), + content_sha256: String::new(), + observation_cutoff: utc(date.and_hms_opt(23, 59, 59).unwrap()), + actions, + position_exposure_events: vec![], + legacy_position_exposure_bps: BTreeMap::new(), + }; replay.content_sha256 = replay.content_digest().unwrap(); replay.validate().unwrap(); replay diff --git a/crates/fidc-core/src/manual_execution.rs b/crates/fidc-core/src/manual_execution.rs index adc753f..e8a101f 100644 --- a/crates/fidc-core/src/manual_execution.rs +++ b/crates/fidc-core/src/manual_execution.rs @@ -12,7 +12,7 @@ use crate::events::OrderSide; use crate::{DataSet, FixedMoney, PortfolioState}; use rust_decimal::prelude::ToPrimitive; -pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v2"; +pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v3"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] @@ -24,6 +24,10 @@ pub struct ManualExecutionReplay { pub content_sha256: String, pub observation_cutoff: DateTime, pub actions: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub position_exposure_events: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub legacy_position_exposure_bps: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -162,6 +166,19 @@ fn identifier(value: &str) -> Result<(), String> { } impl ManualExecutionReplay { + /// Market/indicator data is needed for securities whose observed fills + /// change the portfolio. A rejected, never-filled order is not data demand. + pub fn required_data_symbols(&self) -> Result, String> { + self.validate()?; + Ok(self + .actions + .iter() + .flat_map(|action| &action.orders) + .filter(|order| !order.fills.is_empty()) + .map(|order| order.symbol.clone()) + .collect()) + } + pub fn observations(&self) -> Result>, String> { self.validate()?; let mut observations = Vec::new(); @@ -190,9 +207,30 @@ impl ManualExecutionReplay { } pub fn validate(&self) -> Result<(), String> { - if self.schema != MANUAL_REPLAY_SCHEMA { + if self.schema != MANUAL_REPLAY_SCHEMA + && self.schema != "fidc.observed-manual-executions/v2" + { return Err("unsupported manual replay schema".into()); } + if self.schema == "fidc.observed-manual-executions/v2" + && (!self.position_exposure_events.is_empty() + || !self.legacy_position_exposure_bps.is_empty()) + { + return Err("runtime configuration requires manual replay v3".into()); + } + crate::position_exposure::PositionExposureTimeline::from_events( + &self.position_exposure_events, + )?; + if self.position_exposure_events.iter().any(|event| event.effective_at > self.observation_cutoff) { + return Err("observed runtime position event is after the evidence cutoff".into()); + } + if self + .legacy_position_exposure_bps + .values() + .any(|value| !(0..=10000).contains(value)) + { + return Err("legacy manual exposure is outside 0..10000 bps".into()); + } identifier(&self.runtime_id)?; identifier(&self.account_id)?; if self.source_contract_sha256.len() != 64 diff --git a/crates/fidc-core/src/manual_execution/tests.rs b/crates/fidc-core/src/manual_execution/tests.rs index 44e5055..f61b102 100644 --- a/crates/fidc-core/src/manual_execution/tests.rs +++ b/crates/fidc-core/src/manual_execution/tests.rs @@ -42,6 +42,57 @@ fn complete_exact_decimal_evidence_allows_later_observation_and_retains_source_d ); } +#[test] +fn data_scope_only_contains_actual_filled_securities_and_validates_the_source() { + let mut input = sample(); + let mut rejected = input.actions[0].orders[0].clone(); + rejected.order_id = "rejected-order".into(); + rejected.broker_order_id = None; + rejected.source_adapter = None; + rejected.symbol = "510300.SH".into(); + rejected.terminal_status = ManualOrderTerminalStatus::Rejected; + rejected.fills.clear(); + input.actions[0].orders.push(rejected); + reseal(&mut input); + assert_eq!( + input.required_data_symbols().unwrap(), + BTreeSet::from(["000001.SZ".into()]) + ); + input.actions[0].orders[0].symbol = "600000.SH".into(); + assert!(input.required_data_symbols().is_err()); +} + +#[test] +fn v2_facts_keep_their_encoding_but_cannot_silently_carry_new_runtime_settings() { + let mut input = sample(); + input.schema = "fidc.observed-manual-executions/v2".into(); + reseal(&mut input); + input.validate().unwrap(); + let old = serde_json::to_value(&input).unwrap(); + assert!(old.get("positionExposureEvents").is_none()); + assert!(old.get("legacyPositionExposureBps").is_none()); + input + .legacy_position_exposure_bps + .insert(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(), 5000); + reseal(&mut input); + assert!(input.validate().is_err()); + input.schema = MANUAL_REPLAY_SCHEMA.into(); + reseal(&mut input); + input.validate().unwrap(); +} + +#[test] +fn runtime_position_events_cannot_claim_observations_after_the_source_cutoff() { + let mut input = sample(); + input.position_exposure_events.push(serde_json::from_value(json!({ + "eventId": "position-event", "sequence": 1, "effectiveAt": input.observation_cutoff, + "action": "scale", "requestedBps": 5000 + })).unwrap()); + semantic_result(&input).unwrap(); + input.position_exposure_events[0].effective_at += chrono::Duration::nanoseconds(1); + assert!(semantic_result(&input).unwrap_err().contains("after the evidence cutoff")); +} + #[test] fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() { let original = serde_json::to_value(sample()).unwrap(); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index b1a2cb7..7a1b14d 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -653,6 +653,8 @@ pub struct PlatformExprStrategyConfig { pub exposure_expr: String, pub position_exposure_schedule: BTreeMap, pub position_exposure_timeline: crate::position_exposure::PositionExposureTimeline, + pub runtime_position_exposure_timeline: crate::position_exposure::PositionExposureTimeline, + pub runtime_position_exposure_schedule: BTreeMap, pub portfolio_drawdown_control: Option, pub portfolio_loss_control: Option, pub stop_loss_expr: String, @@ -742,7 +744,11 @@ 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(), + position_exposure_timeline: crate::position_exposure::PositionExposureTimeline::default( + ), + runtime_position_exposure_timeline: + crate::position_exposure::PositionExposureTimeline::default(), + runtime_position_exposure_schedule: BTreeMap::new(), portfolio_drawdown_control: None, portfolio_loss_control: None, stop_loss_expr: String::new(), @@ -8656,13 +8662,28 @@ 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 = 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); - let mut exposure = risk_on_exposure; + 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); + let mut exposure = self + .config + .runtime_position_exposure_timeline + .exposure_at( + portfolio_loss_decision_at(ctx), + ctx.execution_date, + &self.config.runtime_position_exposure_schedule, + risk_on_exposure, + ) + .unwrap_or(risk_on_exposure) + .clamp(0., 1.); if let Some(controller) = self.portfolio_drawdown_controller.as_mut() { exposure = controller.update(ctx.decision_date, day.total_value, exposure)?.target_exposure; } @@ -9986,10 +10007,28 @@ 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(); + for bps in [ + self.config + .position_exposure_timeline + .scale_at(portfolio_loss_decision_at(ctx)), + self.config + .runtime_position_exposure_timeline + .scale_at(portfolio_loss_decision_at(ctx)), + ] + .into_iter() + .flatten() + { + 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)) @@ -12396,10 +12435,41 @@ impl PlatformExprStrategy { } impl Strategy for PlatformExprStrategy { - fn on_observed_manual_execution(&mut self, execution: &crate::manual_execution::ManualReplayApplication) -> Result<(), BacktestError> { - let date = execution.executed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive(); - let history = match execution.side { OrderSide::Buy => &mut self.protection_last_buys, OrderSide::Sell => &mut self.protection_last_sells }; - history.entry(execution.symbol.clone()).and_modify(|previous| *previous = (*previous).max(date)).or_insert(date); + fn bind_runtime_position_configuration( + &mut self, + events: &[crate::position_exposure::PositionExposureEvent], + legacy: &BTreeMap, + ) -> Result<(), BacktestError> { + let timeline = crate::position_exposure::PositionExposureTimeline::from_events(events) + .map_err(BacktestError::Execution)?; + if legacy.values().any(|value| !(0..=10000).contains(value)) { + return Err(BacktestError::Execution( + "invalid runtime exposure schedule".into(), + )); + } + self.config.runtime_position_exposure_timeline = timeline; + self.config.runtime_position_exposure_schedule = legacy + .iter() + .map(|(date, bps)| (*date, f64::from(*bps) / 10000.)) + .collect(); + Ok(()) + } + fn on_observed_manual_execution( + &mut self, + execution: &crate::manual_execution::ManualReplayApplication, + ) -> Result<(), BacktestError> { + let date = execution + .executed_at + .with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()) + .date_naive(); + let history = match execution.side { + OrderSide::Buy => &mut self.protection_last_buys, + OrderSide::Sell => &mut self.protection_last_sells, + }; + history + .entry(execution.symbol.clone()) + .and_modify(|previous| *previous = (*previous).max(date)) + .or_insert(date); Ok(()) } fn name(&self) -> &str { diff --git a/crates/fidc-core/src/platform_stock_pool.rs b/crates/fidc-core/src/platform_stock_pool.rs index ef78255..477ccec 100644 --- a/crates/fidc-core/src/platform_stock_pool.rs +++ b/crates/fidc-core/src/platform_stock_pool.rs @@ -182,6 +182,15 @@ impl PlatformExprStrategy { scope.push(symbol) } } + let allocation_weights = self + .config + .runtime_position_exposure_timeline + .allocation_weights_at(portfolio_loss_decision_at(ctx)) + .or_else(|| { + self.config + .position_exposure_timeline + .allocation_weights_at(portfolio_loss_decision_at(ctx)) + }); let members = scope .iter() .enumerate() @@ -198,15 +207,35 @@ impl PlatformExprStrategy { take_profit: constraints.default_take_profit, }); member.requested_order = index as i32; + if let Some(weights) = allocation_weights { + member.target_weight_bps = Some(*weights.get(symbol).unwrap_or(&0)); + } member }) .collect(); let (base_ratio, reserve_cash) = pool::stock_pool_funding_from_configuration(&program.allocation_policy) .map_err(BacktestError::Execution)?; - 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.) + let base_exposure = 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., + ) + .unwrap_or(f64::from(base_ratio) / 10000.); + let ratio = self + .config + .runtime_position_exposure_timeline + .exposure_at( + portfolio_loss_decision_at(ctx), + ctx.execution_date, + &self.config.runtime_position_exposure_schedule, + base_exposure, + ) + .or(Some(base_exposure)) .map(|value| (value * 10000.).round() as i64) .unwrap_or(i64::from(base_ratio)); let invest_ratio_bps = i32::try_from(ratio) diff --git a/crates/fidc-core/src/position_exposure.rs b/crates/fidc-core/src/position_exposure.rs index 6301a41..c9e1184 100644 --- a/crates/fidc-core/src/position_exposure.rs +++ b/crates/fidc-core/src/position_exposure.rs @@ -25,13 +25,19 @@ pub struct PositionExposureEvent { pub sequence: u64, #[serde(alias = "effective_at")] pub effective_at: DateTime, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "allocation_weights_bps" + )] + pub allocation_weights_bps: Option>, #[serde(flatten)] pub action: PositionExposureAction, } #[derive(Debug, Clone, Default)] pub struct PositionExposureTimeline { - events: BTreeMap<(DateTime, u64), PositionExposureAction>, + events: BTreeMap<(DateTime, u64), (PositionExposureAction, Option>)>, } impl PositionExposureTimeline { @@ -58,9 +64,24 @@ impl PositionExposureTimeline { { return Err("position exposure target must be between 0 and 10000 bps".into()); } - result - .events - .insert((event.effective_at, event.sequence), event.action.clone()); + if let Some(weights) = &event.allocation_weights_bps { + let target = match event.action { + PositionExposureAction::Set { + target_exposure_bps, + } => target_exposure_bps, + PositionExposureAction::Scale { requested_bps } => requested_bps, + PositionExposureAction::Restore => { + return Err( + "restoring strategy allocation cannot carry manual weights".into() + ); + } + }; + validate_allocation_weights(weights, target)?; + } + result.events.insert( + (event.effective_at, event.sequence), + (event.action.clone(), event.allocation_weights_bps.clone()), + ); } Ok(result) } @@ -77,7 +98,7 @@ impl PositionExposureTimeline { .events .range(..=(at, u64::MAX)) .next_back() - .map(|(_, action)| action) + .map(|(_, (action, _))| action) { Some(PositionExposureAction::Scale { requested_bps }) => { Some(strategy_exposure * f64::from(*requested_bps) / 10000.) @@ -98,12 +119,47 @@ impl PositionExposureTimeline { .events .range(..=(at, u64::MAX)) .next_back() - .map(|(_, action)| action) + .map(|(_, (action, _))| action) { Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps), _ => None, } } + + pub fn allocation_weights_at(&self, at: DateTime) -> Option<&BTreeMap> { + self.events + .range(..=(at, u64::MAX)) + .next_back() + .and_then(|(_, (_, weights))| weights.as_ref()) + } +} + +pub fn validate_allocation_weights( + weights: &BTreeMap, + exposure_bps: i32, +) -> Result<(), String> { + if !(0..=10000).contains(&exposure_bps) || weights.len() > 10000 { + return Err("invalid allocation exposure or weight count".into()); + } + for (symbol, weight) in weights { + if !(0..=10000).contains(weight) + || !symbol.rsplit_once('.').is_some_and(|(code, exchange)| { + code.len() == 6 + && code.bytes().all(|byte| byte.is_ascii_digit()) + && matches!(exchange, "SH" | "SZ" | "BJ") + }) + { + return Err( + "allocation weights require canonical stock/ETF symbols and 0..10000 bps".into(), + ); + } + } + if (weights.is_empty() && exposure_bps != 0) + || (!weights.is_empty() && weights.values().sum::() != 10000) + { + return Err("manual allocation weights must total 10000 bps; only a zero exposure may have no weights".into()); + } + Ok(()) } /// Scale new buys and desired targets without weakening sell/reduction or @@ -241,6 +297,7 @@ mod tests { event_id: "scale".into(), sequence: 1, effective_at: at, + allocation_weights_bps: None, action: PositionExposureAction::Scale { requested_bps: 5000, }, @@ -258,6 +315,7 @@ mod tests { event_id: "restore".into(), sequence: 2, effective_at: at, + allocation_weights_bps: None, action: PositionExposureAction::Restore, }; let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap(); @@ -274,6 +332,56 @@ mod tests { ); } + #[test] + fn allocation_is_dated_and_any_later_scalar_or_restore_clears_it() { + let at = DateTime::parse_from_rfc3339("2026-09-14T10:00:00+08:00") + .unwrap() + .with_timezone(&Utc); + let weights = BTreeMap::from([("000001.SZ".into(), 3000), ("510300.SH".into(), 7000)]); + let event = PositionExposureEvent { + event_id: "allocation".into(), + sequence: 1, + effective_at: at, + action: PositionExposureAction::Set { + target_exposure_bps: 8000, + }, + allocation_weights_bps: Some(weights.clone()), + }; + let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap(); + assert!( + timeline + .allocation_weights_at(at - chrono::Duration::seconds(1)) + .is_none() + ); + assert_eq!(timeline.allocation_weights_at(at), Some(&weights)); + for action in [ + PositionExposureAction::Set { + target_exposure_bps: 5000, + }, + PositionExposureAction::Restore, + ] { + let next = PositionExposureEvent { + event_id: "new".into(), + sequence: 2, + effective_at: at + chrono::Duration::seconds(1), + action, + allocation_weights_bps: None, + }; + assert!( + PositionExposureTimeline::from_events(&[event.clone(), next]) + .unwrap() + .allocation_weights_at(at + chrono::Duration::seconds(1)) + .is_none() + ); + } + assert!( + validate_allocation_weights(&BTreeMap::from([("000001.SZ".into(), 9000)]), 5000) + .is_err() + ); + assert!(validate_allocation_weights(&BTreeMap::new(), 1).is_err()); + assert!(validate_allocation_weights(&BTreeMap::new(), 0).is_ok()); + } + #[test] fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() { use crate::OrderIntent as I; diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index c2bd7d0..a5eae17 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -20,6 +20,18 @@ use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSe pub trait Strategy { fn name(&self) -> &str; + fn bind_runtime_position_configuration( + &mut self, + events: &[crate::position_exposure::PositionExposureEvent], + legacy: &BTreeMap, + ) -> Result<(), BacktestError> { + if !events.is_empty() || !legacy.is_empty() { + return Err(BacktestError::Execution( + "strategy does not implement runtime position configuration".into(), + )); + } + Ok(()) + } fn initial_subscriptions(&self) -> BTreeSet { BTreeSet::new() } diff --git a/crates/fidc-core/tests/stock_pool_execution_contract.rs b/crates/fidc-core/tests/stock_pool_execution_contract.rs index da33319..94500f0 100644 --- a/crates/fidc-core/tests/stock_pool_execution_contract.rs +++ b/crates/fidc-core/tests/stock_pool_execution_contract.rs @@ -770,6 +770,150 @@ fn pool_position_adjustments_use_execution_clock_and_restore_original_twenty_per } } +#[test] +fn runtime_allocation_is_separate_from_the_frozen_pool_and_restores_its_weights() { + let program = StockPoolProgram { + schema_version: 1, + pool_id: "manual-allocation".into(), + version_id: "v1".into(), + members: contract(day(2), 2, false).members, + exit_signals: vec![], + allocation_policy: serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":true}}), + timing_policy: serde_json::json!({"pricing_mode":"first_tick"}), + stop_take_policy: serde_json::json!({}), + out_of_pool_policy: "hold".into(), + }; + let mut cfg = platform_expr_config_from_value( + "manual-allocation", + "000300.SH", + &serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}}), + ) + .unwrap(); + cfg.market_cap_field = "close".into(); + cfg.market_cap_lower_expr = "0".into(); + cfg.market_cap_upper_expr = "1e12".into(); + cfg.stock_filter_expr = "true".into(); + cfg.selection_limit_expr = "2".into(); + cfg.selection_candidate_limit_expr = "2".into(); + cfg.rank_expr = "0".into(); + cfg.matching_type = MatchingType::NextBarOpen; + let mut replay:fidc_core::manual_execution::ManualExecutionReplay=serde_json::from_value(serde_json::json!({ + "schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"", + "observationCutoff":"2026-01-06T08:00:00Z","actions":[],"positionExposureEvents":[ + {"eventId":"weights","sequence":1,"effectiveAt":"2026-01-05T09:30:00+08:00","action":"set","targetExposureBps":8000,"allocationWeightsBps":{"000001.SZ":3000,"000002.SZ":7000}}, + {"eventId":"restore","sequence":2,"effectiveAt":"2026-01-06T09:30:00+08:00","action":"restore"} + ]})).unwrap(); + replay.content_sha256 = replay.content_digest().unwrap(); + let result = BacktestEngine::new( + data(false), + PlatformExprStrategy::new(cfg), + broker(false), + BacktestConfig { + 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, + }, + ) + .with_observed_manual_executions(replay) + .unwrap() + .run() + .unwrap(); + let quantities = |date| { + result + .daily_holdings + .iter() + .filter(|row| row.date == date) + .map(|row| (row.symbol.clone(), row.quantity)) + .collect::>() + }; + assert_eq!( + quantities(day(5)), + BTreeMap::from([(code(1), 300), (code(2), 1600)]), + "{:?}", + result.fills + ); + assert_eq!( + quantities(day(6)), + BTreeMap::from([(code(1), 700), (code(2), 1500)]), + "{:?}", + result.fills + ); + assert!( + result.manual_executions.is_empty(), + "parameter events are not fabricated fills" + ); +} + +#[test] +fn outside_manual_holding_data_does_not_become_a_pool_candidate() { + let program = StockPoolProgram { + schema_version: 1, + pool_id: "manual-data-scope".into(), + version_id: "v1".into(), + members: vec![contract(day(2), 1, false).members.remove(0)], + exit_signals: vec![], + allocation_policy: serde_json::json!({"target_holding_count":1,"invest_ratio_bps":2000}), + timing_policy: serde_json::json!({"pricing_mode":"first_tick"}), + stop_take_policy: serde_json::json!({}), + out_of_pool_policy: "hold".into(), + }; + let mut cfg = platform_expr_config_from_value( + "manual-data-scope", + "000300.SH", + &serde_json::json!({"stockPool":program,"universe":{"include":[code(1)]}}), + ) + .unwrap(); + cfg.market_cap_field = "close".into(); + cfg.market_cap_lower_expr = "0".into(); + cfg.market_cap_upper_expr = "1e12".into(); + cfg.stock_filter_expr = "true".into(); + cfg.selection_limit_expr = "1".into(); + cfg.selection_candidate_limit_expr = "2".into(); + cfg.rank_expr = "0".into(); + cfg.matching_type = MatchingType::NextBarOpen; + let fill = serde_json::json!({"tradeId":"fill","observationEventId":"receipt","observationSequence":1,"tradeDate":"2026-01-05","executedAt":"2026-01-05T01:31:00Z","observedAt":"2026-01-05T01:31:01Z", + "feeObservationEventId":"receipt","feeObservationSequence":1,"feeObservedAt":"2026-01-05T01:31:01Z","timestampPrecision":"second","quantity":100,"price":"10","totalFee":"0"}); + let order = serde_json::json!({"orderId":"external-order","sourceAdapter":"paper","symbol":code(2),"side":"Buy","quantity":100,"orderCreatedAt":"2026-01-05T01:30:00Z","terminalObservedAt":"2026-01-05T01:31:01Z","terminalStatus":"filled","fills":[fill]}); + let mut replay:fidc_core::manual_execution::ManualExecutionReplay=serde_json::from_value(serde_json::json!({ + "schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-01-06T08:00:00Z", + "actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],"confirmedAt":"2026-01-05T01:29:59Z","confirmationObservedAt":"2026-01-05T01:29:59Z","outcome":"orders_terminal","orders":[order]}]})).unwrap(); + replay.content_sha256 = replay.content_digest().unwrap(); + let result = BacktestEngine::new( + data(false), + PlatformExprStrategy::new(cfg), + broker(false), + BacktestConfig { + 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, + }, + ) + .with_observed_manual_executions(replay) + .unwrap() + .run() + .unwrap(); + assert!( + result.fills.iter().all(|fill| fill.symbol != code(2)), + "extra data cannot authorize an extra candidate" + ); + assert_eq!(result.manual_executions.len(), 1); + assert_eq!( + result + .holdings_summary + .iter() + .find(|row| row.symbol == code(2)) + .unwrap() + .quantity, + 100 + ); +} + #[test] fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() { for (ordinary, risk, quote, sold) in [ diff --git a/docs/manual-execution-clock-20260914.md b/docs/manual-execution-clock-20260914.md index 2ab4a70..a7fa0ca 100644 --- a/docs/manual-execution-clock-20260914.md +++ b/docs/manual-execution-clock-20260914.md @@ -42,3 +42,7 @@ Core872通过(9项原ignore不计通过),交易工作区619普通测试通 4. 核对GT正式总费用来源、整仓关键日志严格持久化及完整参数矩阵后再配套发布。 本轮未重启生产或发送委托。同期其他维护已将Backtest发布为Engine665653c/Service501f6d0;这不包含本文件所述主时钟候选。交易仍166998d/v2026.9.14.6,Source d5/PID1700096冻结与研究暂停不改。 + +## 2026-09-14 运行级仓位配置补充 + +v3 手工输入独立携带审计仓位/权重时间线与旧日级前缀,不覆盖原策略或股票池。仅已成交证券产生独立行情需求;补充范围不会成为选股候选。恢复跟随回到原规则,未来事件不能被伪称为截止时刻前已观察事实。Core 878 项本机通过,尚未部署;PG、期间隔离、权限与剩余联合验收见 `../../fidc-trading-platform/docs/shadow-manual-input-20260914.md`。本节不替代前述时钟证据,也不宣称全部矩阵完成。