接入独立手工仓位时间线并保留原策略配置

This commit is contained in:
boris
2026-09-14 15:48:44 +08:00
parent fb8192a286
commit f8955bfb18
9 changed files with 561 additions and 50 deletions
+78 -23
View File
@@ -627,8 +627,18 @@ impl<S, C, R> BacktestEngine<S, C, R> {
}
}
pub fn with_observed_manual_executions(mut self, replay: crate::manual_execution::ManualExecutionReplay) -> Result<Self, BacktestError> {
pub fn with_observed_manual_executions(
mut self,
replay: crate::manual_execution::ManualExecutionReplay,
) -> Result<Self, BacktestError>
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::<chrono::Utc>::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::<chrono::Utc>::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
+40 -2
View File
@@ -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<Utc>,
pub actions: Vec<ManualExecutionAction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub legacy_position_exposure_bps: BTreeMap<NaiveDate, i32>,
}
#[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<BTreeSet<String>, 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<Vec<ManualFillObservation<'_>>, 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
@@ -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();
+86 -16
View File
@@ -653,6 +653,8 @@ pub struct PlatformExprStrategyConfig {
pub exposure_expr: String,
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
pub position_exposure_timeline: crate::position_exposure::PositionExposureTimeline,
pub runtime_position_exposure_timeline: crate::position_exposure::PositionExposureTimeline,
pub runtime_position_exposure_schedule: BTreeMap<NaiveDate, f64>,
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
pub portfolio_loss_control: Option<PortfolioLossConfig>,
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::<Result<Vec<_>,_>>().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::<Result<Vec<_>, _>>()
.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<NaiveDate, i32>,
) -> 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 {
+32 -3
View File
@@ -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)
+114 -6
View File
@@ -25,13 +25,19 @@ pub struct PositionExposureEvent {
pub sequence: u64,
#[serde(alias = "effective_at")]
pub effective_at: DateTime<Utc>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "allocation_weights_bps"
)]
pub allocation_weights_bps: Option<BTreeMap<String, i32>>,
#[serde(flatten)]
pub action: PositionExposureAction,
}
#[derive(Debug, Clone, Default)]
pub struct PositionExposureTimeline {
events: BTreeMap<(DateTime<Utc>, u64), PositionExposureAction>,
events: BTreeMap<(DateTime<Utc>, u64), (PositionExposureAction, Option<BTreeMap<String, i32>>)>,
}
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<Utc>) -> Option<&BTreeMap<String, i32>> {
self.events
.range(..=(at, u64::MAX))
.next_back()
.and_then(|(_, (_, weights))| weights.as_ref())
}
}
pub fn validate_allocation_weights(
weights: &BTreeMap<String, i32>,
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::<i32>() != 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;
+12
View File
@@ -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<NaiveDate, i32>,
) -> 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<String> {
BTreeSet::new()
}
@@ -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::<BTreeMap<_, _>>()
};
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 [
+4
View File
@@ -42,3 +42,7 @@ Core872通过(9项原ignore不计通过),交易工作区619普通测试通
4. 核对GT正式总费用来源、整仓关键日志严格持久化及完整参数矩阵后再配套发布。
本轮未重启生产或发送委托。同期其他维护已将Backtest发布为Engine665653c/Service501f6d0;这不包含本文件所述主时钟候选。交易仍166998d/v2026.9.14.6Source d5/PID1700096冻结与研究暂停不改。
## 2026-09-14 运行级仓位配置补充
v3 手工输入独立携带审计仓位/权重时间线与旧日级前缀,不覆盖原策略或股票池。仅已成交证券产生独立行情需求;补充范围不会成为选股候选。恢复跟随回到原规则,未来事件不能被伪称为截止时刻前已观察事实。Core 878 项本机通过,尚未部署;PG、期间隔离、权限与剩余联合验收见 `../../fidc-trading-platform/docs/shadow-manual-input-20260914.md`。本节不替代前述时钟证据,也不宣称全部矩阵完成。