接入独立手工仓位时间线并保留原策略配置
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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`。本节不替代前述时钟证据,也不宣称全部矩阵完成。
|
||||
|
||||
Reference in New Issue
Block a user