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