Compare commits

..

4 Commits

15 changed files with 1276 additions and 72 deletions
+52
View File
@@ -423,6 +423,10 @@ struct AlgoExecutionRequest {
} }
pub struct BrokerSimulator<C, R> { pub struct BrokerSimulator<C, R> {
historical_etf_open_fallback: bool,
verified_etf_minute_absences: RefCell<BTreeSet<(NaiveDate, String)>>,
runtime_etf_daily_open: Cell<bool>,
deferred_etf_targets: RefCell<crate::etf_execution::DeferredEtfTargets>,
cost_model: C, cost_model: C,
rules: R, rules: R,
board_lot_size: u32, board_lot_size: u32,
@@ -461,6 +465,10 @@ pub struct BrokerSimulator<C, R> {
impl<C, R> BrokerSimulator<C, R> { impl<C, R> BrokerSimulator<C, R> {
pub fn new(cost_model: C, rules: R) -> Self { pub fn new(cost_model: C, rules: R) -> Self {
Self { Self {
historical_etf_open_fallback: false,
verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
runtime_etf_daily_open: Cell::new(false),
deferred_etf_targets: RefCell::new(Default::default()),
cost_model, cost_model,
rules, rules,
board_lot_size: 100, board_lot_size: 100,
@@ -503,6 +511,10 @@ impl<C, R> BrokerSimulator<C, R> {
execution_price_field: PriceField, execution_price_field: PriceField,
) -> Self { ) -> Self {
Self { Self {
historical_etf_open_fallback: false,
verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
runtime_etf_daily_open: Cell::new(false),
deferred_etf_targets: RefCell::new(Default::default()),
cost_model, cost_model,
rules, rules,
board_lot_size: 100, board_lot_size: 100,
@@ -549,6 +561,40 @@ impl<C, R> BrokerSimulator<C, R> {
self self
} }
/// Historical stock-pool adapter only. Online runtimes never enable this.
pub fn with_historical_etf_open_fallback(mut self, enabled: bool) -> Self {
self.historical_etf_open_fallback = enabled;
self
}
pub(crate) fn requires_etf_absence_check(&self, data: &DataSet, symbol: &str) -> bool {
self.historical_etf_open_fallback && data.instrument(symbol).is_some_and(|v| v.is_exchange_traded_fund())
}
pub(crate) fn record_complete_etf_minute_query(&self, date: NaiveDate, data: &DataSet, symbols: &[String]) {
for symbol in symbols {
if self.requires_etf_absence_check(data, symbol) && data.execution_quotes_on(date, symbol).is_empty() {
self.verified_etf_minute_absences.borrow_mut().insert((date, symbol.clone()));
}
}
}
pub(crate) fn has_verified_etf_minute_absence(&self, date: NaiveDate, symbol: &str) -> bool {
self.historical_etf_open_fallback && self.verified_etf_minute_absences.borrow().contains(&(date, symbol.to_string()))
}
fn with_etf_daily_open<T>(&self, operation: impl FnOnce() -> Result<T, BacktestError>) -> Result<T, BacktestError> {
if self.liquidity_limit {
return Err(BacktestError::Execution("etf_daily_open_fallback: historical opening depth is unavailable; cannot satisfy liquidity_limit".into()));
}
self.volume_capacity_mode.validate(self.volume_limit, false)
.map_err(|error| BacktestError::Execution(format!("etf_daily_open_fallback: {error}")))?;
let prior = self.runtime_etf_daily_open.replace(true);
let result = operation();
self.runtime_etf_daily_open.set(prior);
result
}
pub fn capacity_audit_summary(&self) -> CapacityAuditSummary { pub fn capacity_audit_summary(&self) -> CapacityAuditSummary {
CapacityAuditSummary { mode: self.volume_capacity_mode, enabled: self.volume_limit, CapacityAuditSummary { mode: self.volume_capacity_mode, enabled: self.volume_limit,
participation_rate: self.volume_percent, ..Default::default() } participation_rate: self.volume_percent, ..Default::default() }
@@ -729,6 +775,7 @@ impl<C, R> BrokerSimulator<C, R> {
} }
fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField { fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField {
if self.runtime_etf_daily_open.get() { return PriceField::Open; }
if self.is_post_close_fixed_price(date) { if self.is_post_close_fixed_price(date) {
PriceField::Close PriceField::Close
} else if self.resting_daily_open_order() { } else if self.resting_daily_open_order() {
@@ -910,6 +957,7 @@ where
symbol: &str, symbol: &str,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
) -> f64 { ) -> f64 {
if self.runtime_etf_daily_open.get() { return snapshot.open; }
if self.is_post_close_fixed_price(date) { if self.is_post_close_fixed_price(date) {
return snapshot.close; return snapshot.close;
} }
@@ -1268,6 +1316,7 @@ where
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide, side: OrderSide,
) -> f64 { ) -> f64 {
if self.runtime_etf_daily_open.get() { return snapshot.open; }
if self.is_post_close_fixed_price(snapshot.date) { if self.is_post_close_fixed_price(snapshot.date) {
return snapshot.close; return snapshot.close;
} }
@@ -1426,6 +1475,7 @@ where
&self, &self,
algo_request: Option<&AlgoExecutionRequest>, algo_request: Option<&AlgoExecutionRequest>,
) -> MatchingType { ) -> MatchingType {
if self.runtime_etf_daily_open.get() && algo_request.is_none() { return MatchingType::NextBarOpen; }
match algo_request.map(|request| request.style) { match algo_request.map(|request| request.style) {
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap, Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap, Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
@@ -4082,6 +4132,7 @@ where
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide, side: OrderSide,
) -> f64 { ) -> f64 {
if self.runtime_etf_daily_open.get() { return snapshot.open; }
match (self.execution_price_field, side) { match (self.execution_price_field, side) {
(PriceField::Last, _) => snapshot.price(PriceField::Last), (PriceField::Last, _) => snapshot.price(PriceField::Last),
(_, OrderSide::Buy) => snapshot.buy_price(self.execution_price_field), (_, OrderSide::Buy) => snapshot.buy_price(self.execution_price_field),
@@ -8058,6 +8109,7 @@ where
} }
pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool { pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
if self.runtime_etf_daily_open.get() { return false; }
if self.resting_daily_open_order() { return true; } if self.resting_daily_open_order() { return true; }
matches!( matches!(
self.matching_type, self.matching_type,
+201 -7
View File
@@ -16,6 +16,11 @@ fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
.map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}"))) .map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}")))
} }
fn etf_activity(report:&mut BrokerExecutionReport,date:NaiveDate,symbol:&str,side:pool::OrderSide,detail:String) {
report.process_events.push(ProcessEvent {date,kind:ProcessEventKind::EtfExecutionFallback,order_id:None,
symbol:Some(symbol.into()),side:Some(if side==pool::OrderSide::Buy {OrderSide::Buy} else {OrderSide::Sell}),detail});
}
fn pool_positions( fn pool_positions(
portfolio: &PortfolioState, portfolio: &PortfolioState,
date: NaiveDate, date: NaiveDate,
@@ -54,7 +59,13 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
let instrument = data.instruments().get(symbol).ok_or_else(|| { let instrument = data.instruments().get(symbol).ok_or_else(|| {
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}")) BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
})?; })?;
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if self let fallback = self.pool_etf_fallback_reference(date, data, symbol, execution_clock)?;
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if let Some(reference) = fallback {
let calibration = self.slippage_calibration(data, snapshot)?;
(reference.price, snapshot.prev_close, None, None, None, None,
self.quote_execution_price(snapshot, OrderSide::Buy, reference.price, None, calibration.as_ref())?,
self.quote_execution_price(snapshot, OrderSide::Sell, reference.price, None, calibration.as_ref())?)
} else if self
.matching_type_uses_intraday_quotes() .matching_type_uses_intraday_quotes()
{ {
let time = self let time = self
@@ -174,6 +185,16 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.collect() .collect()
} }
fn pool_etf_fallback_reference(&self, date: NaiveDate, data: &DataSet, symbol: &str, clock: Option<NaiveDateTime>) -> Result<Option<crate::etf_execution::EtfFallbackReference>, BacktestError> {
if !self.matching_type_uses_intraday_quotes() || !self.has_verified_etf_minute_absence(date, symbol) {
return Ok(None);
}
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time)
.ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: execution clock missing".into()))?;
let at = clock.unwrap_or(date.and_time(time)).max(date.and_time(time));
crate::etf_execution::reference(data, symbol, at).map(Some)
}
pub(super) fn process_stock_pool_contract( pub(super) fn process_stock_pool_contract(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -240,6 +261,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
portfolio portfolio
.set_stock_pool_execution_state(&contract.pool_id, state) .set_stock_pool_execution_state(&contract.pool_id, state)
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); }
if self.has_open_orders() { if self.has_open_orders() {
report report
.diagnostics .diagnostics
@@ -291,7 +314,20 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.keys() .keys()
.cloned() .cloned()
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
// All delayed symbols in a generation share immutable configuration.
// Do not duplicate an N-member pool N times in a large mixed pool.
let mut deferred_configuration = None;
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] { for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
let mut fallback_references = BTreeMap::new();
for symbol in &quote_scope {
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
let condition = if side == pool::OrderSide::Buy { &contract.rule.buy_condition } else { &contract.rule.sell_condition };
if !condition.trim().is_empty() {
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: intraday condition evidence unavailable symbol={symbol} side={side:?}; daily reference is not a minute or tick signal")));
}
fallback_references.insert(symbol.clone(), reference);
}
}
let quotes = let quotes =
self.pool_quote_inputs(date, data, &quote_scope, *global_execution_cursor)?; self.pool_quote_inputs(date, data, &quote_scope, *global_execution_cursor)?;
let positions = pool_positions(portfolio, date)?; let positions = pool_positions(portfolio, date)?;
@@ -307,6 +343,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
constraints.pending_entry_symbols = execution_state.pending_symbols(); constraints.pending_entry_symbols = execution_state.pending_symbols();
constraints.prior_target_weights = execution_state.last_target_weights.clone(); constraints.prior_target_weights = execution_state.last_target_weights.clone();
constraints.position_action_bases = execution_state.position_action_bases_for(&contract.generation);
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date); constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
let account = pool::AccountSnapshot { let account = pool::AccountSnapshot {
total_equity: contract.frozen_equity, total_equity: contract.frozen_equity,
@@ -340,6 +377,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.insert(symbol.clone(), permission); .insert(symbol.clone(), permission);
} }
} }
if side == pool::OrderSide::Buy {
for (symbol, reference) in &fallback_references {
if !reference.immediate {
// The sell leg was queued, not filled. Keep its real
// holdings/slots and do not finance buys with proceeds
// from the following session.
constraints.automatic_permissions.entry(symbol.clone()).or_default()
.sell_denial.get_or_insert("etf_daily_open_deferred");
}
}
}
if self if self
.risk_config .risk_config
.static_rules .static_rules
@@ -401,9 +449,15 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
Some(&fee), Some(&fee),
) )
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
let updated = execution_state let mut updated = execution_state
.record_plan(contract.signal_date, &contract.generation, &plan) .record_plan(contract.signal_date, &contract.generation, &plan)
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
for (symbol, reference) in &fallback_references {
if !reference.immediate && let Some(entry) = updated.entries.get_mut(symbol) {
// The signal only fixes money, not shares at a stale close.
entry.completion_quantity = None;
}
}
portfolio portfolio
.set_stock_pool_execution_state(&contract.pool_id, updated) .set_stock_pool_execution_state(&contract.pool_id, updated)
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
@@ -425,6 +479,26 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
if row.side != Some(side) { if row.side != Some(side) {
continue; continue;
} }
if let Some(reference) = fallback_references.get(&row.symbol) {
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time).expect("fallback clock validated");
let at = global_execution_cursor.unwrap_or(date.and_time(time)).max(date.and_time(time));
if !reference.immediate {
report.diagnostics.push(format!("etf_daily_open_fallback:deferred symbol={} signal_at={at} reference_date={} reference_price={} target_value={} execute_on={:?}", row.symbol, reference.reference_date, reference.price, row.target_value, reference.execute_on));
let deferred = deferred_configuration.get_or_insert_with(|| (
std::sync::Arc::new(contract.rule.clone()), std::sync::Arc::new(members.clone()),
));
let opening_date=reference.execute_on.map(|day|day.to_string()).unwrap_or_else(||"回测区间外(后续日历未加载)".into());
etf_activity(report,date,&row.symbol,side,format!("ETF 顺延执行:信号 {at},参考 {} 收盘 {},目标金额 {},下一正式开盘日 {opening_date};未生成成交。",reference.reference_date,reference.price,row.target_value));
self.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
pool_id:contract.pool_id.clone(), generation:contract.generation.clone(), symbol:row.symbol.clone(),
signal_date:contract.signal_date, signal_at:at, execute_on:reference.execute_on,
target_value:row.target_value, target_weight_bps:row.target_weight_bps, side,
max_positions, rule:std::sync::Arc::clone(&deferred.0), members:std::sync::Arc::clone(&deferred.1),
reason:row.source_intent.clone().unwrap_or_else(||"stock_pool_target".into()),
});
continue;
}
}
if side == pool::OrderSide::Buy if side == pool::OrderSide::Buy
&& portfolio && portfolio
.position(&row.symbol) .position(&row.symbol)
@@ -440,8 +514,14 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
let target = row.target_quantity.to_i32().ok_or_else(|| { let target = row.target_quantity.to_i32().ok_or_else(|| {
BacktestError::Execution("stock_pool_target_quantity_out_of_range".into()) BacktestError::Execution("stock_pool_target_quantity_out_of_range".into())
})?; })?;
let reason = row.source_intent.as_deref().unwrap_or("stock_pool_target"); let fallback_reason = fallback_references.contains_key(&row.symbol).then(|| format!("{}: etf_daily_open_fallback signal_date={} execution_date={date}", row.source_intent.as_deref().unwrap_or("stock_pool_target"), contract.signal_date));
if let Some(price) = row.limit_price { let reason = fallback_reason.as_deref().unwrap_or_else(|| row.source_intent.as_deref().unwrap_or("stock_pool_target"));
let first_fill = report.fill_events.len();
if fallback_references.contains_key(&row.symbol) {
report.diagnostics.push(format!("etf_daily_open_fallback:opening symbol={} signal_date={} execution_date={date}", row.symbol, contract.signal_date));
etf_activity(report,date,&row.symbol,side,format!("ETF 日线开盘回退:信号日 {},执行日 {date},使用正式日线开盘价;不是分钟成交行情。",contract.signal_date));
}
let mut execute = || if let Some(price) = row.limit_price {
self.process_limit_target_shares( self.process_limit_target_shares(
date, date,
portfolio, portfolio,
@@ -457,7 +537,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
global_execution_cursor, global_execution_cursor,
commission_state, commission_state,
report, report,
)?; )
} else { } else {
self.process_target_shares( self.process_target_shares(
date, date,
@@ -471,10 +551,124 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
global_execution_cursor, global_execution_cursor,
commission_state, commission_state,
report, report,
)?; )
} };
if fallback_references.contains_key(&row.symbol) {
self.with_etf_daily_open(execute)?;
for fill in &mut report.fill_events[first_fill..] {
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
}
} else { execute()?; }
} }
} }
Ok(()) Ok(())
} }
pub(crate) fn pending_etf_target_count(&self) -> usize {
self.deferred_etf_targets.borrow().len()
}
/// Called at the opening clock, after settlement/corporate actions and
/// auction callbacks. It never sends a stock order or replays a strategy.
pub(crate) fn execute_deferred_etf_targets(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
if self.has_open_orders() {
if self.pending_etf_target_count() > 0 {
report.diagnostics.push("etf_daily_open_fallback:waiting_for_active_orders".into());
}
return Ok(report);
}
let due = self.deferred_etf_targets.borrow_mut().take_due(date);
let dates = data.calendar().iter().collect::<Vec<_>>();
for target in due {
let instrument = data.instrument(&target.symbol).ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: instrument identity missing at execution".into()))?;
if !instrument.is_exchange_traded_fund() { return Err(BacktestError::Execution("etf_daily_open_fallback: instrument identity changed".into())); }
if let Some(reason) = instrument.dated_market_absence_reason(date) {
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} date={date} reason={reason}", target.symbol));
continue;
}
let snapshot = data.market(date, &target.symbol).ok_or_else(|| BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_missing symbol={} date={date}", target.symbol)))?;
if !snapshot.open.is_finite() || snapshot.open <= 0.0 {
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_invalid symbol={} date={date}", target.symbol)));
}
let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0);
let before_quantity = position.map_or(0, |p| p.quantity);
let permission = target.rule.automatic_trade_protection.evaluate(&target.symbol, date, &HoldingLifecycleEvidence {
has_position:position.is_some(), opened_date:position.and_then(|p| p.opened_date()), last_buy_date:position.and_then(|p| p.last_buy_date()),
last_sell_date:self.same_day_sold_symbols.borrow().iter().rev().find(|(day, symbols)| **day <= date && symbols.contains(&target.symbol)).map(|(day, _)| *day),
}, data.calendar()).map_err(BacktestError::Execution)?;
let denial = if target.side == pool::OrderSide::Buy {
permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending"))
} else { permission.sell_denial };
if let Some(denial) = denial {
report.diagnostics.push(format!("etf_daily_open_fallback:protected symbol={} date={date} reason={denial}", target.symbol));
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标受持有保护限制:{denial};未提交委托。"));
continue;
}
if target.side == pool::OrderSide::Buy && before_quantity == 0 && Self::positive_position_count(portfolio) >= target.max_positions {
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} reason=occupied_position_slots", target.symbol));
continue;
}
let value = target.target_value.to_f64().ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: target value out of range".into()))?;
let current_value = snapshot.open * f64::from(before_quantity);
let satisfied = (target.side == pool::OrderSide::Buy && value <= current_value)
|| (target.side == pool::OrderSide::Sell && value >= current_value);
let reason = format!("{}: etf_daily_open_fallback signal_at={} execution_at={} target_value={}", target.reason, target.signal_at, date.and_time(crate::etf_execution::opening_time()), target.target_value);
let mut sub = BrokerExecutionReport::default();
if !satisfied {
let (_, limit) = pool::resolve_stock_pool_order_price(&target.rule, &target.symbol, decimal(snapshot.open, "etf_open")?, target.side, decimal(snapshot.price_tick, "etf_tick")?).map_err(BacktestError::Execution)?;
let intent = match limit {
Some(limit) => OrderIntent::LimitTargetValue { symbol:target.symbol.clone(), target_value:value, limit_price:limit.to_f64().ok_or_else(|| BacktestError::Execution("ETF limit out of range".into()))?, reason:reason.clone() },
None => OrderIntent::TargetValue { symbol:target.symbol.clone(), target_value:value, reason:reason.clone() },
};
let old_time = self.runtime_intraday_start_time.replace(Some(crate::etf_execution::opening_time()));
let old_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin { created_date:Some(target.signal_at.date()), submission_time:Some(target.signal_at.time()), accepted_date:date }));
let outcome = self.with_etf_daily_open(|| self.execute_with_event_dates(date, target.signal_date, target.signal_at.date(), portfolio, data, &StrategyDecision {
order_intents:vec![OrderIntent::WithTimeInForce { intent:Box::new(intent), time_in_force:OrderTimeInForce::Day }], ..Default::default()
}));
self.runtime_intraday_start_time.set(old_time);
self.runtime_resting_order_origin.set(old_origin);
sub = outcome?;
}
// The actual open determines the full requested shares. A clipped
// or rejected execution must not be recorded as completed entry.
let order = sub.order_events.iter().rev().find(|order| order.symbol == target.symbol);
let goal_quantity = order.map_or(before_quantity, |order| match order.side {
OrderSide::Buy => before_quantity.saturating_add(order.requested_quantity),
OrderSide::Sell => before_quantity.saturating_sub(order.requested_quantity),
});
let status = if satisfied || (order.is_none() && !self.has_open_orders()) { "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED" } else { "READY" };
let positions = pool_positions(portfolio, date)?;
let state = portfolio.stock_pool_execution_state(&target.pool_id)
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?
.record_targets(target.signal_date, &target.generation, [crate::stock_pool_state::StockPoolGoalObservation {
symbol:&target.symbol, target_weight_bps:target.target_weight_bps, target_value:target.target_value,
current_quantity:before_quantity.into(), target_quantity:goal_quantity.into(), status,
}]).map_err(BacktestError::Execution)?
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?;
portfolio.set_stock_pool_execution_state(&target.pool_id, state).map_err(BacktestError::Execution)?;
for fill in &mut sub.fill_events {
fill.decision_date.get_or_insert(target.signal_date);
fill.order_created_date.get_or_insert(target.signal_at.date());
fill.execution_date.get_or_insert(date);
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
}
for order in &mut sub.order_events {
order.decision_date.get_or_insert(target.signal_date);
order.order_created_date.get_or_insert(target.signal_at.date());
order.execution_date.get_or_insert(date);
}
report.diagnostics.push(reason);
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标开盘处理:原信号 {},本次 {date} 09:30,冻结目标金额 {},持仓 {before_quantity}{};按本日开盘价、资金与风控重新定量。",target.signal_at,target.target_value,portfolio.position(&target.symbol).map_or(0,|position|position.quantity)));
report.order_events.extend(sub.order_events);
report.fill_events.extend(sub.fill_events);
report.position_events.extend(sub.position_events);
report.account_events.extend(sub.account_events);
report.process_events.extend(sub.process_events);
report.diagnostics.extend(sub.diagnostics);
}
Ok(report)
}
} }
+29 -2
View File
@@ -169,6 +169,8 @@ pub enum BacktestTerminalAssetClass {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BacktestTerminalAudit { pub struct BacktestTerminalAudit {
#[serde(default, skip_serializing_if = "is_zero_count")]
pub deferred_etf_target_count: usize,
pub status: BacktestTerminalStatus, pub status: BacktestTerminalStatus,
pub last_execution_date: Option<NaiveDate>, pub last_execution_date: Option<NaiveDate>,
pub stock_open_order_count: usize, pub stock_open_order_count: usize,
@@ -185,6 +187,7 @@ pub struct BacktestTerminalAudit {
impl Default for BacktestTerminalAudit { impl Default for BacktestTerminalAudit {
fn default() -> Self { fn default() -> Self {
Self { Self {
deferred_etf_target_count: 0,
status: BacktestTerminalStatus::Clean, status: BacktestTerminalStatus::Clean,
last_execution_date: None, last_execution_date: None,
stock_open_order_count: 0, stock_open_order_count: 0,
@@ -200,6 +203,8 @@ impl Default for BacktestTerminalAudit {
} }
} }
fn is_zero_count(value: &usize) -> bool { *value == 0 }
impl BacktestTerminalAudit { impl BacktestTerminalAudit {
pub fn is_clean(&self) -> bool { pub fn is_clean(&self) -> bool {
self.status == BacktestTerminalStatus::Clean self.status == BacktestTerminalStatus::Clean
@@ -787,6 +792,17 @@ where
end_time: Option<NaiveTime>, end_time: Option<NaiveTime>,
symbols: &mut BTreeSet<String>, symbols: &mut BTreeSet<String>,
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
// A missing point is not proof of an absent ETF minute dataset. Query
// its complete formal session first; loader/contract failures propagate.
if start_time.is_some() || end_time.is_some() {
let mut etfs = symbols.iter().filter(|symbol| {
self.broker.requires_etf_absence_check(&self.data, symbol)
&& !self.execution_quote_request_cache.contains(&(execution_date, (*symbol).clone(), None, None))
}).cloned().collect::<BTreeSet<_>>();
if !etfs.is_empty() {
self.load_missing_execution_quotes(execution_date, None, None, &mut etfs)?;
}
}
let mut available = BTreeSet::new(); let mut available = BTreeSet::new();
for symbol in symbols.iter() { for symbol in symbols.iter() {
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!( let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
@@ -813,6 +829,9 @@ where
} }
*symbols = available; *symbols = available;
symbols.retain(|symbol| { symbols.retain(|symbol| {
if (start_time.is_some() || end_time.is_some()) && self.broker.has_verified_etf_minute_absence(execution_date, symbol) {
return false;
}
let request_key = (execution_date, symbol.clone(), start_time, end_time); let request_key = (execution_date, symbol.clone(), start_time, end_time);
if self.execution_quote_request_cache.contains(&request_key) { if self.execution_quote_request_cache.contains(&request_key) {
return false; return false;
@@ -857,6 +876,7 @@ where
} }
self.data.add_execution_quotes(quotes); self.data.add_execution_quotes(quotes);
if start_time.is_none() && end_time.is_none() { if start_time.is_none() && end_time.is_none() {
self.broker.record_complete_etf_minute_query(execution_date, &self.data, &requested_symbols);
self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?; self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?;
} }
for symbol in requested_symbols { for symbol in requested_symbols {
@@ -893,7 +913,7 @@ where
} }
continue; continue;
} }
if market.volume > 0 && !has_quotes { if market.volume > 0 && !has_quotes && !self.broker.has_verified_etf_minute_absence(execution_date, symbol) {
missing_active.push(symbol.clone()); missing_active.push(symbol.clone());
} }
} }
@@ -1395,6 +1415,7 @@ where
let status = if open_order_count == 0 let status = if open_order_count == 0
&& pending_cash_flow_count == 0 && pending_cash_flow_count == 0
&& cash_receivable_count == 0 && cash_receivable_count == 0
&& self.broker.pending_etf_target_count() == 0
{ {
BacktestTerminalStatus::Clean BacktestTerminalStatus::Clean
} else { } else {
@@ -1402,6 +1423,7 @@ where
}; };
BacktestTerminalAudit { BacktestTerminalAudit {
deferred_etf_target_count: self.broker.pending_etf_target_count(),
status, status,
last_execution_date, last_execution_date,
stock_open_order_count, stock_open_order_count,
@@ -2204,7 +2226,7 @@ where
.and_then(|(_, decision_slot)| *decision_slot); .and_then(|(_, decision_slot)| *decision_slot);
let Some((decision_index, decision_date)) = decision_slot else { let Some((decision_index, decision_date)) = decision_slot else {
let mut process_events = Vec::new(); let mut process_events = Vec::new();
let mut report = BrokerExecutionReport::default(); let mut report = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
portfolio.update_prices_with_options( portfolio.update_prices_with_options(
execution_date, execution_date,
&self.data, &self.data,
@@ -2566,6 +2588,9 @@ where
"open_auction:post", "open_auction:post",
)?; )?;
let deferred_etfs = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
merge_broker_report(&mut report, deferred_etfs);
publish_phase_event( publish_phase_event(
&mut self.strategy, &mut self.strategy,
&mut self.process_event_bus, &mut self.process_event_bus,
@@ -3641,6 +3666,8 @@ where
let split_ratio = action.split_ratio(); let split_ratio = action.split_ratio();
if (split_ratio - 1.0).abs() > f64::EPSILON { if (split_ratio - 1.0).abs() > f64::EPSILON {
portfolio.adjust_stock_pool_split(&action.symbol, split_ratio)
.map_err(BacktestError::Execution)?;
let (delta_quantity, quantity_after, average_cost) = { let (delta_quantity, quantity_after, average_cost) = {
let position = portfolio let position = portfolio
.position_mut_if_exists(&action.symbol) .position_mut_if_exists(&action.symbol)
+128
View File
@@ -0,0 +1,128 @@
//! Historical ETF execution fallback. Never manufactures an intraday bar.
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use rust_decimal::Decimal;
use crate::{BacktestError, DataSet};
pub(crate) fn opening_time() -> NaiveTime {
NaiveTime::from_hms_opt(9, 30, 0).expect("valid exchange opening time")
}
#[derive(Debug, Clone)]
pub(crate) struct EtfFallbackReference {
pub price: f64,
pub reference_date: NaiveDate,
/// None means the next official session is outside the loaded calendar.
/// No natural-day guess or price from beyond the requested run is used.
pub execute_on: Option<NaiveDate>,
pub immediate: bool,
}
pub(crate) fn reference(data: &DataSet, symbol: &str, at: NaiveDateTime) -> Result<EtfFallbackReference, BacktestError> {
let fail = |reason: &str| BacktestError::Execution(format!(
"etf_daily_open_fallback:{reason} symbol={symbol} signal_at={at}"
));
let instrument = data.instrument(symbol).ok_or_else(|| fail("instrument_identity_missing"))?;
if !instrument.is_exchange_traded_fund() || instrument.listed_at.is_none() {
return Err(fail("verified_etf_identity_required"));
}
if instrument.dated_market_absence_reason(at.date()).is_some() {
return Err(fail("outside_instrument_lifecycle"));
}
if at.time() == opening_time() {
let row = data.market(at.date(), symbol).ok_or_else(|| fail("daily_open_missing"))?;
if !row.open.is_finite() || row.open <= 0.0 { return Err(fail("daily_open_invalid")); }
return Ok(EtfFallbackReference { price: row.open, reference_date: at.date(), execute_on: Some(at.date()), immediate: true });
}
let previous = data.previous_trading_date(at.date(), 1).ok_or_else(|| fail("previous_official_session_missing"))?;
let close = data.market(previous, symbol).map(|row| row.close).ok_or_else(|| fail("previous_completed_close_missing"))?;
if !close.is_finite() || close <= 0.0 { return Err(fail("previous_completed_close_invalid")); }
Ok(EtfFallbackReference {
price: close, reference_date: previous, immediate: false,
execute_on: if at.time() < opening_time() { Some(at.date()) } else { data.next_trading_date(at.date(), 1) },
})
}
#[derive(Debug, Clone)]
pub(crate) struct DeferredEtfTarget {
pub pool_id: String,
pub generation: String,
pub symbol: String,
pub signal_date: NaiveDate,
pub signal_at: NaiveDateTime,
pub execute_on: Option<NaiveDate>,
pub target_value: Decimal,
pub target_weight_bps: i32,
pub side: crate::stock_pool_execution::OrderSide,
pub max_positions: usize,
pub rule: std::sync::Arc<crate::stock_pool_execution::StockPoolExecutionRule>,
pub members: std::sync::Arc<Vec<crate::stock_pool_execution::StockPoolMemberSpec>>,
pub reason: String,
}
/// Owned by one broker/run. Replacing a full pool generation supersedes older
/// queued targets; order of the latest candidate list is retained.
#[derive(Debug, Default)]
pub(crate) struct DeferredEtfTargets {
generations: std::collections::BTreeMap<String, String>,
rows: Vec<DeferredEtfTarget>,
}
impl DeferredEtfTargets {
pub fn replace_generation(&mut self, pool_id: &str, generation: &str) -> usize {
if self.generations.get(pool_id).is_some_and(|old| old == generation) { return 0; }
self.generations.insert(pool_id.into(), generation.into());
let before = self.rows.len();
self.rows.retain(|row| row.pool_id != pool_id);
before - self.rows.len()
}
pub fn upsert(&mut self, row: DeferredEtfTarget) {
if let Some(existing) = self.rows.iter_mut().find(|v| v.pool_id == row.pool_id && v.symbol == row.symbol) {
*existing = row;
} else { self.rows.push(row); }
}
pub fn take_due(&mut self, date: NaiveDate) -> Vec<DeferredEtfTarget> {
let mut due = Vec::new();
self.rows.retain(|row| {
if row.execute_on.is_some_and(|day| day <= date) { due.push(row.clone()); false } else { true }
});
due.sort_by_key(|row| match row.side { crate::stock_pool_execution::OrderSide::Sell => 0, crate::stock_pool_execution::OrderSide::Buy => 1 });
due
}
pub fn len(&self) -> usize { self.rows.len() }
}
#[cfg(test)]
mod tests {
use super::*;
fn target(symbol:&str,side:crate::stock_pool_execution::OrderSide,generation:&str)->DeferredEtfTarget {
let date=NaiveDate::from_ymd_opt(2026,1,2).unwrap();
DeferredEtfTarget {pool_id:"pool".into(),generation:generation.into(),symbol:symbol.into(),signal_date:date,signal_at:date.and_hms_opt(13,0,0).unwrap(),execute_on:NaiveDate::from_ymd_opt(2026,1,5),target_value:1000.into(),target_weight_bps:5000,side,max_positions:2,rule:Default::default(),members:std::sync::Arc::new(vec![]),reason:"fixture".into()}
}
#[test]
fn latest_generation_overwrites_pending_targets_and_preserves_candidate_order() {
use crate::stock_pool_execution::OrderSide::{Buy,Sell};
let mut queue=DeferredEtfTargets::default();
queue.replace_generation("pool","v1");
queue.upsert(target("510300.SH",Buy,"v1"));
queue.upsert(target("159915.SZ",Buy,"v1"));
assert_eq!(queue.replace_generation("pool","v1"),0);
assert_eq!(queue.replace_generation("pool","v2"),2);
queue.upsert(target("560450.SH",Buy,"v2"));
queue.upsert(target("159915.SZ",Sell,"v2"));
queue.upsert(target("510300.SH",Buy,"v2"));
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,1,2).unwrap()).is_empty());
let due=queue.take_due(NaiveDate::from_ymd_opt(2026,1,5).unwrap());
assert_eq!(due.iter().map(|v|v.symbol.as_str()).collect::<Vec<_>>(),vec!["159915.SZ","560450.SH","510300.SH"]);
assert!(due.iter().all(|v|v.generation=="v2"));
assert_eq!(queue.len(),0);
}
#[test]
fn no_loaded_next_session_is_not_guessed_from_natural_days() {
let mut queue=DeferredEtfTargets::default();
let mut item=target("510300.SH",crate::stock_pool_execution::OrderSide::Buy,"v1");
item.execute_on=None;
queue.upsert(item);
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,2,1).unwrap()).is_empty());
assert_eq!(queue.len(),1);
}
}
+3
View File
@@ -318,6 +318,7 @@ pub enum ProcessEventKind {
AccountFinanceRepay, AccountFinanceRepay,
AccountManagementFee, AccountManagementFee,
SessionCapacityAudit, SessionCapacityAudit,
EtfExecutionFallback,
} }
impl ProcessEventKind { impl ProcessEventKind {
@@ -364,6 +365,7 @@ impl ProcessEventKind {
Self::AccountFinanceRepay => "account_finance_repay", Self::AccountFinanceRepay => "account_finance_repay",
Self::AccountManagementFee => "account_management_fee", Self::AccountManagementFee => "account_management_fee",
Self::SessionCapacityAudit => "session_capacity_audit", Self::SessionCapacityAudit => "session_capacity_audit",
Self::EtfExecutionFallback => "etf_execution_fallback",
} }
} }
@@ -396,6 +398,7 @@ impl ProcessEventKind {
| Self::AccountFinanceRepay | Self::AccountFinanceRepay
| Self::AccountManagementFee | Self::AccountManagementFee
| Self::SessionCapacityAudit | Self::SessionCapacityAudit
| Self::EtfExecutionFallback
| Self::Settlement | Self::Settlement
) )
} }
+1
View File
@@ -7,6 +7,7 @@ pub mod pattern_context;
pub mod session_events; pub mod session_events;
pub mod factor_events; pub mod factor_events;
pub mod execution_capacity; pub mod execution_capacity;
mod etf_execution;
mod execution_schedule; mod execution_schedule;
mod factor_event_catalog; mod factor_event_catalog;
pub mod factor_cross_section; pub mod factor_cross_section;
+52 -8
View File
@@ -624,6 +624,7 @@ pub struct PlatformPositionTargetRule {
pub when_expr: String, pub when_expr: String,
pub remaining_position_bps: u32, pub remaining_position_bps: u32,
pub reason: String, pub reason: String,
pub stock_pool_role: crate::stock_pool_execution::StockPoolExitRole,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -2739,7 +2740,19 @@ impl PlatformExprStrategy {
if position.quantity == 0 { if position.quantity == 0 {
continue; continue;
} }
let mark_price = if self.uses_intraday_execution_quotes() { let etf_reference_clock = ctx.active_datetime.filter(|at| at.date() == date && at.time() < NaiveTime::from_hms_opt(15,0,0).unwrap());
let mark_price = if self.config.stock_pool.is_some()
&& ctx.data.instrument(&position.symbol).is_some_and(|instrument| instrument.is_exchange_traded_fund())
&& etf_reference_clock.is_some()
{
// The current daily close is not visible while the session is
// running. This is a valuation reference only; the execution
// adapter still requires a successful minute-absence query.
self.scheduled_last_price(ctx, date, &position.symbol).unwrap_or_else(|| {
crate::etf_execution::reference(ctx.data, &position.symbol, etf_reference_clock.unwrap())
.map(|reference| reference.price).unwrap_or(f64::NAN)
})
} else if self.uses_intraday_execution_quotes() {
self.scheduled_last_price(ctx, date, &position.symbol) self.scheduled_last_price(ctx, date, &position.symbol)
.or_else(|| ctx.data.price(date, &position.symbol, PriceField::Last)) .or_else(|| ctx.data.price(date, &position.symbol, PriceField::Last))
.or_else(|| { .or_else(|| {
@@ -2763,6 +2776,9 @@ impl PlatformExprStrategy {
.filter(|price| price.is_finite() && *price > 0.0) .filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(position.last_price) .unwrap_or(position.last_price)
}; };
if !mark_price.is_finite() && self.config.stock_pool.is_some() {
return f64::NAN;
}
if mark_price.is_finite() && mark_price > 0.0 { if mark_price.is_finite() && mark_price > 0.0 {
total += mark_price * position.quantity as f64; total += mark_price * position.quantity as f64;
} }
@@ -10131,6 +10147,22 @@ impl PlatformExprStrategy {
factor_date: NaiveDate, factor_date: NaiveDate,
day: &DayExpressionState, day: &DayExpressionState,
) -> Result<BTreeMap<String, (u32, String)>, BacktestError> { ) -> Result<BTreeMap<String, (u32, String)>, BacktestError> {
let mut targets = BTreeMap::new();
for (_, scoped) in self.current_position_target_rules_by_role(ctx, signal_date, factor_date, day)? {
for (symbol, value) in scoped {
if targets.get(&symbol).is_none_or(|(bps, _)| value.0 < *bps) { targets.insert(symbol, value); }
}
}
Ok(targets)
}
fn current_position_target_rules_by_role(
&self,
ctx: &StrategyContext<'_>,
signal_date: NaiveDate,
factor_date: NaiveDate,
day: &DayExpressionState,
) -> Result<BTreeMap<crate::stock_pool_execution::StockPoolExitRole, BTreeMap<String, (u32, String)>>, BacktestError> {
let mut targets = BTreeMap::new(); let mut targets = BTreeMap::new();
if self.config.position_target_rules.is_empty() { if self.config.position_target_rules.is_empty() {
return Ok(targets); return Ok(targets);
@@ -10145,11 +10177,12 @@ impl PlatformExprStrategy {
if !self.eval_bool(ctx, &rule.when_expr, day, Some(&stock), None)? { if !self.eval_bool(ctx, &rule.when_expr, day, Some(&stock), None)? {
continue; continue;
} }
let replace = targets let scoped = targets.entry(rule.stock_pool_role).or_insert_with(BTreeMap::new);
let replace = scoped
.get(&position.symbol) .get(&position.symbol)
.map_or(true, |(bps, _)| rule.remaining_position_bps < *bps); .map_or(true, |(bps, _)| rule.remaining_position_bps < *bps);
if replace { if replace {
targets.insert( scoped.insert(
position.symbol.clone(), position.symbol.clone(),
(rule.remaining_position_bps, rule.reason.clone()), (rule.remaining_position_bps, rule.reason.clone()),
); );
@@ -10303,6 +10336,13 @@ impl PlatformExprStrategy {
let factor_day = ctx.data.daily_snapshot_view(factor_date); let factor_day = ctx.data.daily_snapshot_view(factor_date);
let factor_rows = factor_day.factor_rows(); let factor_rows = factor_day.factor_rows();
let factor_symbol_ids = factor_day.factor_symbol_ids(); let factor_symbol_ids = factor_day.factor_symbol_ids();
// Market-cap caches are an optimization, not an implicit universe
// condition. A manual/price-screened ETF need not have share capital.
let requires_total_cap = matches!(self.config.market_cap_field.as_str(),
"market_cap" | "market_cap_bn" | "candidate_market_cap" | "candidate_market_cap_bn")
|| self.rank_reuses_market_cap_order();
let requires_float_cap = matches!(self.config.market_cap_field.as_str(),
"free_float_cap" | "free_float_market_cap" | "free_float_cap_bn");
debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len()); debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len());
for (factor, symbol_id) in factor_rows.iter().zip(factor_symbol_ids.iter().copied()) { for (factor, symbol_id) in factor_rows.iter().zip(factor_symbol_ids.iter().copied()) {
if self if self
@@ -10371,21 +10411,24 @@ impl PlatformExprStrategy {
if reject_from_universe { if reject_from_universe {
continue; continue;
} }
if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() { if requires_total_cap && (factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite()) {
continue; continue;
} }
if !self.stock_passes_universe_exclude(candidate, market) { if !self.stock_passes_universe_exclude(candidate, market) {
continue; continue;
} }
let market_cap_bn = decision_market_cap_bn(factor); let market_cap_bn = decision_market_cap_bn(factor);
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() { if requires_total_cap && (market_cap_bn <= 0.0 || !market_cap_bn.is_finite()) {
continue; continue;
} }
let free_float_cap = decision_free_float_cap_bn(factor);
if requires_float_cap && (!free_float_cap.is_finite() || free_float_cap <= 0.0) { continue; }
eligible_symbols[symbol_id as usize] = true; eligible_symbols[symbol_id as usize] = true;
} }
for symbol_id in ctx let ordered_ids = if requires_total_cap {
.data ctx.data.factor_symbol_ids_by_market_cap_on(factor_date)
.factor_symbol_ids_by_market_cap_on(factor_date) } else { factor_symbol_ids };
for symbol_id in ordered_ids
.iter() .iter()
.copied() .copied()
{ {
@@ -37801,6 +37844,7 @@ let target_exposure = csi_ready ? dynamic_exposure : 0.0;
config.rebalance_existing_positions = true; config.rebalance_existing_positions = true;
config.hold_until_exit_enabled = true; config.hold_until_exit_enabled = true;
config.position_target_rules = vec![PlatformPositionTargetRule { config.position_target_rules = vec![PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: "factors[\"reduce_signal\"] == 1".to_string(), when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
remaining_position_bps: 5_000, remaining_position_bps: 5_000,
reason: "factor_reduce_position".to_string(), reason: "factor_reduce_position".to_string(),
+27 -17
View File
@@ -14,6 +14,14 @@ impl PlatformExprStrategy {
.as_ref() .as_ref()
.ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))? .ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))?
.clone(); .clone();
if !self.config.stop_loss_expr.trim().is_empty() || !self.config.take_profit_expr.trim().is_empty()
|| self.config.position_target_rules.len() != program.exit_signals.len()
|| self.config.position_target_rules.iter().zip(&program.exit_signals).any(|(compiled, frozen)|
compiled.when_expr != frozen.when_expr || compiled.remaining_position_bps != frozen.remaining_position_bps
|| compiled.reason != frozen.reason || compiled.stock_pool_role != frozen.role)
{
return Err(BacktestError::Execution("stock_pool_exit_roles_required: exit rules must remain bound to the frozen stock_pool program".into()));
}
let mut constraints = pool::stock_pool_constraints_from_configuration( let mut constraints = pool::stock_pool_constraints_from_configuration(
&program.allocation_policy, &program.allocation_policy,
&program.stop_take_policy, &program.stop_take_policy,
@@ -78,17 +86,29 @@ impl PlatformExprStrategy {
closes, closes,
}); });
} }
let rule = pool::normalize_stock_pool_execution_rule( let rule = pool::normalize_stock_pool_execution_rule_with_exit_roles(
Some(&program.timing_policy), Some(&program.timing_policy),
!self.config.buy_filter_expr.trim().is_empty(), !self.config.buy_filter_expr.trim().is_empty(),
!self.config.stop_loss_expr.trim().is_empty() self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::OrdinarySell),
|| !self.config.take_profit_expr.trim().is_empty() self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::RiskExit),
|| !self.config.position_target_rules.is_empty(),
) )
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
if self.config.in_skip_window(ctx.decision_date) { if self.config.in_skip_window(ctx.decision_date) {
return Ok(StrategyDecision::default()); return Ok(StrategyDecision::default());
} }
let explicit_quote_condition = self.selection_quote_usage != StockFilterQuoteUsage::DailyOnly
|| [self.config.buy_filter_expr.as_str(), self.config.stop_loss_expr.as_str(), self.config.take_profit_expr.as_str()]
.into_iter().chain(self.config.position_target_rules.iter().map(|rule|rule.when_expr.as_str()))
.any(|expression|Self::stock_filter_quote_usage_for_expr(expression)!=StockFilterQuoteUsage::DailyOnly);
if explicit_quote_condition && ctx.active_datetime.is_some_and(|at|at.time()<NaiveTime::from_hms_opt(15,0,0).unwrap()) {
for symbol in program.members.iter().map(|member|&member.symbol).chain(ctx.portfolio.positions().keys()) {
if ctx.data.instrument(symbol).is_some_and(|instrument|instrument.is_exchange_traded_fund()&&instrument.dated_market_absence_reason(ctx.execution_date).is_none())
&& self.scheduled_quote_at_time(ctx,ctx.execution_date,symbol,None).is_none()
{
return Err(BacktestError::Execution(format!("etf_intraday_condition_evidence_missing:{symbol}; completed daily references cannot make minute/tick conditions true")));
}
}
}
let day = self.day_state(ctx, ctx.decision_date)?; let day = self.day_state(ctx, ctx.decision_date)?;
let (market_date, universe_date, factor_date) = self.selection_dates(ctx); let (market_date, universe_date, factor_date) = self.selection_dates(ctx);
let (low, high) = self.market_cap_band(ctx, &day)?; let (low, high) = self.market_cap_band(ctx, &day)?;
@@ -120,23 +140,13 @@ impl PlatformExprStrategy {
} }
} }
} }
let native_exits = self.current_stop_take_exit_symbols(ctx, ctx.decision_date, &day)?; for (role, targets) in self.current_position_target_rules_by_role(ctx, ctx.decision_date, factor_date, &day)? {
for symbol in native_exits { let output = match role { pool::StockPoolExitRole::OrdinarySell => &mut constraints.position_target_bps, pool::StockPoolExitRole::RiskExit => &mut constraints.independent_position_target_bps };
constraints.position_target_bps.insert(symbol, 0); for (symbol, (bps, _)) in targets { output.insert(symbol, bps); }
}
for (symbol, (bps, _)) in
self.current_position_target_rules(ctx, ctx.decision_date, factor_date, &day)?
{
constraints
.position_target_bps
.entry(symbol)
.and_modify(|old| *old = (*old).min(bps))
.or_insert(bps);
} }
let limit = constraints.target_holding_count.unwrap_or(ranked.len()); let limit = constraints.target_holding_count.unwrap_or(ranked.len());
let final_symbols = ranked let final_symbols = ranked
.iter() .iter()
.filter(|symbol| !constraints.position_target_bps.contains_key(*symbol))
.take(limit) .take(limit)
.cloned() .cloned()
.collect(); .collect();
@@ -2339,6 +2339,7 @@ pub fn platform_expr_config_from_spec(
)); ));
} }
cfg.position_target_rules.push(PlatformPositionTargetRule { cfg.position_target_rules.push(PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: when_expr.to_string(), when_expr: when_expr.to_string(),
remaining_position_bps: rule.remaining_position_bps, remaining_position_bps: rule.remaining_position_bps,
reason: rule reason: rule
@@ -2712,9 +2713,14 @@ pub fn platform_expr_config_from_spec(
} }
if let Some(pool)=&spec.stock_pool { if let Some(pool)=&spec.stock_pool {
if cfg.signal_book.is_some() || spec.signal_book_ref.is_some() || !cfg.explicit_actions.is_empty(){return Err("stock_pool_program_cannot_mix_other_order_programs".into())} if cfg.signal_book.is_some() || spec.signal_book_ref.is_some() || !cfg.explicit_actions.is_empty(){return Err("stock_pool_program_cannot_mix_other_order_programs".into())}
let legacy_exit = !cfg.stop_loss_expr.trim().is_empty() || !cfg.take_profit_expr.trim().is_empty() || !cfg.position_target_rules.is_empty();
if legacy_exit { return Err("stock_pool_exit_roles_required: regenerate this historical stock-pool strategy from its saved configuration; legacy risk expressions do not preserve ordinary/risk exit roles".into()); }
let secondary_buy=!cfg.buy_filter_expr.trim().is_empty(); let secondary_buy=!cfg.buy_filter_expr.trim().is_empty();
let secondary_sell=spec.runtime_expressions.as_ref().and_then(|runtime|runtime.risk.as_ref()).is_some_and(|risk|risk.stop_loss_expr.is_some()||risk.take_profit_expr.is_some()) || !cfg.position_target_rules.is_empty(); pool.validate(secondary_buy,false)?;
pool.validate(secondary_buy,secondary_sell)?; cfg.position_target_rules.extend(pool.exit_signals.iter().map(|signal| PlatformPositionTargetRule {
when_expr: signal.when_expr.clone(), remaining_position_bps: signal.remaining_position_bps,
reason: signal.reason.clone(), stock_pool_role: signal.role,
}));
cfg.stock_pool=Some(pool.clone()); cfg.stock_pool=Some(pool.clone());
cfg.hold_until_exit_enabled=false; cfg.hold_until_exit_enabled=false;
cfg.daily_top_up_enabled=false; cfg.daily_top_up_enabled=false;
@@ -3456,6 +3462,7 @@ mod tests {
assert_eq!( assert_eq!(
cfg.position_target_rules, cfg.position_target_rules,
vec![PlatformPositionTargetRule { vec![PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: "factors[\"reduce_signal\"] == 1".to_string(), when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
remaining_position_bps: 5000, remaining_position_bps: 5000,
reason: "factor_reduce_position".to_string(), reason: "factor_reduce_position".to_string(),
+10
View File
@@ -732,6 +732,16 @@ impl PortfolioState {
state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(()) state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(())
} }
pub(crate) fn adjust_stock_pool_split(&mut self, symbol: &str, ratio: f64) -> Result<(), String> {
let ratio = rust_decimal::Decimal::from_str_exact(&ratio.to_string())
.map_err(|_| "stock_pool_execution_state_split_invalid".to_string())?;
let adjusted = self.stock_pool_states.iter()
.map(|(pool, state)| Ok((pool.clone(), state.adjust_for_split(symbol, ratio)?)))
.collect::<Result<BTreeMap<_, _>, String>>()?;
self.stock_pool_states = adjusted;
Ok(())
}
pub fn initial_cash(&self) -> f64 { pub fn initial_cash(&self) -> f64 {
self.initial_cash.to_f64() self.initial_cash.to_f64()
} }
+148 -33
View File
@@ -39,6 +39,22 @@ pub enum QuoteConditionScope {
AnyTarget, AnyTarget,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StockPoolExitRole {
OrdinarySell,
RiskExit,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolExitSignal {
pub role: StockPoolExitRole,
pub when_expr: String,
pub remaining_position_bps: u32,
pub reason: String,
}
pub fn stock_pool_target_holding_count(policy: &Value) -> Result<Option<usize>, String> { pub fn stock_pool_target_holding_count(policy: &Value) -> Result<Option<usize>, String> {
let object = policy let object = policy
.as_object() .as_object()
@@ -399,6 +415,8 @@ pub struct StockPoolExecutionRule {
pub sell_condition_scope: Option<QuoteConditionScope>, pub sell_condition_scope: Option<QuoteConditionScope>,
#[serde(skip)] #[serde(skip)]
pub secondary_sell_condition: bool, pub secondary_sell_condition: bool,
#[serde(skip)]
pub independent_sell_condition: bool,
#[serde( #[serde(
default, default,
deserialize_with = "crate::holding_policy::deserialize_optional_policy" deserialize_with = "crate::holding_policy::deserialize_optional_policy"
@@ -476,6 +494,10 @@ pub struct StockPoolDecisionConstraints {
pub default_stop_loss: Option<Decimal>, pub default_stop_loss: Option<Decimal>,
pub default_take_profit: Option<Decimal>, pub default_take_profit: Option<Decimal>,
pub position_target_bps: BTreeMap<String, u32>, pub position_target_bps: BTreeMap<String, u32>,
pub independent_position_target_bps: BTreeMap<String, u32>,
/// First actually planned holding quantity for this generation. Retries
/// apply percentages to this basis, never to the remaining holding.
pub position_action_bases: BTreeMap<String, Decimal>,
pub buy_denials: BTreeMap<String, Vec<String>>, pub buy_denials: BTreeMap<String, Vec<String>>,
pub same_day_sold_symbols: BTreeSet<String>, pub same_day_sold_symbols: BTreeSet<String>,
pub automatic_permissions: BTreeMap<String, crate::holding_policy::AutomaticTradePermission>, pub automatic_permissions: BTreeMap<String, crate::holding_policy::AutomaticTradePermission>,
@@ -508,6 +530,8 @@ pub struct StockPoolPlanRow {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StockPoolPlan { pub struct StockPoolPlan {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub position_action_bases: BTreeMap<String, Decimal>,
pub market_timing: Option<crate::stock_pool_index_policy::MarketTimingEvaluation>, pub market_timing: Option<crate::stock_pool_index_policy::MarketTimingEvaluation>,
pub rows: Vec<StockPoolPlanRow>, pub rows: Vec<StockPoolPlanRow>,
pub budget: Decimal, pub budget: Decimal,
@@ -549,6 +573,8 @@ pub struct StockPoolProgram {
pub timing_policy: Value, pub timing_policy: Value,
pub stop_take_policy: Value, pub stop_take_policy: Value,
pub out_of_pool_policy: String, pub out_of_pool_policy: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exit_signals: Vec<StockPoolExitSignal>,
} }
impl StockPoolProgram { impl StockPoolProgram {
@@ -562,10 +588,19 @@ impl StockPoolProgram {
normalize_stock_pool_members(&self.members)?; normalize_stock_pool_members(&self.members)?;
stock_pool_funding_from_configuration(&self.allocation_policy)?; stock_pool_funding_from_configuration(&self.allocation_policy)?;
stock_pool_constraints_from_configuration(&self.allocation_policy, &self.stop_take_policy)?; stock_pool_constraints_from_configuration(&self.allocation_policy, &self.stop_take_policy)?;
normalize_stock_pool_execution_rule( let mut identities = BTreeSet::new();
for signal in &self.exit_signals {
if signal.when_expr.trim().is_empty() || signal.reason.trim().is_empty() || signal.remaining_position_bps >= 10000 {
return Err("stock_pool_exit_signal_invalid".into());
}
let identity = serde_json::to_string(signal).map_err(|error| error.to_string())?;
if !identities.insert(identity) { return Err("stock_pool_exit_signal_duplicate".into()); }
}
normalize_stock_pool_execution_rule_with_exit_roles(
Some(&self.timing_policy), Some(&self.timing_policy),
secondary_buy, secondary_buy,
secondary_sell, secondary_sell || self.exit_signals.iter().any(|signal| signal.role == StockPoolExitRole::OrdinarySell),
self.exit_signals.iter().any(|signal| signal.role == StockPoolExitRole::RiskExit),
)?; )?;
if !matches!( if !matches!(
self.out_of_pool_policy.as_str(), self.out_of_pool_policy.as_str(),
@@ -583,6 +618,7 @@ impl Default for StockPoolExecutionRule {
buy_condition_scope: None, buy_condition_scope: None,
sell_condition_scope: None, sell_condition_scope: None,
secondary_sell_condition: false, secondary_sell_condition: false,
independent_sell_condition: false,
automatic_trade_protection: Default::default(), automatic_trade_protection: Default::default(),
schema_version: STOCK_POOL_SCHEMA_VERSION, schema_version: STOCK_POOL_SCHEMA_VERSION,
auto_execute: true, auto_execute: true,
@@ -669,7 +705,17 @@ pub fn build_stock_pool_target_plan_with_fee_model(
} }
} }
} }
// Validate source targets before a stronger stop/expiry can replace them.
// Otherwise an invalid ratio could be hidden by target consolidation.
for (symbol, target) in constraints.position_target_bps.iter().chain(constraints.independent_position_target_bps.iter()) {
if *target >= 10_000 {
return Err(format!("factor position target for {symbol} must be below 10000 bps"));
}
}
let mut effective_position_targets = constraints.position_target_bps.clone(); let mut effective_position_targets = constraints.position_target_bps.clone();
for (symbol, target) in &constraints.independent_position_target_bps {
effective_position_targets.entry(symbol.clone()).and_modify(|current| *current = (*current).min(*target)).or_insert(*target);
}
for (symbol, permission) in &constraints.automatic_permissions { for (symbol, permission) in &constraints.automatic_permissions {
if permission.max_holding_exit { if permission.max_holding_exit {
effective_position_targets.insert(symbol.clone(), 0); effective_position_targets.insert(symbol.clone(), 0);
@@ -764,6 +810,19 @@ pub fn build_stock_pool_target_plan_with_fee_model(
if quote_map.len() != quotes.len() { if quote_map.len() != quotes.len() {
return Err("duplicate or invalid stock pool execution quotes".into()); return Err("duplicate or invalid stock pool execution quotes".into());
} }
let declared_symbols = normalized_members.iter().map(|member| member.symbol.as_str()).collect::<BTreeSet<_>>();
for (symbol, quantity) in &constraints.position_action_bases {
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) || *quantity <= Decimal::ZERO {
return Err(format!("invalid stock pool position-action basis:{symbol}"));
}
}
for (symbol, _) in constraints.position_target_bps.iter().chain(constraints.independent_position_target_bps.iter()) {
if normalize_stock_symbol(symbol).as_deref() != Some(symbol.as_str()) || (!declared_symbols.contains(symbol.as_str()) && !current.contains_key(symbol)) {
return Err(format!("position action is outside declared candidates and managed holdings:{symbol}"));
}
}
// Exit rules act on managed holdings, not on an unheld candidate's entry.
effective_position_targets.retain(|symbol, _| current.get(symbol).is_some_and(|position| position.0 > Decimal::ZERO));
frozen::validate(selection.trade_date, constraints, &current)?; frozen::validate(selection.trade_date, constraints, &current)?;
for symbol in constraints.frozen_positions.keys() { for symbol in constraints.frozen_positions.keys() {
effective_position_targets.remove(symbol); effective_position_targets.remove(symbol);
@@ -863,37 +922,49 @@ pub fn build_stock_pool_target_plan_with_fee_model(
.then(|| symbol.clone()) .then(|| symbol.clone())
}) })
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
// A full stop is stricter than a simultaneous relative reduction. Merge
// the target before selecting its single owner, never emit a second exit.
for symbol in &global_stop_hits {
if let Some(target) = effective_position_targets.get_mut(symbol) {
*target = 0;
}
}
let mut quote_sell_exits = BTreeSet::new(); let mut quote_sell_exits = BTreeSet::new();
let mut sell_condition_denials = BTreeSet::new(); let mut sell_condition_denials = BTreeSet::new();
if rule.sell_trigger_mode == POOL_SELL_CONDITION { if rule.sell_trigger_mode == POOL_SELL_CONDITION {
let ordinary_enabled = !rule.sell_condition.trim().is_empty() || rule.secondary_sell_condition;
// Ordinary sell predicates only depend on positions participating in
// that stage. Independent stops/expiry and protected holdings were
// already decided above; unrelated quote fields must not block them.
let held = current let held = current
.iter() .iter()
.filter(|(symbol, row)| { .filter(|(symbol, row)| {
row.0 > Decimal::ZERO && !constraints.frozen_positions.contains_key(*symbol) row.0 > Decimal::ZERO
&& !protected_positions.contains(*symbol)
&& !global_stop_hits.contains(*symbol)
&& constraints.independent_position_target_bps.get(*symbol) != Some(&0)
&& !constraints.automatic_permissions.get(*symbol)
.is_some_and(|permission| permission.max_holding_exit)
}) })
.map(|(symbol, _)| symbol.clone()) .map(|(symbol, _)| symbol.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let qualified = quote_condition_results( let qualified = if ordinary_enabled { quote_condition_results(
&rule.sell_condition, &rule.sell_condition,
rule.sell_condition_scope, rule.sell_condition_scope,
&held, &held,
&quote_map, &quote_map,
)?; )? } else { BTreeMap::new() };
for symbol in held { for symbol in held {
if global_stop_hits.contains(&symbol) let permitted = ordinary_enabled && qualified.get(&symbol) == Some(&true)
|| constraints
.automatic_permissions
.get(&symbol)
.is_some_and(|permission| permission.max_holding_exit)
{
continue;
}
let permitted = qualified.get(&symbol) == Some(&true)
&& (!rule.secondary_sell_condition && (!rule.secondary_sell_condition
|| constraints.position_target_bps.contains_key(&symbol)); || constraints.position_target_bps.contains_key(&symbol));
if !permitted { if !permitted {
sell_condition_denials.insert(symbol.clone());
effective_position_targets.remove(&symbol); effective_position_targets.remove(&symbol);
if let Some(target) = constraints.independent_position_target_bps.get(&symbol) {
effective_position_targets.insert(symbol.clone(), *target);
} else {
sell_condition_denials.insert(symbol.clone());
}
} else if !rule.secondary_sell_condition { } else if !rule.secondary_sell_condition {
quote_sell_exits.insert(symbol.clone()); quote_sell_exits.insert(symbol.clone());
effective_position_targets.insert(symbol, 0); effective_position_targets.insert(symbol, 0);
@@ -944,6 +1015,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
let normalized_same_day_sold = let normalized_same_day_sold =
normalize_symbol_set(&same_day_sold_symbols.iter().cloned().collect::<Vec<_>>())?; normalize_symbol_set(&same_day_sold_symbols.iter().cloned().collect::<Vec<_>>())?;
let mut rebuy_exclusions = stop_take_exits.clone(); let mut rebuy_exclusions = stop_take_exits.clone();
rebuy_exclusions.extend(effective_position_targets.keys().cloned());
rebuy_exclusions.extend( rebuy_exclusions.extend(
normalized_same_day_sold normalized_same_day_sold
.iter() .iter()
@@ -1031,7 +1103,12 @@ pub fn build_stock_pool_target_plan_with_fee_model(
} }
let mut planning_symbols = active_symbols; let mut planning_symbols = active_symbols;
for symbol in &original_final_symbols { for symbol in &original_final_symbols {
if rebuy_exclusions.contains(symbol) && !planning_symbols.contains(symbol) { // An explicit quote/expiry position action owns its single target row.
// Keep it excluded from entry sizing without adding a second stop row.
if rebuy_exclusions.contains(symbol)
&& !factor_position_target_bps.contains_key(symbol)
&& !planning_symbols.contains(symbol)
{
planning_symbols.push(symbol.clone()); planning_symbols.push(symbol.clone());
} }
} }
@@ -1323,24 +1400,11 @@ pub fn build_stock_pool_target_plan_with_fee_model(
} }
for (symbol, target_bps) in factor_position_target_bps { for (symbol, target_bps) in factor_position_target_bps {
if *target_bps >= 10_000 {
return Err(format!(
"factor position target for {symbol} must be below 10000 bps"
));
}
if !member_map.contains_key(symbol) && !current.contains_key(symbol) { if !member_map.contains_key(symbol) && !current.contains_key(symbol) {
return Err(format!( return Err(format!(
"factor position-action symbol {symbol} is outside candidates and managed holdings" "factor position-action symbol {symbol} is outside candidates and managed holdings"
)); ));
} }
if selection.final_symbols.contains(symbol)
&& !maximum_holding_exits.contains(symbol)
&& !quote_sell_exits.contains(symbol)
{
return Err(format!(
"factor position-action symbol {symbol} cannot remain in final selection"
));
}
let current_quantity = current let current_quantity = current
.get(symbol) .get(symbol)
.map(|value| value.0) .map(|value| value.0)
@@ -1362,10 +1426,11 @@ pub fn build_stock_pool_target_plan_with_fee_model(
Decimal::ZERO Decimal::ZERO
} else { } else {
floor_step( floor_step(
current_quantity * Decimal::from(*target_bps) / Decimal::from(10_000), constraints.position_action_bases.get(symbol).copied().unwrap_or(current_quantity)
* Decimal::from(*target_bps) / Decimal::from(10_000),
step, step,
) )
}; }.min(current_quantity);
let desired_reduction = (current_quantity - requested_target).max(Decimal::ZERO); let desired_reduction = (current_quantity - requested_target).max(Decimal::ZERO);
let executable = if *target_bps == 0 { let executable = if *target_bps == 0 {
closable_quantity.min(current_quantity).max(Decimal::ZERO) closable_quantity.min(current_quantity).max(Decimal::ZERO)
@@ -1379,13 +1444,33 @@ pub fn build_stock_pool_target_plan_with_fee_model(
if current_quantity == Decimal::ZERO { if current_quantity == Decimal::ZERO {
( (
"FACTOR_EXIT_ALREADY_SATISFIED", "FACTOR_EXIT_ALREADY_SATISFIED",
"生产因子持仓动作命中,当前无持仓", "持仓退出规则命中,当前无持仓",
Decimal::ZERO, Decimal::ZERO,
Decimal::ZERO, Decimal::ZERO,
None, None,
None, None,
None, None,
) )
} else if desired_reduction == Decimal::ZERO {
(
"FACTOR_EXIT_ALREADY_SATISFIED",
"本次信号的持仓退出目标已达到,不重复减仓",
Decimal::ZERO,
current_quantity,
None,
None,
None,
)
} else if executable == Decimal::ZERO && closable_quantity >= desired_reduction {
(
"BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED",
"目标持仓差额不足最小交易单位,无需重复委托",
Decimal::ZERO,
current_quantity,
None,
None,
None,
)
} else if executable == Decimal::ZERO { } else if executable == Decimal::ZERO {
( (
"DEFERRED_T_PLUS_ONE", "DEFERRED_T_PLUS_ONE",
@@ -1414,6 +1499,10 @@ pub fn build_stock_pool_target_plan_with_fee_model(
"达到最长持有期,按配置退出" "达到最长持有期,按配置退出"
} else if quote_sell_exits.contains(symbol) { } else if quote_sell_exits.contains(symbol) {
"卖出行情条件命中" "卖出行情条件命中"
} else if stop_take_exits.contains(symbol) {
"止损/止盈触发,覆盖较弱的减仓目标"
} else if constraints.independent_position_target_bps.get(symbol) == Some(target_bps) {
"独立风险退出条件命中"
} else if *target_bps == 0 { } else if *target_bps == 0 {
"生产因子退出条件命中" "生产因子退出条件命中"
} else { } else {
@@ -1688,6 +1777,14 @@ pub fn build_stock_pool_target_plan_with_fee_model(
} }
} }
} }
// Verify disjoint planning ownership before an index cap can address rows
// by symbol. Never deduplicate emitted intentions or count proceeds twice.
let mut owners = BTreeSet::new();
for row in &rows {
if !owners.insert(row.symbol.as_str()) {
return Err(format!("stock_pool_target_owner_conflict:{}", row.symbol));
}
}
if market_timing.is_some() { if market_timing.is_some() {
let caps = index_cap::remaining_index_targets( let caps = index_cap::remaining_index_targets(
&current, &current,
@@ -1890,7 +1987,15 @@ pub fn build_stock_pool_target_plan_with_fee_model(
.into_iter() .into_iter()
.sum(); .sum();
let estimated_cash_after = available_cash - estimated_buy_amount + estimated_sell_amount; let estimated_cash_after = available_cash - estimated_buy_amount + estimated_sell_amount;
let position_action_bases = rows.iter()
.filter(|row| effective_position_targets.get(&row.symbol).is_some_and(|bps| *bps > 0)
&& row.current_quantity > Decimal::ZERO
&& row.status != "AUTOMATIC_TRADE_PROTECTED"
&& !constraints.frozen_positions.contains_key(&row.symbol))
.map(|row| (row.symbol.clone(), constraints.position_action_bases.get(&row.symbol).copied().unwrap_or(row.current_quantity)))
.collect();
Ok(StockPoolPlan { Ok(StockPoolPlan {
position_action_bases,
market_timing, market_timing,
rows, rows,
budget, budget,
@@ -2167,6 +2272,15 @@ pub fn normalize_stock_pool_execution_rule(
raw: Option<&Value>, raw: Option<&Value>,
secondary_buy_condition: bool, secondary_buy_condition: bool,
secondary_sell_condition: bool, secondary_sell_condition: bool,
) -> Result<StockPoolExecutionRule, String> {
normalize_stock_pool_execution_rule_with_exit_roles(raw, secondary_buy_condition, secondary_sell_condition, false)
}
pub fn normalize_stock_pool_execution_rule_with_exit_roles(
raw: Option<&Value>,
secondary_buy_condition: bool,
secondary_sell_condition: bool,
independent_sell_condition: bool,
) -> Result<StockPoolExecutionRule, String> { ) -> Result<StockPoolExecutionRule, String> {
let mut rule = match raw { let mut rule = match raw {
None | Some(Value::Null) => StockPoolExecutionRule::default(), None | Some(Value::Null) => StockPoolExecutionRule::default(),
@@ -2174,6 +2288,7 @@ pub fn normalize_stock_pool_execution_rule(
.map_err(|err| format!("stock pool execution_rule is invalid: {err}"))?, .map_err(|err| format!("stock pool execution_rule is invalid: {err}"))?,
}; };
rule.secondary_sell_condition = secondary_sell_condition; rule.secondary_sell_condition = secondary_sell_condition;
rule.independent_sell_condition = independent_sell_condition;
rule.automatic_trade_protection.validate()?; rule.automatic_trade_protection.validate()?;
if rule.schema_version != STOCK_POOL_SCHEMA_VERSION { if rule.schema_version != STOCK_POOL_SCHEMA_VERSION {
return Err(format!( return Err(format!(
@@ -2260,7 +2375,7 @@ pub fn normalize_stock_pool_execution_rule(
return Err("stock pool buy_condition is not supported".to_string()); return Err("stock pool buy_condition is not supported".to_string());
} }
if rule.sell_trigger_mode == POOL_SELL_CONDITION { if rule.sell_trigger_mode == POOL_SELL_CONDITION {
if (rule.sell_condition.trim().is_empty() && !secondary_sell_condition) if (rule.sell_condition.trim().is_empty() && !secondary_sell_condition && !independent_sell_condition)
|| (!rule.sell_condition.trim().is_empty() || (!rule.sell_condition.trim().is_empty()
&& parse_stock_pool_condition(&rule.sell_condition).is_none()) && parse_stock_pool_condition(&rule.sell_condition).is_none())
{ {
@@ -509,6 +509,16 @@ fn condition_plan(
quotes: &[MarketSnapshot], quotes: &[MarketSnapshot],
constraints: &StockPoolDecisionConstraints, constraints: &StockPoolDecisionConstraints,
) -> StockPoolPlan { ) -> StockPoolPlan {
condition_plan_result(selection, rule, positions, quotes, constraints).unwrap()
}
fn condition_plan_result(
selection: &StockPoolSelection,
rule: &StockPoolExecutionRule,
positions: &[Position],
quotes: &[MarketSnapshot],
constraints: &StockPoolDecisionConstraints,
) -> Result<StockPoolPlan, String> {
let held_value = positions let held_value = positions
.iter() .iter()
.map(|position| { .map(|position| {
@@ -541,7 +551,6 @@ fn condition_plan(
Decimal::ZERO, Decimal::ZERO,
Decimal::ZERO, Decimal::ZERO,
) )
.unwrap()
} }
#[test] #[test]
@@ -704,6 +713,221 @@ fn native_sell_and_quote_conditions_are_and_but_stop_and_protection_remain_indep
); );
} }
#[test]
fn independent_stop_does_not_require_unused_ordinary_sell_quote_facts() {
let mut market = quotes(1);
market[0].last_price = 9.into();
market[0].volume = None;
let rule = normalize_stock_pool_execution_rule(
Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})),
false,
false,
).unwrap();
let constraints = StockPoolDecisionConstraints {
default_stop_loss: Some(Decimal::new(5, 2)),
..Default::default()
};
let plan = condition_plan(&selection(1, 1), &rule, &[position(1)], &market, &constraints);
let exit = plan.rows.iter().find(|row| row.symbol == symbol(1)).unwrap();
assert_eq!(exit.side, Some(OrderSide::Sell), "{plan:?}");
assert_eq!(exit.target_quantity, Decimal::ZERO, "{plan:?}");
}
#[test]
fn ordinary_sell_scope_excludes_independent_exits_and_protected_positions() {
for scope in [QuoteConditionScope::PerSymbol, QuoteConditionScope::AllTargets, QuoteConditionScope::AnyTarget] {
for cause in ["stop_loss", "take_profit", "maximum_holding_exit", "automatic_trade_locked", "buy_fill_protection"] {
let mut market = quotes(2);
market[0].volume = None;
let mut constraints = StockPoolDecisionConstraints::default();
match cause {
"stop_loss" => { market[0].last_price = 9.into(); constraints.default_stop_loss = Some(Decimal::new(5, 2)); },
"take_profit" => { market[0].last_price = 12.into(); constraints.default_take_profit = Some(Decimal::new(10, 2)); },
"maximum_holding_exit" => { constraints.automatic_permissions.insert(symbol(1), crate::holding_policy::AutomaticTradePermission { max_holding_exit: true, ..Default::default() }); },
_ => { constraints.automatic_permissions.insert(symbol(1), crate::holding_policy::AutomaticTradePermission { sell_denial: Some(cause), buy_denial: Some(cause), ..Default::default() }); },
}
let rule = normalize_stock_pool_execution_rule(
Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000","sell_condition_scope":scope})),
false, false,
).unwrap();
let plan = condition_plan(&selection(2, 2), &rule, &[position(1), position(2)], &market, &constraints);
let protected = plan.rows.iter().find(|row| row.symbol == symbol(1)).unwrap();
let normal = plan.rows.iter().find(|row| row.symbol == symbol(2)).unwrap();
assert_eq!(normal.side, Some(OrderSide::Sell), "{scope:?}/{cause}: {plan:?}");
if cause == "automatic_trade_locked" || cause == "buy_fill_protection" {
assert_eq!(protected.side, None, "{scope:?}/{cause}: {plan:?}");
assert_eq!(protected.target_quantity, 1000.into(), "{scope:?}/{cause}: {plan:?}");
assert_eq!(protected.status, "AUTOMATIC_TRADE_PROTECTED", "{scope:?}/{cause}: {plan:?}");
} else {
assert_eq!(protected.side, Some(OrderSide::Sell), "{scope:?}/{cause}: {plan:?}");
assert_eq!(protected.target_quantity, Decimal::ZERO, "{scope:?}/{cause}: {plan:?}");
}
}
}
}
#[test]
fn independent_exit_quote_priority_does_not_bypass_t_plus_one_or_price_validation() {
let rule = normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})), false, false).unwrap();
let mut market = quotes(1);
market[0].last_price = 9.into(); market[0].volume = None;
let constraints = StockPoolDecisionConstraints { default_stop_loss: Some(Decimal::new(5,2)), ..Default::default() };
for closable in [0, 400, 1000] {
let mut held = position(1); held.closable_quantity = Decimal::from(closable);
let plan = condition_plan(&selection(1,1), &rule, &[held], &market, &constraints);
let row = plan.rows.iter().find(|row|row.symbol==symbol(1)).unwrap();
assert_eq!(row.delta_quantity, -Decimal::from(closable), "{plan:?}");
assert_eq!(row.target_quantity, Decimal::from(1000-closable), "{plan:?}");
}
market[0].last_price = Decimal::ZERO;
assert!(condition_plan_result(&selection(1,1), &rule, &[position(1)], &market, &constraints).unwrap_err().contains("execution quote is invalid"));
}
#[test]
fn full_stop_overrides_a_simultaneous_factor_reduction_without_a_second_target() {
let mut selected = selection(2, 1);
selected.final_symbols = vec![symbol(2)];
let mut market = quotes(2);
market[0].last_price = 9.into();
let constraints = StockPoolDecisionConstraints {
default_stop_loss: Some(Decimal::new(5, 2)),
position_target_bps: BTreeMap::from([(symbol(1), 5000)]),
..Default::default()
};
let plan = condition_plan(&selected, &StockPoolExecutionRule::default(), &[position(1)], &market, &constraints);
let rows = plan.rows.iter().filter(|row|row.symbol==symbol(1)).collect::<Vec<_>>();
assert_eq!(rows.len(),1,"{plan:?}");
assert_eq!(rows[0].target_quantity,Decimal::ZERO,"a full stop must not be weakened by a 50% reduction: {plan:?}");
assert_eq!(rows[0].delta_quantity,Decimal::from(-1000),"{plan:?}");
}
#[test]
fn stop_reduction_merge_matrix_preserves_protection_t_plus_one_and_invalid_config_errors() {
for take_profit in [false,true] {
for reduction in [0,2500,5000,9999] {
for closable in [0,400,1000] {
for locked in [false,true] {
let mut selected=selection(2,1);selected.final_symbols=vec![symbol(2)];
let mut market=quotes(2);market[0].last_price=if take_profit {12.into()} else {9.into()};market[0].volume=None;
let mut held=position(1);held.closable_quantity=Decimal::from(closable);
let mut constraints=StockPoolDecisionConstraints {position_target_bps:BTreeMap::from([(symbol(1),reduction)]),..Default::default()};
if take_profit {constraints.default_take_profit=Some(Decimal::new(10,2))} else {constraints.default_stop_loss=Some(Decimal::new(5,2))}
if locked {constraints.automatic_permissions.insert(symbol(1),crate::holding_policy::AutomaticTradePermission {sell_denial:Some("automatic_trade_locked"),buy_denial:Some("automatic_trade_locked"),..Default::default()});}
let rule=normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})),false,true).unwrap();
let plan=condition_plan(&selected,&rule,&[held],&market,&constraints);
let rows=plan.rows.iter().filter(|row|row.symbol==symbol(1)).collect::<Vec<_>>();
assert_eq!(rows.len(),1,"{plan:?}");
let sold=if locked {0} else {closable};
assert_eq!(rows[0].delta_quantity,-Decimal::from(sold),"{plan:?}");
assert_eq!(rows[0].target_quantity,Decimal::from(1000-sold),"{plan:?}");
assert_eq!(plan.estimated_sell_amount,Decimal::from(sold)*market[0].last_price,"{plan:?}");
if locked {assert_eq!(rows[0].status,"AUTOMATIC_TRADE_PROTECTED","{plan:?}")}
}
}
}
}
let mut invalid=StockPoolDecisionConstraints {default_stop_loss:Some(Decimal::new(5,2)),position_target_bps:BTreeMap::from([(symbol(1),10000)]),..Default::default()};
invalid.automatic_permissions.insert(symbol(1),crate::holding_policy::AutomaticTradePermission {max_holding_exit:true,..Default::default()});
assert!(condition_plan_result(&selection(1,1),&StockPoolExecutionRule::default(),&[position(1)],&quotes(1),&invalid).unwrap_err().contains("must be below 10000"));
}
#[test]
fn ordinary_sell_keeps_required_quote_failures_and_zero_stop_is_not_an_exit() {
let rule = normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})), false, false).unwrap();
let mut market = quotes(1); market[0].last_price = 9.into(); market[0].volume = None;
for stop in [None, Some(Decimal::ZERO)] {
let constraints = StockPoolDecisionConstraints { default_stop_loss: stop, ..Default::default() };
assert_eq!(condition_plan_result(&selection(1,1), &rule, &[position(1)], &market, &constraints).unwrap_err(), "condition requires volume");
}
}
#[test]
fn quote_field_operator_side_and_scope_matrix_matches_the_configured_predicate() {
let market = quotes(2);
for (field, threshold) in [("price", "10"), ("last", "10"), ("change_pct", "0"), ("volume", "1000000"), ("amount", "10000000"), ("bid1", "10"), ("ask1", "10")] {
for (operator, matched) in [(">",false), (">=",true), ("<",false), ("<=",true), ("==",true), ("!=",false)] {
for scope in [QuoteConditionScope::PerSymbol, QuoteConditionScope::AllTargets, QuoteConditionScope::AnyTarget] {
for side in [OrderSide::Buy, OrderSide::Sell] {
let condition = format!("{field}{operator}{threshold}");
let config = if side == OrderSide::Buy {
json!({"trigger_mode":"condition","buy_condition":condition,"buy_condition_scope":scope})
} else {
json!({"sell_trigger_mode":"condition","sell_condition":condition,"sell_condition_scope":scope})
};
let rule = normalize_stock_pool_execution_rule(Some(&config), false, false).unwrap();
let held = if side == OrderSide::Sell { vec![position(1),position(2)] } else { vec![] };
let plan = condition_plan(&selection(2,2), &rule, &held, &market, &StockPoolDecisionConstraints::default());
assert_eq!(plan.rows.iter().map(|row| &row.symbol).collect::<BTreeSet<_>>().len(), plan.rows.len(), "duplicate target ownership: {plan:?}");
let orders = plan.rows.iter().filter(|row|row.side==Some(side)).count();
assert_eq!(orders, if matched {2} else {0}, "{side:?}/{scope:?}/{condition}: {plan:?}");
if side == OrderSide::Sell && matched {
assert_eq!(plan.estimated_sell_amount, Decimal::from(20000), "{plan:?}");
assert_eq!(plan.estimated_cash_after, Decimal::from(40000), "{plan:?}");
}
}
}
}
}
}
#[test]
fn typed_exit_roles_merge_only_satisfied_ordinary_actions_with_independent_risk() {
for risk in [None,Some(0),Some(5000)] {
for ordinary in [None,Some(0),Some(7500)] {
for quote in ["","price<9","price>9"] {
for locked in [false,true] {
for closable in [0,400,1000] {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":quote})),false,true,true).unwrap();
let mut constraints=StockPoolDecisionConstraints {portfolio_policy:Some(StockPoolPortfolioPolicy{schema_version:1,membership:MembershipPolicy::RetainHoldings,rebalance_weights:false}),..Default::default()};
if let Some(target)=ordinary {constraints.position_target_bps.insert(symbol(1),target);}
if let Some(target)=risk {constraints.independent_position_target_bps.insert(symbol(1),target);}
if locked {constraints.automatic_permissions.insert(symbol(1),crate::holding_policy::AutomaticTradePermission{sell_denial:Some("automatic_trade_locked"),buy_denial:Some("automatic_trade_locked"),..Default::default()});}
let mut held=position(1);held.closable_quantity=closable.into();
let plan=condition_plan(&selection(1,1),&rule,&[held],&quotes(1),&constraints);
assert_eq!(plan.rows.len(),1,"{risk:?}/{ordinary:?}/{quote}: {plan:?}");
let ordinary=if quote=="price<9" {None} else {ordinary};
let target_bps=risk.into_iter().chain(ordinary).min().unwrap_or(10000);
let desired=if target_bps==0 {0} else {(1000*target_bps/10000)/100*100};
let sold=if locked {0} else {(1000-desired).min(closable)};
assert_eq!(plan.rows[0].delta_quantity,-Decimal::from(sold),"{risk:?}/{ordinary:?}/{quote}: {plan:?}");
}
}
}
}
}
}
#[test]
fn risk_only_configuration_never_turns_into_an_unconditional_ordinary_exit() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition"})),false,false,true).unwrap();
let mut constraints=StockPoolDecisionConstraints::default();
let hold=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&constraints);
assert_ne!(hold.rows[0].side,Some(OrderSide::Sell),"a risk-only configuration must not manufacture an exit: {hold:?}");
constraints.independent_position_target_bps.insert(symbol(1),5000);
let exit=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&constraints);
assert_eq!(exit.rows[0].delta_quantity,Decimal::from(-500),"{exit:?}");
let unheld=condition_plan(&selection(1,1),&rule,&[],&quotes(1),&constraints);
assert_eq!(unheld.rows[0].side,Some(OrderSide::Buy),"an exit-only rule must not secretly become a selection/buy filter: {unheld:?}");
}
#[test]
fn quote_only_exit_still_works_when_independent_risk_rules_are_configured() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"price>9"})),false,false,true).unwrap();
let plan=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&StockPoolDecisionConstraints::default());
assert_eq!(plan.rows.len(),1);assert_eq!(plan.rows[0].delta_quantity,Decimal::from(-1000),"{plan:?}");
}
#[test]
fn independent_full_exit_has_no_ordinary_quote_dependency_but_partial_risk_does_not_fake_missing_facts() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>100"})),false,true,true).unwrap();
let mut market=quotes(1);market[0].volume=None;
let mut constraints=StockPoolDecisionConstraints {position_target_bps:BTreeMap::from([(symbol(1),0)]),independent_position_target_bps:BTreeMap::from([(symbol(1),0)]),..Default::default()};
let complete=condition_plan(&selection(1,1),&rule,&[position(1)],&market,&constraints);
assert_eq!(complete.rows[0].delta_quantity,Decimal::from(-1000));
constraints.independent_position_target_bps.insert(symbol(1),5000);
assert!(condition_plan_result(&selection(1,1),&rule,&[position(1)],&market,&constraints).unwrap_err().contains("requires volume"));
}
#[test] #[test]
fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() { fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() {
let mut constraints = StockPoolDecisionConstraints::default(); let mut constraints = StockPoolDecisionConstraints::default();
+86 -2
View File
@@ -24,6 +24,14 @@ pub struct StockPoolEntryProgress {
pub completion_quantity: Option<Decimal>, pub completion_quantity: Option<Decimal>,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolPositionActionBasis {
pub generation: String,
pub first_execution_date: NaiveDate,
pub quantity: Decimal,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct StockPoolExecutionState { pub struct StockPoolExecutionState {
@@ -34,6 +42,10 @@ pub struct StockPoolExecutionState {
pub last_target_weights: BTreeMap<String, i32>, pub last_target_weights: BTreeMap<String, i32>,
/// First signal excluding an actually held member; not an acquisition date. /// First signal excluding an actually held member; not an acquisition date.
pub removed_since: BTreeMap<String, NaiveDate>, pub removed_since: BTreeMap<String, NaiveDate>,
/// Signal progress, not a fill or holding-period fact. Kept across retries
/// and later execution sessions until a new generation supersedes it.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub position_action_bases: BTreeMap<String, StockPoolPositionActionBasis>,
} }
pub struct StockPoolGoalObservation<'a> { pub struct StockPoolGoalObservation<'a> {
@@ -53,6 +65,7 @@ impl Default for StockPoolExecutionState {
entries: BTreeMap::new(), entries: BTreeMap::new(),
last_target_weights: BTreeMap::new(), last_target_weights: BTreeMap::new(),
removed_since: BTreeMap::new(), removed_since: BTreeMap::new(),
position_action_bases: BTreeMap::new(),
} }
} }
} }
@@ -62,6 +75,7 @@ impl StockPoolExecutionState {
if self.schema_version != 1 if self.schema_version != 1
|| self.entries.len() > 10000 || self.entries.len() > 10000
|| self.removed_since.len() > 10000 || self.removed_since.len() > 10000
|| self.position_action_bases.len() > 10000
{ {
return Err("stock_pool_execution_state_invalid_schema_or_size".into()); return Err("stock_pool_execution_state_invalid_schema_or_size".into());
} }
@@ -70,6 +84,7 @@ impl StockPoolExecutionState {
.keys() .keys()
.chain(self.removed_since.keys()) .chain(self.removed_since.keys())
.chain(self.last_target_weights.keys()) .chain(self.last_target_weights.keys())
.chain(self.position_action_bases.keys())
{ {
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) { if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
return Err("stock_pool_execution_state_invalid_symbol".into()); return Err("stock_pool_execution_state_invalid_symbol".into());
@@ -97,6 +112,12 @@ impl StockPoolExecutionState {
{ {
return Err("stock_pool_execution_state_invalid_goal_or_clock".into()); return Err("stock_pool_execution_state_invalid_goal_or_clock".into());
} }
if self.position_action_bases.values().any(|basis| {
basis.generation.trim().is_empty() || basis.quantity <= Decimal::ZERO
|| self.last_execution_date.is_none_or(|date| basis.first_execution_date > date)
}) {
return Err("stock_pool_execution_state_invalid_action_basis".into());
}
Ok(()) Ok(())
} }
@@ -186,7 +207,7 @@ impl StockPoolExecutionState {
self.record_targets( self.record_targets(
decision_date, decision_date,
generation, generation,
plan.rows.iter().map(|row| StockPoolGoalObservation { plan.rows.iter().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).map(|row| StockPoolGoalObservation {
symbol: &row.symbol, symbol: &row.symbol,
target_weight_bps: row.target_weight_bps, target_weight_bps: row.target_weight_bps,
target_value: row.target_value, target_value: row.target_value,
@@ -194,7 +215,67 @@ impl StockPoolExecutionState {
target_quantity: row.target_quantity, target_quantity: row.target_quantity,
status: &row.status, status: &row.status,
}), }),
) )?.record_position_action_bases(generation, &plan.position_action_bases)
}
pub fn position_action_bases_for(&self, generation: &str) -> BTreeMap<String, Decimal> {
self.position_action_bases.iter()
.filter(|(_, basis)| basis.generation == generation)
.map(|(symbol, basis)| (symbol.clone(), basis.quantity))
.collect()
}
/// A verified split changes the share unit, not the intended reduction or
/// entry completion. Never infer a split from a changed holding quantity.
pub fn adjust_for_split(&self, symbol: &str, ratio: Decimal) -> Result<Self, String> {
self.validate()?;
if ratio <= Decimal::ZERO || normalize_stock_symbol(symbol).as_deref() != Some(symbol) {
return Err("stock_pool_execution_state_split_invalid".into());
}
let scale = |quantity: Decimal| quantity.checked_mul(ratio)
.map(|value| value.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero))
.ok_or_else(|| "stock_pool_execution_state_split_overflow".to_string());
let mut next = self.clone();
if let Some(entry) = next.entries.get_mut(symbol) {
if let Some(quantity) = entry.completion_quantity {
let quantity = scale(quantity)?;
entry.completion_quantity = (quantity > Decimal::ZERO).then_some(quantity);
}
}
if let Some(basis) = next.position_action_bases.get_mut(symbol) {
basis.quantity = scale(basis.quantity)?;
if basis.quantity == Decimal::ZERO { next.position_action_bases.remove(symbol); }
}
next.validate()?;
Ok(next)
}
pub fn record_position_action_bases(
&self,
generation: &str,
quantities: &BTreeMap<String, Decimal>,
) -> Result<Self, String> {
self.validate()?;
if generation.trim().is_empty() {
return Err("stock_pool_execution_state_action_generation_missing".into());
}
let first_execution_date = self.last_execution_date
.ok_or("stock_pool_execution_state_action_clock_missing")?;
let mut next = self.clone();
next.position_action_bases.retain(|_, basis| basis.generation == generation);
for (symbol, quantity) in quantities {
if let Some(basis) = next.position_action_bases.get(symbol) {
if basis.quantity != *quantity {
return Err(format!("stock_pool_execution_state_action_basis_changed:{symbol}"));
}
} else {
next.position_action_bases.insert(symbol.clone(), StockPoolPositionActionBasis {
generation: generation.into(), first_execution_date, quantity: *quantity,
});
}
}
next.validate()?;
Ok(next)
} }
pub fn record_targets<'a>( pub fn record_targets<'a>(
@@ -213,6 +294,9 @@ impl StockPoolExecutionState {
} }
let mut next = self.clone(); let mut next = self.clone();
for row in rows { for row in rows {
if row.status == "AUTOMATIC_TRADE_PROTECTED" {
continue;
}
if row.target_weight_bps > 0 { if row.target_weight_bps > 0 {
next.last_target_weights next.last_target_weights
.insert(row.symbol.into(), row.target_weight_bps); .insert(row.symbol.into(), row.target_weight_bps);
@@ -445,11 +445,56 @@ fn actual_fill_protection_is_evaluated_on_execution_date() {
assert_eq!(account.position(&code(1)).unwrap().quantity, 1000); assert_eq!(account.position(&code(1)).unwrap().quantity, 1000);
} }
#[test]
fn ordinary_sell_has_one_order_owner_before_broker_execution() {
let data = data(false);
let broker = broker(false);
let mut account = PortfolioState::new(20_000.);
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
let mut intent = contract(day(2), 1, false);
intent.rule.sell_trigger_mode = POOL_SELL_CONDITION.into();
intent.rule.sell_condition = "price>0".into();
let report = broker.execute_with_event_dates(
day(5), day(2), day(2), &mut account, &data, &decision(intent),
).unwrap();
let sells=report.fill_events.iter().filter(|row|row.symbol==code(1)).collect::<Vec<_>>();
assert_eq!(sells.len(),1,"{report:?}");
assert_eq!(sells[0].quantity,1000,"{report:?}");
let owners=report.order_events.iter().filter(|row|row.symbol==code(1)).map(|row|row.order_id).collect::<BTreeSet<_>>();
assert_eq!(owners.len(),1,"{report:?}");
assert_eq!(account.position(&code(1)).map(|row|row.quantity).unwrap_or(0),0);
// The replacement may enter only after the single sell has settled.
let replacement=report.fill_events.iter().find(|row|row.symbol==code(2)).unwrap();
assert_eq!(replacement.quantity,3000,"{report:?}");
assert_eq!(report.account_events[0].cash_after,40000.);
assert_eq!(report.account_events[1].cash_before,40000.);
}
#[test]
fn repeating_the_same_partial_exit_generation_does_not_reduce_again() {
let data=data(false);let broker=broker(false);let mut account=PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(2),1000,10.);
let mut intent=contract(day(2),1,true);
intent.constraints.independent_position_target_bps.insert(code(1),5000);
let first=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
assert_eq!(first.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),500);
let repeated=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
assert!(repeated.fill_events.iter().all(|fill|fill.symbol!=code(1)),"same generation must keep its first partial-exit target: {repeated:?}");
assert_eq!(account.position(&code(1)).unwrap().quantity,500);
let next_day=broker.execute_with_event_dates(day(6),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
assert!(next_day.fill_events.iter().all(|fill|fill.symbol!=code(1)),"{next_day:?}");
assert_eq!(account.position(&code(1)).unwrap().quantity,500);
intent.generation="a-new-reduction-signal".into();
let new_signal=broker.execute_with_event_dates(day(6),day(6),day(6),&mut account,&data,&decision(intent)).unwrap();
assert_eq!(new_signal.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),300);
}
#[test] #[test]
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() { fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
let intent = contract(day(2), 1, false); let intent = contract(day(2), 1, false);
for quote_condition in ["", "price<5"] { for quote_condition in ["", "price<5"] {
let program = StockPoolProgram { let program = StockPoolProgram {
exit_signals: vec![],
schema_version: 1, schema_version: 1,
pool_id: "pool-fixture".into(), pool_id: "pool-fixture".into(),
version_id: "version-fixture".into(), version_id: "version-fixture".into(),
@@ -516,6 +561,34 @@ fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translat
} }
} }
#[test]
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
for (ordinary, risk, quote, sold) in [
(Some(0),None,"price<1",0),
(None,Some(0),"price<1",3000),
(Some(0),Some(5000),"price<1",1500),
(Some(0),Some(5000),"price>1",3000),
(None,Some(5000),"",1500),
] {
let exits=ordinary.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::OrdinarySell,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"ordinary fixture".into()})
.chain(risk.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::RiskExit,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"risk fixture".into()})).collect::<Vec<_>>();
let program=StockPoolProgram{schema_version:1,pool_id:"typed-exits".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","sell_trigger_mode":"condition","sell_condition":quote}),
stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),exit_signals:exits};
let mut config=platform_expr_config_from_value("typed-exits","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
config.stock_filter_expr="close>0".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));
config.matching_type=MatchingType::CurrentBarClose;
let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose),BacktestConfig{
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Close,
}).run().unwrap();
assert_eq!(result.fills.iter().filter(|fill|fill.date==day(2)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Buy).map(|fill|fill.quantity).sum::<u32>(),3000,"exit-only criteria must not suppress a new entry: {result:?}");
let sold_quantity=result.fills.iter().filter(|fill|fill.date==day(5)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Sell).map(|fill|fill.quantity).sum::<u32>();
assert_eq!(sold_quantity,sold,"ordinary={ordinary:?} risk={risk:?} quote={quote}: {result:?}");
}
}
#[test] #[test]
fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() { fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() {
// Generated by OmniQuant's actual handoff and compiler, not a hand-written // Generated by OmniQuant's actual handoff and compiler, not a hand-written
@@ -638,3 +711,199 @@ fn next_day_outside_policy_executes_after_the_first_exclusion_signal() {
); );
assert_eq!(account.position(&code(2)).unwrap().quantity, 3000); assert_eq!(account.position(&code(2)).unwrap().quantity, 3000);
} }
fn etf_fallback_fixture(time: chrono::NaiveTime) -> DataSet {
let mut parts = data_with_fund_rules(1_000_000, None, true).snapshot_components();
let previous = NaiveDate::from_ymd_opt(2025,12,31).unwrap();
for instrument in &mut parts.instruments { instrument.listed_at = Some(NaiveDate::from_ymd_opt(2025,12,1).unwrap()); }
let mut past_market = parts.market.iter().filter(|row| row.date == day(2)).cloned().collect::<Vec<_>>();
for row in &mut past_market { row.date=previous; if row.symbol == code(2) { row.close=5.; row.open=5.; row.high=5.; row.low=5.; row.last_price=5.; } }
parts.market.extend(past_market);
let mut past_factors=parts.factors.iter().filter(|row|row.date==day(2)).cloned().collect::<Vec<_>>();
for row in &mut past_factors {row.date=previous;}
parts.factors.extend(past_factors);
let mut past_candidates=parts.candidates.iter().filter(|row|row.date==day(2)).cloned().collect::<Vec<_>>();
for row in &mut past_candidates {row.date=previous;}
parts.candidates.extend(past_candidates);
for factor in &mut parts.factors { if factor.symbol==code(2) {factor.market_cap_bn=f64::NAN;factor.free_float_cap_bn=f64::NAN;} }
let mut past_benchmark = parts.benchmarks[0].clone(); past_benchmark.date=previous; parts.benchmarks.push(past_benchmark);
for row in &mut parts.market {
if row.symbol==code(2) && row.date>=day(2) {
row.open=if row.date==day(2) {10.} else {4.}; row.day_open=row.open;
row.close=40.; row.last_price=40.; row.high=40.; row.low=row.open; row.prev_close=5.;
}
}
parts.execution_quotes.retain(|row| row.symbol==code(1));
for quote in &mut parts.execution_quotes { quote.timestamp=quote.date.and_time(time); }
DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap()
}
struct EtfPoolSignal { at:chrono::NaiveTime, condition:String }
impl fidc_core::strategy::Strategy for EtfPoolSignal {
fn name(&self)->&str {"ETF fallback fixture"}
fn requires_minute_callbacks(&self)->bool {false}
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {vec![self.at]}
fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError> {Ok(BTreeSet::from([code(1),code(2)]))}
fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {
if ctx.execution_date!=day(2) {return Ok(StrategyDecision::default());}
let mut intent=contract(day(2),1,true);
intent.selection.final_symbols=vec![code(1),code(2)];
intent.constraints.target_holding_count=Some(2);
intent.rule.buy_condition=self.condition.clone();
Ok(decision(intent))
}
}
fn run_etf_fallback(time:chrono::NaiveTime,end:NaiveDate,enabled:bool,condition:&str,loader_fails:bool,volume_limit:bool)->Result<fidc_core::BacktestResult,fidc_core::BacktestError> {
let broker=broker(volume_limit).with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time)
.with_historical_etf_open_fallback(enabled);
BacktestEngine::new(etf_fallback_fixture(time),EtfPoolSignal{at:time,condition:condition.into()},broker,BacktestConfig{
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(end),decision_lag_trading_days:0,execution_price_field:PriceField::Last,
}).with_execution_quote_loader(Box::new(move |_| {
if loader_fails {Err(fidc_core::BacktestError::Execution("fixture_source_unavailable".into()))} else {Ok(vec![])}
})).run()
}
#[test]
fn historical_etf_open_uses_real_open_without_creating_minute_bars() {
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(9,30,0).unwrap(),day(6),true,"",false,false).unwrap();
let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::<Vec<_>>();
assert_eq!(etf.len(),1,"{:?}",result.fills);
assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(2),10.,1500));
assert_eq!(etf[0].execution_timestamp,Some(day(2).and_hms_opt(9,30,0).unwrap()));
assert!(etf[0].reason.contains("etf_daily_open_fallback"));
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2)));
}
#[test]
fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_open() {
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(6),true,"",false,false).unwrap();
let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::<Vec<_>>();
assert_eq!(etf.len(),1,"{:?}",result.fills);
assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(5),4.,3700));
assert_eq!(etf[0].execution_timestamp,Some(day(5).and_hms_opt(9,30,0).unwrap()));
assert_eq!(etf[0].order_created_date,Some(day(2)));
assert!(etf[0].reason.contains("2026-01-02 13:00:00"));
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2)));
assert!(result.terminal_audit.is_clean());
}
#[test]
fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() {
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap();
assert_eq!(result.terminal_audit.deferred_etf_target_count,1);
assert_eq!(result.terminal_audit.status,fidc_core::BacktestTerminalStatus::CompletedWithPendingState);
assert!(result.order_events.iter().all(|order|order.symbol!=code(2)));
assert!(result.fills.iter().all(|fill|fill.symbol!=code(2)));
}
#[test]
fn historical_etf_fallback_does_not_waive_source_conditions_or_capacity() {
let at=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
assert!(run_etf_fallback(at,day(6),false,"",false,false).is_err());
assert!(run_etf_fallback(at,day(6),true,"last > 1",false,false).unwrap_err().to_string().contains("condition evidence unavailable"));
assert!(run_etf_fallback(at,day(6),true,"",true,false).unwrap_err().to_string().contains("fixture_source_unavailable"));
assert!(run_etf_fallback(at,day(6),true,"",false,true).unwrap_err().to_string().contains("capacity is missing"));
}
#[test]
fn compiled_pool_price_screen_does_not_require_unconfigured_etf_market_cap() {
let time=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
let intent=contract(day(2),1,true);
let program=StockPoolProgram {
exit_signals: vec![],
schema_version:1,pool_id:"typed-mixed-pool".into(),version_id:"v1".into(),members:intent.members,
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"09:30"}),
stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),
};
let mut config=platform_expr_config_from_value("etf-no-cap-filter","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
config.stock_filter_expr="close > 0".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into();
config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));
config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose;
config.risk_config.trading_constraints.volume_limit_enabled=false;
let result=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config.clone()),
broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)),"{:?}",result.equity_curve.iter().map(|row|&row.diagnostics).collect::<Vec<_>>());
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)));
config.stock_filter_expr="last != 0".into();
let rejected=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config),
broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap_err();
assert!(rejected.to_string().contains("etf_intraday_condition_evidence_missing"),"{rejected}");
}
#[test]
fn etf_signal_budget_does_not_read_the_current_sessions_future_close() {
let run=|future_close:f64| {
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
let mut parts=etf_fallback_fixture(time).snapshot_components();
for row in &mut parts.market {
if row.symbol==code(2)&&row.date==day(5) {row.close=future_close;row.last_price=future_close;row.high=future_close.max(row.open);}
}
let data=DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap();
let program=StockPoolProgram{exit_signals:vec![],schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":true}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"13:00","window_end":"14:55"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into()};
let mut config=platform_expr_config_from_value("etf-budget","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"13:00"}}})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
config.stock_filter_expr="true".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into();
config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose;
config.risk_config.trading_constraints.volume_limit_enabled=false;
BacktestEngine::new(data,PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap()
};
let a=run(40.);let b=run(400.);
let budget=|result:&fidc_core::BacktestResult|result.equity_curve.iter().find(|row|row.date==day(5)).unwrap().diagnostics.split(" | ").find(|line|line.starts_with("stock_pool_signal_frozen")).unwrap().to_string();
assert_eq!(budget(&a),budget(&b));
assert_eq!(serde_json::to_value(&a.fills).unwrap(),serde_json::to_value(&b.fills).unwrap());
}
struct EtfReallocationSignal { protection_days: u32 }
impl fidc_core::strategy::Strategy for EtfReallocationSignal {
fn name(&self)->&str {"deferred ETF sell funding"}
fn requires_minute_callbacks(&self)->bool {false}
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {vec![chrono::NaiveTime::from_hms_opt(13,0,0).unwrap()]}
fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError>{Ok(BTreeSet::from([code(1),code(2)]))}
fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {
if ![day(2),day(6)].contains(&ctx.execution_date) {return Ok(Default::default());}
let mut intent=contract(ctx.execution_date,1,false);
intent.rule.automatic_trade_protection.buy_protection_days=self.protection_days;
if ctx.execution_date==day(2) {intent.selection.final_symbols=vec![code(1),code(2)];intent.constraints.target_holding_count=Some(2);}
else {intent.frozen_equity=300000.into();intent.out_of_pool_policy="reduce_to_zero_when_sellable".into();}
Ok(decision(intent))
}
}
#[test]
fn deferred_etf_sell_does_not_finance_same_day_stock_topup() {
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:0},
broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5)));
assert!(result.fills.iter().all(|fill|fill.date!=day(6)),"{:?}",result.fills);
assert!(!result.order_events.iter().any(|order|order.date==day(6)&&order.symbol==code(1)&&order.side==fidc_core::OrderSide::Buy),"{:?}",result.order_events);
assert_eq!(result.terminal_audit.deferred_etf_target_count,1);
}
#[test]
fn etf_post_buy_protection_starts_on_deferred_fill_day_not_signal_day() {
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:1},
broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5)));
assert!(result.fills.iter().filter(|fill|fill.symbol==code(2)).all(|fill|fill.side!=fidc_core::OrderSide::Sell));
// Jan 2 is the signal; actual Jan 5 fill protects Jan 5 and Jan 6.
// Starting the timer on Jan 2 would incorrectly queue an exit on Jan 6.
assert_eq!(result.terminal_audit.deferred_etf_target_count,0);
}
@@ -158,6 +158,42 @@ fn legacy_state_without_quantity_keeps_its_serialized_identity() {
assert_eq!(serde_json::to_value(state).unwrap(), original); assert_eq!(serde_json::to_value(state).unwrap(), original);
} }
#[test]
fn partial_exit_basis_is_immutable_restart_safe_and_scoped_to_the_signal() {
let original = StockPoolExecutionState::default()
.observe(day(11), day(11), &[day(11), day(14)], &[member()], &[held(1000, 1000)]).unwrap();
let basis = BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))]);
let saved = original.record_position_action_bases("sell-signal", &basis).unwrap();
assert!(original.position_action_bases.is_empty(), "a preview must not mutate its input");
let restored: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&saved).unwrap()).unwrap();
let next_day = restored.observe(day(11), day(14), &[day(11), day(14)], &[member()], &[held(500, 500)]).unwrap();
assert_eq!(next_day.position_action_bases_for("sell-signal"), basis);
assert!(next_day.position_action_bases_for("new-signal").is_empty());
assert!(next_day.record_position_action_bases("sell-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap_err().contains("basis_changed"));
let new_signal = next_day.record_position_action_bases("new-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap();
assert!(new_signal.position_action_bases_for("sell-signal").is_empty());
assert_eq!(new_signal.position_action_bases_for("new-signal")["000001.SZ"], Decimal::from(500));
for invalid in [Decimal::ZERO, Decimal::NEGATIVE_ONE] {
assert!(original.record_position_action_bases("signal", &BTreeMap::from([("000001.SZ".into(), invalid)])).is_err());
}
assert!(original.record_position_action_bases(" ", &basis).is_err());
}
#[test]
fn verified_split_adjusts_exit_basis_and_entry_completion_not_generation() {
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &[day(11)], &[member()], &[]).unwrap();
let entry_plan = plan(&initial, day(11), &[member()], &[], 10000, "hold");
let entered = initial.record_plan(day(11), "entry", &entry_plan).unwrap();
let saved = entered.record_position_action_bases("sell", &BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))])).unwrap();
let adjusted = saved.adjust_for_split("000001.SZ", Decimal::new(15,1)).unwrap();
assert_eq!(adjusted.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1500));
assert_eq!(adjusted.entries["000001.SZ"].completion_quantity, Some(Decimal::from(1500)));
assert_eq!(adjusted.position_action_bases["000001.SZ"].first_execution_date, day(11));
assert_eq!(saved.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1000));
assert!(saved.adjust_for_split("000001.SZ", Decimal::ZERO).is_err());
}
#[test] #[test]
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() { fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
let members = vec![member()]; let members = vec![member()];