接入独立手工仓位时间线并保留原策略配置
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)?;
|
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));
|
self.manual_execution_source = Some(std::sync::Arc::new(replay));
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
@@ -5416,28 +5426,73 @@ mod tests {
|
|||||||
|
|
||||||
fn observed_manual_replay(date: NaiveDate, times: &[(u32, u32)]) -> crate::manual_execution::ManualExecutionReplay {
|
fn observed_manual_replay(date: NaiveDate, times: &[(u32, u32)]) -> crate::manual_execution::ManualExecutionReplay {
|
||||||
use crate::manual_execution::*;
|
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 utc = |local: NaiveDateTime| {
|
||||||
let actions = times.iter().enumerate().map(|(index, &(hour, minute))| {
|
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
||||||
let observed = date.and_hms_opt(hour, minute, 0).unwrap();
|
local - chrono::Duration::hours(8),
|
||||||
let executed = if hour < 9 || (hour == 9 && minute < 25) {
|
chrono::Utc,
|
||||||
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));
|
let actions = times
|
||||||
ManualExecutionAction { action_id: format!("manual-action-{index}"), source: ManualExecutionSource::ManualSecurityTrade,
|
.iter()
|
||||||
audit_event_ids: vec![format!("manual-audit-{index}")], confirmed_at: created, confirmation_observed_at: created,
|
.enumerate()
|
||||||
outcome: ManualActionOutcome::OrdersTerminal, orders: vec![ManualExecutionOrder {
|
.map(|(index, &(hour, minute))| {
|
||||||
order_id: format!("manual-order-{index}"), broker_order_id: Some(format!("broker-order-{index}")), source_adapter: Some("gt-api".into()),
|
let observed = date.and_hms_opt(hour, minute, 0).unwrap();
|
||||||
symbol: SYMBOL.into(), side: OrderSide::Buy, quantity: 100, order_created_at: created,
|
let executed = if hour < 9 || (hour == 9 && minute < 25) {
|
||||||
terminal_observed_at: utc(observed), terminal_status: ManualOrderTerminalStatus::Filled,
|
date.pred_opt().unwrap().and_hms_opt(15, 20, 0).unwrap()
|
||||||
fills: vec![ManualExecutionFill { trade_id: format!("manual-trade-{index}"), observation_event_id: format!("manual-receipt-{index}"),
|
} else {
|
||||||
observation_sequence: index as u64 + 1, fee_observation_event_id: format!("manual-receipt-{index}"), fee_observation_sequence: index as u64 + 1,
|
observed.min(date.and_hms_opt(15, 20, 0).unwrap())
|
||||||
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()),
|
let created = utc(executed - chrono::Duration::seconds(1));
|
||||||
stamp_tax: None, transfer_fee: None, total_fee: "1.5".parse().unwrap() }],
|
ManualExecutionAction {
|
||||||
}] }
|
action_id: format!("manual-action-{index}"),
|
||||||
}).collect();
|
source: ManualExecutionSource::ManualSecurityTrade,
|
||||||
let mut replay = ManualExecutionReplay { schema: MANUAL_REPLAY_SCHEMA.into(), runtime_id: "manual-runtime".into(), account_id: "manual-account".into(),
|
audit_event_ids: vec![format!("manual-audit-{index}")],
|
||||||
source_contract_sha256: "a".repeat(64), content_sha256: String::new(), observation_cutoff: utc(date.and_hms_opt(23, 59, 59).unwrap()), actions };
|
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.content_sha256 = replay.content_digest().unwrap();
|
||||||
replay.validate().unwrap();
|
replay.validate().unwrap();
|
||||||
replay
|
replay
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::events::OrderSide;
|
|||||||
use crate::{DataSet, FixedMoney, PortfolioState};
|
use crate::{DataSet, FixedMoney, PortfolioState};
|
||||||
use rust_decimal::prelude::ToPrimitive;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||||
@@ -24,6 +24,10 @@ pub struct ManualExecutionReplay {
|
|||||||
pub content_sha256: String,
|
pub content_sha256: String,
|
||||||
pub observation_cutoff: DateTime<Utc>,
|
pub observation_cutoff: DateTime<Utc>,
|
||||||
pub actions: Vec<ManualExecutionAction>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -162,6 +166,19 @@ fn identifier(value: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ManualExecutionReplay {
|
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> {
|
pub fn observations(&self) -> Result<Vec<ManualFillObservation<'_>>, String> {
|
||||||
self.validate()?;
|
self.validate()?;
|
||||||
let mut observations = Vec::new();
|
let mut observations = Vec::new();
|
||||||
@@ -190,9 +207,30 @@ impl ManualExecutionReplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
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());
|
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.runtime_id)?;
|
||||||
identifier(&self.account_id)?;
|
identifier(&self.account_id)?;
|
||||||
if self.source_contract_sha256.len() != 64
|
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]
|
#[test]
|
||||||
fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() {
|
fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() {
|
||||||
let original = serde_json::to_value(sample()).unwrap();
|
let original = serde_json::to_value(sample()).unwrap();
|
||||||
|
|||||||
@@ -653,6 +653,8 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub exposure_expr: String,
|
pub exposure_expr: String,
|
||||||
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
||||||
pub position_exposure_timeline: crate::position_exposure::PositionExposureTimeline,
|
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_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
||||||
pub portfolio_loss_control: Option<PortfolioLossConfig>,
|
pub portfolio_loss_control: Option<PortfolioLossConfig>,
|
||||||
pub stop_loss_expr: String,
|
pub stop_loss_expr: String,
|
||||||
@@ -742,7 +744,11 @@ impl PlatformExprStrategyConfig {
|
|||||||
buy_scale_expr: "1.0".to_string(),
|
buy_scale_expr: "1.0".to_string(),
|
||||||
exposure_expr: "1.0".to_string(),
|
exposure_expr: "1.0".to_string(),
|
||||||
position_exposure_schedule: BTreeMap::new(),
|
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_drawdown_control: None,
|
||||||
portfolio_loss_control: None,
|
portfolio_loss_control: None,
|
||||||
stop_loss_expr: String::new(),
|
stop_loss_expr: String::new(),
|
||||||
@@ -8656,13 +8662,28 @@ impl PlatformExprStrategy {
|
|||||||
let strategy_exposure = self
|
let strategy_exposure = self
|
||||||
.eval_float(ctx, &self.config.exposure_expr, day, None, None)?
|
.eval_float(ctx, &self.config.exposure_expr, day, None, None)?
|
||||||
.clamp(0.0, 1.0);
|
.clamp(0.0, 1.0);
|
||||||
let risk_on_exposure = self.config.position_exposure_timeline.exposure_at(
|
let risk_on_exposure = self
|
||||||
portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule,
|
.config
|
||||||
strategy_exposure,
|
.position_exposure_timeline
|
||||||
)
|
.exposure_at(
|
||||||
.unwrap_or(strategy_exposure)
|
portfolio_loss_decision_at(ctx),
|
||||||
.clamp(0.0, 1.0);
|
ctx.execution_date,
|
||||||
let mut exposure = risk_on_exposure;
|
&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() {
|
if let Some(controller) = self.portfolio_drawdown_controller.as_mut() {
|
||||||
exposure = controller.update(ctx.decision_date, day.total_value, exposure)?.target_exposure;
|
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)) {
|
for bps in [
|
||||||
let before=intents.len();
|
self.config
|
||||||
intents=intents.into_iter().map(|intent|crate::position_exposure::scale_explicit_intent(intent,bps,ctx.open_orders))
|
.position_exposure_timeline
|
||||||
.collect::<Result<Vec<_>,_>>().map_err(BacktestError::Execution)?.into_iter().flatten().collect();
|
.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()));
|
diagnostics.push(format!("position_override_scale requested_bps={bps} original_intents={before} emitted_intents={}",intents.len()));
|
||||||
}
|
}
|
||||||
Ok((intents, diagnostics))
|
Ok((intents, diagnostics))
|
||||||
@@ -12396,10 +12435,41 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Strategy for PlatformExprStrategy {
|
impl Strategy for PlatformExprStrategy {
|
||||||
fn on_observed_manual_execution(&mut self, execution: &crate::manual_execution::ManualReplayApplication) -> Result<(), BacktestError> {
|
fn bind_runtime_position_configuration(
|
||||||
let date = execution.executed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
|
&mut self,
|
||||||
let history = match execution.side { OrderSide::Buy => &mut self.protection_last_buys, OrderSide::Sell => &mut self.protection_last_sells };
|
events: &[crate::position_exposure::PositionExposureEvent],
|
||||||
history.entry(execution.symbol.clone()).and_modify(|previous| *previous = (*previous).max(date)).or_insert(date);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
|
|||||||
@@ -182,6 +182,15 @@ impl PlatformExprStrategy {
|
|||||||
scope.push(symbol)
|
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
|
let members = scope
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -198,15 +207,35 @@ impl PlatformExprStrategy {
|
|||||||
take_profit: constraints.default_take_profit,
|
take_profit: constraints.default_take_profit,
|
||||||
});
|
});
|
||||||
member.requested_order = index as i32;
|
member.requested_order = index as i32;
|
||||||
|
if let Some(weights) = allocation_weights {
|
||||||
|
member.target_weight_bps = Some(*weights.get(symbol).unwrap_or(&0));
|
||||||
|
}
|
||||||
member
|
member
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let (base_ratio, reserve_cash) =
|
let (base_ratio, reserve_cash) =
|
||||||
pool::stock_pool_funding_from_configuration(&program.allocation_policy)
|
pool::stock_pool_funding_from_configuration(&program.allocation_policy)
|
||||||
.map_err(BacktestError::Execution)?;
|
.map_err(BacktestError::Execution)?;
|
||||||
let ratio = self.config.position_exposure_timeline
|
let base_exposure = self
|
||||||
.exposure_at(portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule,
|
.config
|
||||||
f64::from(base_ratio)/10000.)
|
.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)
|
.map(|value| (value * 10000.).round() as i64)
|
||||||
.unwrap_or(i64::from(base_ratio));
|
.unwrap_or(i64::from(base_ratio));
|
||||||
let invest_ratio_bps = i32::try_from(ratio)
|
let invest_ratio_bps = i32::try_from(ratio)
|
||||||
|
|||||||
@@ -25,13 +25,19 @@ pub struct PositionExposureEvent {
|
|||||||
pub sequence: u64,
|
pub sequence: u64,
|
||||||
#[serde(alias = "effective_at")]
|
#[serde(alias = "effective_at")]
|
||||||
pub effective_at: DateTime<Utc>,
|
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)]
|
#[serde(flatten)]
|
||||||
pub action: PositionExposureAction,
|
pub action: PositionExposureAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct PositionExposureTimeline {
|
pub struct PositionExposureTimeline {
|
||||||
events: BTreeMap<(DateTime<Utc>, u64), PositionExposureAction>,
|
events: BTreeMap<(DateTime<Utc>, u64), (PositionExposureAction, Option<BTreeMap<String, i32>>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PositionExposureTimeline {
|
impl PositionExposureTimeline {
|
||||||
@@ -58,9 +64,24 @@ impl PositionExposureTimeline {
|
|||||||
{
|
{
|
||||||
return Err("position exposure target must be between 0 and 10000 bps".into());
|
return Err("position exposure target must be between 0 and 10000 bps".into());
|
||||||
}
|
}
|
||||||
result
|
if let Some(weights) = &event.allocation_weights_bps {
|
||||||
.events
|
let target = match event.action {
|
||||||
.insert((event.effective_at, event.sequence), event.action.clone());
|
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)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -77,7 +98,7 @@ impl PositionExposureTimeline {
|
|||||||
.events
|
.events
|
||||||
.range(..=(at, u64::MAX))
|
.range(..=(at, u64::MAX))
|
||||||
.next_back()
|
.next_back()
|
||||||
.map(|(_, action)| action)
|
.map(|(_, (action, _))| action)
|
||||||
{
|
{
|
||||||
Some(PositionExposureAction::Scale { requested_bps }) => {
|
Some(PositionExposureAction::Scale { requested_bps }) => {
|
||||||
Some(strategy_exposure * f64::from(*requested_bps) / 10000.)
|
Some(strategy_exposure * f64::from(*requested_bps) / 10000.)
|
||||||
@@ -98,12 +119,47 @@ impl PositionExposureTimeline {
|
|||||||
.events
|
.events
|
||||||
.range(..=(at, u64::MAX))
|
.range(..=(at, u64::MAX))
|
||||||
.next_back()
|
.next_back()
|
||||||
.map(|(_, action)| action)
|
.map(|(_, (action, _))| action)
|
||||||
{
|
{
|
||||||
Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps),
|
Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps),
|
||||||
_ => None,
|
_ => 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
|
/// Scale new buys and desired targets without weakening sell/reduction or
|
||||||
@@ -241,6 +297,7 @@ mod tests {
|
|||||||
event_id: "scale".into(),
|
event_id: "scale".into(),
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
effective_at: at,
|
effective_at: at,
|
||||||
|
allocation_weights_bps: None,
|
||||||
action: PositionExposureAction::Scale {
|
action: PositionExposureAction::Scale {
|
||||||
requested_bps: 5000,
|
requested_bps: 5000,
|
||||||
},
|
},
|
||||||
@@ -258,6 +315,7 @@ mod tests {
|
|||||||
event_id: "restore".into(),
|
event_id: "restore".into(),
|
||||||
sequence: 2,
|
sequence: 2,
|
||||||
effective_at: at,
|
effective_at: at,
|
||||||
|
allocation_weights_bps: None,
|
||||||
action: PositionExposureAction::Restore,
|
action: PositionExposureAction::Restore,
|
||||||
};
|
};
|
||||||
let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap();
|
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]
|
#[test]
|
||||||
fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() {
|
fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() {
|
||||||
use crate::OrderIntent as I;
|
use crate::OrderIntent as I;
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSe
|
|||||||
|
|
||||||
pub trait Strategy {
|
pub trait Strategy {
|
||||||
fn name(&self) -> &str;
|
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> {
|
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
||||||
BTreeSet::new()
|
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]
|
#[test]
|
||||||
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
|
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
|
||||||
for (ordinary, risk, quote, sold) in [
|
for (ordinary, risk, quote, sold) in [
|
||||||
|
|||||||
@@ -42,3 +42,7 @@ Core872通过(9项原ignore不计通过),交易工作区619普通测试通
|
|||||||
4. 核对GT正式总费用来源、整仓关键日志严格持久化及完整参数矩阵后再配套发布。
|
4. 核对GT正式总费用来源、整仓关键日志严格持久化及完整参数矩阵后再配套发布。
|
||||||
|
|
||||||
本轮未重启生产或发送委托。同期其他维护已将Backtest发布为Engine665653c/Service501f6d0;这不包含本文件所述主时钟候选。交易仍166998d/v2026.9.14.6,Source d5/PID1700096冻结与研究暂停不改。
|
本轮未重启生产或发送委托。同期其他维护已将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