Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 875e31f71f | |||
| 61bd14d001 | |||
| 531df39911 | |||
| e912e2a6f5 | |||
| b1f2fcb85c | |||
| 828690a51e | |||
| ffd23b9920 | |||
| 4ac9ee5058 | |||
| 848c1a514a | |||
| 099759ae67 | |||
| d646ca455d | |||
| 3b2a97fa84 | |||
| 2f2258f208 | |||
| 32a34fadd6 | |||
| 7ac87a90c4 | |||
| 5949d4cc69 | |||
| 24cb4805a7 | |||
| 053f880e34 | |||
| 4d3a9e0e5b |
Generated
+30
@@ -145,6 +145,15 @@ version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.59"
|
||||
@@ -192,6 +201,20 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
@@ -316,6 +339,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"chrono",
|
||||
"compact_str",
|
||||
"indexmap",
|
||||
"rayon",
|
||||
"rhai",
|
||||
@@ -2044,3 +2068,9 @@ dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
compact_str = { version = "=0.10.0", features = ["serde"] }
|
||||
rust_decimal.workspace = true
|
||||
ahash.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
+402
-126
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,11 @@ fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
|
||||
.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(
|
||||
portfolio: &PortfolioState,
|
||||
date: NaiveDate,
|
||||
@@ -42,8 +47,10 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
data: &DataSet,
|
||||
symbols: &BTreeSet<String>,
|
||||
execution_clock: Option<NaiveDateTime>,
|
||||
) -> Result<Vec<pool::MarketSnapshot>, BacktestError> {
|
||||
symbols
|
||||
cumulative_conditions: bool,
|
||||
) -> Result<(Vec<pool::MarketSnapshot>, Vec<String>), BacktestError> {
|
||||
let mut unavailable = Vec::new();
|
||||
let quotes = symbols
|
||||
.iter()
|
||||
.map(|symbol| {
|
||||
let snapshot = data.market(date, symbol).ok_or_else(|| {
|
||||
@@ -54,7 +61,13 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
let instrument = data.instruments().get(symbol).ok_or_else(|| {
|
||||
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()
|
||||
{
|
||||
let time = self
|
||||
@@ -123,11 +136,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
None,
|
||||
calibration.as_ref(),
|
||||
)?;
|
||||
let totals = if cumulative_conditions {
|
||||
match data.execution_session_totals(symbol, clock) {
|
||||
Ok(totals) => Some(totals),
|
||||
Err(reason) => { unavailable.push(reason); None }
|
||||
}
|
||||
} else { None };
|
||||
(
|
||||
quote.last_price,
|
||||
snapshot.prev_close,
|
||||
Some(quote.volume_delta as f64),
|
||||
Some(quote.amount_delta),
|
||||
totals.map(|total| total.0),
|
||||
totals.map(|total| total.1),
|
||||
Some(quote.bid1),
|
||||
Some(quote.ask1),
|
||||
buy,
|
||||
@@ -142,13 +161,24 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
}
|
||||
// A daily open does not reveal the session's volume/turnover.
|
||||
let completed = self.effective_execution_price_field(date) == PriceField::Close;
|
||||
let totals = if cumulative_conditions && !completed {
|
||||
let at = execution_clock.unwrap_or_else(|| date.and_hms_opt(9,30,0).unwrap());
|
||||
match data.execution_session_totals(symbol, at) {
|
||||
Ok(totals) => Some(totals),
|
||||
Err(reason) => { unavailable.push(reason); None }
|
||||
}
|
||||
} else { None };
|
||||
let amount = if completed && cumulative_conditions {
|
||||
data.factor(date, symbol).and_then(|row| row.extra_factors.get("amount")).copied()
|
||||
.map(|value| decimal(value, "amount")).transpose()?
|
||||
} else { totals.map(|total| total.1) };
|
||||
(
|
||||
price,
|
||||
snapshot.prev_close,
|
||||
completed.then_some(snapshot.volume as f64),
|
||||
if completed { Some(Decimal::from(snapshot.volume)) } else { totals.map(|total| total.0) },
|
||||
amount,
|
||||
None,
|
||||
None,
|
||||
Some(price),
|
||||
Some(price),
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?,
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?,
|
||||
)
|
||||
@@ -157,8 +187,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
symbol: symbol.clone(),
|
||||
last_price: decimal(price, "price")?,
|
||||
prev_close: Some(decimal(prev, "prev_close")?),
|
||||
volume: volume.map(|v| decimal(v, "volume")).transpose()?,
|
||||
turnover: amount.map(|v| decimal(v, "amount")).transpose()?,
|
||||
volume,
|
||||
turnover: amount,
|
||||
bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?,
|
||||
ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?,
|
||||
is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")),
|
||||
@@ -171,7 +201,18 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
sell_sizing_price: Some(decimal(sell_price, "sell_price")?),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect::<Result<Vec<_>, BacktestError>>()?;
|
||||
Ok((quotes, unavailable))
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -240,6 +281,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
portfolio
|
||||
.set_stock_pool_execution_state(&contract.pool_id, state)
|
||||
.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() {
|
||||
report
|
||||
.diagnostics
|
||||
@@ -291,9 +334,22 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
.keys()
|
||||
.cloned()
|
||||
.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] {
|
||||
let quotes =
|
||||
self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor)?;
|
||||
let mut fallback_references = BTreeMap::new();
|
||||
for symbol in "e_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, unavailable) = self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor,
|
||||
crate::stock_pool_quote_facts::requires_session_totals(&contract.rule))?;
|
||||
let positions = pool_positions(portfolio, date)?;
|
||||
let execution_state = portfolio
|
||||
.stock_pool_execution_state(&contract.pool_id)
|
||||
@@ -307,6 +363,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
.map_err(BacktestError::Execution)?;
|
||||
constraints.pending_entry_symbols = execution_state.pending_symbols();
|
||||
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);
|
||||
let account = pool::AccountSnapshot {
|
||||
total_equity: contract.frozen_equity,
|
||||
@@ -340,6 +397,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
.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
|
||||
.risk_config
|
||||
.static_rules
|
||||
@@ -400,10 +468,20 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
Decimal::ZERO,
|
||||
Some(&fee),
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let updated = execution_state
|
||||
.map_err(|error| BacktestError::Execution(if !unavailable.is_empty()
|
||||
&& (error.contains("requires volume") || error.contains("requires amount")) {
|
||||
format!("{error}; {}", unavailable.join("; "))
|
||||
} else { error }))?;
|
||||
report.diagnostics.extend(unavailable.into_iter().map(|reason| format!("stock_pool_quote_fact_unavailable {reason}")));
|
||||
let mut updated = execution_state
|
||||
.record_plan(contract.signal_date, &contract.generation, &plan)
|
||||
.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
|
||||
.set_stock_pool_execution_state(&contract.pool_id, updated)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
@@ -425,6 +503,26 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
if row.side != Some(side) {
|
||||
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
|
||||
&& portfolio
|
||||
.position(&row.symbol)
|
||||
@@ -440,8 +538,14 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
let target = row.target_quantity.to_i32().ok_or_else(|| {
|
||||
BacktestError::Execution("stock_pool_target_quantity_out_of_range".into())
|
||||
})?;
|
||||
let reason = row.source_intent.as_deref().unwrap_or("stock_pool_target");
|
||||
if let Some(price) = row.limit_price {
|
||||
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));
|
||||
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(
|
||||
date,
|
||||
portfolio,
|
||||
@@ -457,7 +561,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
report,
|
||||
)?;
|
||||
)
|
||||
} else {
|
||||
self.process_target_shares(
|
||||
date,
|
||||
@@ -471,10 +575,124 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
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(())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
use ahash::AHashMap;
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use compact_str::CompactString;
|
||||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
@@ -122,7 +123,7 @@ pub enum PriceField {
|
||||
pub struct DailyMarketSnapshot {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub symbol: CompactString,
|
||||
pub timestamp: Option<String>,
|
||||
pub day_open: f64,
|
||||
pub open: f64,
|
||||
@@ -199,13 +200,13 @@ impl DailyMarketSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
pub type NumericFactorMap = BTreeMap<Cow<'static, str>, f64>;
|
||||
pub use crate::numeric_factors::NumericFactorMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DailyFactorSnapshot {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub symbol: CompactString,
|
||||
pub market_cap_bn: f64,
|
||||
pub free_float_cap_bn: f64,
|
||||
pub pe_ttm: f64,
|
||||
@@ -232,7 +233,7 @@ pub struct BenchmarkSnapshot {
|
||||
pub struct CandidateEligibility {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub symbol: CompactString,
|
||||
pub is_st: bool,
|
||||
#[serde(default)]
|
||||
pub is_star_st: bool,
|
||||
@@ -283,6 +284,8 @@ pub struct CorporateAction {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IntradayExecutionQuote {
|
||||
#[serde(default)]
|
||||
pub observation_kind: QuoteObservationKind,
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
@@ -300,6 +303,14 @@ pub struct IntradayExecutionQuote {
|
||||
pub trading_phase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum QuoteObservationKind {
|
||||
#[default]
|
||||
Unspecified,
|
||||
MinuteBar,
|
||||
}
|
||||
|
||||
/// Sparse same-day fields layered onto an already-built immutable daily panel.
|
||||
///
|
||||
/// These fields do not participate in daily price series, adjustment series,
|
||||
@@ -1204,7 +1215,7 @@ impl SymbolPriceSeries {
|
||||
fn snapshot_at(&self, index: usize) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: self.dates[index],
|
||||
symbol: self.symbol.clone(),
|
||||
symbol: self.symbol.as_str().into(),
|
||||
timestamp: self.timestamps[index].clone(),
|
||||
day_open: self.day_opens[index],
|
||||
open: self.opens[index],
|
||||
@@ -1406,6 +1417,7 @@ pub struct DataSet {
|
||||
corporate_actions_by_date: Arc<BTreeMap<NaiveDate, Vec<CorporateAction>>>,
|
||||
execution_quotes_by_date: Arc<HashMap<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>>,
|
||||
execution_quote_dates: Arc<Vec<NaiveDate>>,
|
||||
condition_totals: Arc<std::sync::Mutex<crate::stock_pool_quote_facts::SessionTotalsCache>>,
|
||||
order_book_depth_index: Arc<HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>>,
|
||||
benchmark_by_date: Arc<BTreeMap<NaiveDate, BenchmarkSnapshot>>,
|
||||
market_series_by_symbol: Arc<AHashMap<String, Arc<SymbolPriceSeries>>>,
|
||||
@@ -1940,6 +1952,7 @@ impl DataSet {
|
||||
candidate_row_positions_by_date: Arc::new(candidate_row_positions_by_date),
|
||||
corporate_actions_by_date: Arc::new(corporate_actions_by_date),
|
||||
execution_quotes_by_date: Arc::new(execution_quotes_by_date),
|
||||
condition_totals: Arc::new(std::sync::Mutex::new(Default::default())),
|
||||
execution_quote_dates: Arc::new(execution_quote_dates),
|
||||
order_book_depth_index: Arc::new(order_book_depth_index),
|
||||
benchmark_by_date: Arc::new(benchmark_by_date),
|
||||
@@ -2278,6 +2291,15 @@ impl DataSet {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn execution_session_totals(&self, symbol: &str, at: NaiveDateTime) -> Result<(rust_decimal::Decimal, rust_decimal::Decimal), String> {
|
||||
let mut cache = self.condition_totals.lock().map_err(|_| "stock_pool_session_prefix_cache_poisoned")?;
|
||||
if cache.date != Some(at.date()) {
|
||||
cache.date = Some(at.date());
|
||||
cache.symbols.clear();
|
||||
}
|
||||
cache.symbols.entry(symbol.into()).or_insert_with(|| crate::stock_pool_quote_facts::MinutePrefix::build(at.date(), symbol, self.execution_quotes_on(at.date(), symbol))).at(at)
|
||||
}
|
||||
|
||||
pub fn has_execution_quotes_on_date(&self, date: NaiveDate) -> bool {
|
||||
self.execution_quotes_by_date
|
||||
.get(&date)
|
||||
@@ -2450,6 +2472,7 @@ impl DataSet {
|
||||
/// Replaces the run-local execution quote layer without touching the
|
||||
/// immutable daily panel.
|
||||
pub fn replace_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let execution_quotes_by_date = build_execution_quote_index(quotes);
|
||||
let quote_count = execution_quotes_by_date
|
||||
.values()
|
||||
@@ -2465,6 +2488,7 @@ impl DataSet {
|
||||
}
|
||||
|
||||
pub fn add_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let mut grouped = HashMap::<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>::new();
|
||||
for quote in quotes {
|
||||
grouped
|
||||
@@ -2565,6 +2589,7 @@ impl DataSet {
|
||||
}
|
||||
|
||||
pub fn remove_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let removed = Arc::make_mut(&mut self.execution_quotes_by_date).remove(&date);
|
||||
let Some(rows_by_symbol) = removed else {
|
||||
return 0;
|
||||
@@ -2577,6 +2602,7 @@ impl DataSet {
|
||||
}
|
||||
|
||||
pub fn release_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let row_count = self
|
||||
.execution_quotes_by_date
|
||||
.get(&date)
|
||||
@@ -2865,7 +2891,7 @@ impl DataSet {
|
||||
.filter_map(|snapshot| {
|
||||
factor_numeric_value(snapshot, &field).map(|value| FactorValue {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol.clone(),
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: field.clone(),
|
||||
value,
|
||||
})
|
||||
@@ -2959,7 +2985,7 @@ impl DataSet {
|
||||
.or_else(|| row.extra_factors.get("margin_all"))
|
||||
.is_some_and(|value| *value > 0.0)
|
||||
})
|
||||
.map(|row| row.symbol.clone())
|
||||
.map(|row| row.symbol.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -3051,7 +3077,7 @@ impl DataSet {
|
||||
if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 {
|
||||
Some(FactorValue {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol.clone(),
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: "price_change_rate".to_string(),
|
||||
value: snapshot.close / snapshot.prev_close - 1.0,
|
||||
})
|
||||
@@ -3173,7 +3199,7 @@ impl DataSet {
|
||||
if let Some(value) = factor_numeric_value(snapshot, field) {
|
||||
return Some(FactorValue {
|
||||
date: *factor_date,
|
||||
symbol: snapshot.symbol.clone(),
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: field.clone(),
|
||||
value,
|
||||
});
|
||||
@@ -3524,7 +3550,7 @@ impl DataSet {
|
||||
if let Some(value) = factor_numeric_value(snapshot, field) {
|
||||
rows.push(FactorValue {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol.clone(),
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: output_field.clone(),
|
||||
value,
|
||||
});
|
||||
@@ -4322,7 +4348,7 @@ fn daily_market_price_bar(snapshot: &DailyMarketSnapshot) -> PriceBar {
|
||||
PriceBar {
|
||||
date: snapshot.date,
|
||||
timestamp: snapshot.timestamp.clone(),
|
||||
symbol: snapshot.symbol.clone(),
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
frequency: "1d".to_string(),
|
||||
open: snapshot.open,
|
||||
high: snapshot.high,
|
||||
@@ -4383,7 +4409,7 @@ fn normalize_factor_snapshots(
|
||||
{
|
||||
return Err(DataSetError::ReservedTypedFactorInExtraMap {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol,
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: BACKWARD_ADJUSTMENT_FACTOR_FIELD,
|
||||
});
|
||||
}
|
||||
@@ -4392,7 +4418,7 @@ fn normalize_factor_snapshots(
|
||||
{
|
||||
return Err(DataSetError::InvalidBackwardAdjustmentFactor {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol,
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
value,
|
||||
});
|
||||
}
|
||||
@@ -4428,7 +4454,7 @@ fn normalize_factor_snapshots(
|
||||
{
|
||||
return Err(DataSetError::ReservedTypedFactorInExtraMap {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol,
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: BACKWARD_ADJUSTMENT_FACTOR_FIELD,
|
||||
});
|
||||
}
|
||||
@@ -4510,21 +4536,21 @@ fn build_symbol_id_index(
|
||||
for rows in market_by_date.values() {
|
||||
for row in rows {
|
||||
if !symbols.contains(row.symbol.as_str()) {
|
||||
symbols.insert(row.symbol.clone());
|
||||
symbols.insert(row.symbol.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for rows in factor_by_date.values() {
|
||||
for row in rows {
|
||||
if !symbols.contains(row.symbol.as_str()) {
|
||||
symbols.insert(row.symbol.clone());
|
||||
symbols.insert(row.symbol.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for rows in candidate_by_date.values() {
|
||||
for row in rows {
|
||||
if !symbols.contains(row.symbol.as_str()) {
|
||||
symbols.insert(row.symbol.clone());
|
||||
symbols.insert(row.symbol.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4942,7 +4968,7 @@ fn build_fundamental_universe_for_date(
|
||||
continue;
|
||||
}
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
symbol: factor.symbol.clone(),
|
||||
symbol: factor.symbol.to_string(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn: decision_free_float_cap_bn(factor),
|
||||
});
|
||||
@@ -5012,7 +5038,7 @@ fn build_eligible_universe_for_date_from_factors(
|
||||
date,
|
||||
candidate,
|
||||
market,
|
||||
instruments.get(&factor.symbol),
|
||||
instruments.get(factor.symbol.as_str()),
|
||||
risk_config,
|
||||
)
|
||||
.is_some()
|
||||
@@ -5025,7 +5051,7 @@ fn build_eligible_universe_for_date_from_factors(
|
||||
}
|
||||
let free_float_cap_bn = decision_free_float_cap_bn(factor);
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
symbol: factor.symbol.clone(),
|
||||
symbol: factor.symbol.to_string(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
});
|
||||
@@ -5042,7 +5068,7 @@ fn build_eligible_universe_for_date_from_factors(
|
||||
pub(crate) fn missing_candidate_risk_state(date: NaiveDate, symbol: &str) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -5070,7 +5096,7 @@ mod tests {
|
||||
fn market_row(date: &str, prev_close: f64, volume: u64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: None,
|
||||
day_open: prev_close,
|
||||
open: prev_close,
|
||||
@@ -5157,7 +5183,7 @@ mod tests {
|
||||
&run_data.execution_quote_dates
|
||||
));
|
||||
|
||||
run_data.add_execution_quotes(vec![IntradayExecutionQuote {
|
||||
run_data.add_execution_quotes(vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
@@ -5300,7 +5326,7 @@ mod tests {
|
||||
vec![benchmark_row("2025-01-02", 12.0)],
|
||||
)
|
||||
.unwrap();
|
||||
let quote = IntradayExecutionQuote {
|
||||
let quote = IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp,
|
||||
@@ -5352,10 +5378,10 @@ mod tests {
|
||||
for (symbol_index, symbol) in symbols.into_iter().enumerate().rev() {
|
||||
let close = 10.0 + date_index as f64 + symbol_index as f64;
|
||||
let mut market_row = market_row(&date_text, close, 1_000_000);
|
||||
market_row.symbol = symbol.to_string();
|
||||
market_row.symbol = symbol.into();
|
||||
let factor_row = DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 100.0 + close,
|
||||
free_float_cap_bn: 80.0 + close,
|
||||
pe_ttm: 0.0,
|
||||
@@ -5366,7 +5392,7 @@ mod tests {
|
||||
};
|
||||
let candidate_row = CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -5402,7 +5428,7 @@ mod tests {
|
||||
successor_cash: None,
|
||||
};
|
||||
corporate_actions.push(corporate_action.clone());
|
||||
execution_quotes.push(IntradayExecutionQuote {
|
||||
execution_quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbols[0].to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -5534,12 +5560,12 @@ mod tests {
|
||||
};
|
||||
let market = |symbol: &str, close: f64| {
|
||||
let mut row = market_row("2025-01-02", close, 1_000_000);
|
||||
row.symbol = symbol.to_string();
|
||||
row.symbol = symbol.into();
|
||||
row
|
||||
};
|
||||
let factor = |symbol: &str, market_cap_bn: f64| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn: market_cap_bn,
|
||||
pe_ttm: 0.0,
|
||||
@@ -5550,7 +5576,7 @@ mod tests {
|
||||
};
|
||||
let candidate = |symbol: &str| CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -5773,7 +5799,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|date| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -5977,7 +6003,7 @@ mod tests {
|
||||
.enumerate()
|
||||
.map(|(index, symbol)| {
|
||||
let mut row = market_row("2025-01-02", 10.0 + index as f64 / 1000.0, 1_000_000);
|
||||
row.symbol = symbol.clone();
|
||||
row.symbol = symbol.clone().into();
|
||||
row
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -5986,7 +6012,7 @@ mod tests {
|
||||
.enumerate()
|
||||
.map(|(index, symbol)| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
symbol: symbol.clone().into(),
|
||||
market_cap_bn: 10.0 + index as f64 / 1000.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -6000,7 +6026,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|symbol| CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
symbol: symbol.clone().into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -6113,7 +6139,7 @@ mod tests {
|
||||
vec![benchmark_row("2025-01-02", 12.0)],
|
||||
)
|
||||
.unwrap();
|
||||
let quote = |symbol: &str, time: &str| IntradayExecutionQuote {
|
||||
let quote = |symbol: &str, time: &str| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
timestamp: NaiveDateTime::parse_from_str(
|
||||
&format!("2025-01-02 {time}"),
|
||||
@@ -6192,7 +6218,7 @@ mod tests {
|
||||
#[test]
|
||||
fn shared_execution_quote_release_does_not_clone_the_base_map() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
let quote = IntradayExecutionQuote {
|
||||
let quote = IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
@@ -6289,14 +6315,14 @@ mod tests {
|
||||
fn factor_numeric_value_normalizes_fields_without_changing_aliases() {
|
||||
let snapshot = DailyFactorSnapshot {
|
||||
date: NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 12.5,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.25),
|
||||
extra_factors: BTreeMap::from([("custom_factor".into(), 3.5)]),
|
||||
extra_factors: From::from([("custom_factor".into(), 3.5)]),
|
||||
};
|
||||
|
||||
assert_eq!(factor_numeric_value(&snapshot, " MARKET_CAP "), Some(12.5));
|
||||
@@ -6312,14 +6338,14 @@ mod tests {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
let clean = normalize_factor_snapshots(vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 1.0,
|
||||
free_float_cap_bn: 1.0,
|
||||
pe_ttm: 1.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::from([(Cow::Borrowed("amount"), 10.0)]),
|
||||
extra_factors: From::from([(Cow::Borrowed("amount"), 10.0)]),
|
||||
}])
|
||||
.expect("normalize clean factor snapshot");
|
||||
assert!(matches!(
|
||||
@@ -6329,14 +6355,14 @@ mod tests {
|
||||
|
||||
let dirty = normalize_factor_snapshots(vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 1.0,
|
||||
free_float_cap_bn: 1.0,
|
||||
pe_ttm: 1.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::from([
|
||||
extra_factors: From::from([
|
||||
(Cow::Owned(" CUSTOM_FACTOR ".to_string()), 2.0),
|
||||
(Cow::Borrowed("bad_nan"), f64::NAN),
|
||||
]),
|
||||
@@ -6351,7 +6377,7 @@ mod tests {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
let snapshot = |adjustment_factor_backward1, extra_factors| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 1.0,
|
||||
free_float_cap_bn: 1.0,
|
||||
pe_ttm: 1.0,
|
||||
@@ -6364,12 +6390,12 @@ mod tests {
|
||||
assert!(matches!(
|
||||
normalize_factor_snapshots(vec![snapshot(
|
||||
Some(1.0),
|
||||
BTreeMap::from([(Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD), 1.0)]),
|
||||
BTreeMap::from([(Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD), 1.0)]).into(),
|
||||
)]),
|
||||
Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
normalize_factor_snapshots(vec![snapshot(Some(0.0), BTreeMap::new())]),
|
||||
normalize_factor_snapshots(vec![snapshot(Some(0.0), BTreeMap::new().into())]),
|
||||
Err(DataSetError::InvalidBackwardAdjustmentFactor { .. })
|
||||
));
|
||||
for field in [
|
||||
@@ -6382,7 +6408,7 @@ mod tests {
|
||||
matches!(
|
||||
normalize_factor_snapshots(vec![snapshot(
|
||||
typed_value,
|
||||
BTreeMap::from([(Cow::Borrowed(field), 2.0)]),
|
||||
BTreeMap::from([(Cow::Borrowed(field), 2.0)]).into(),
|
||||
)]),
|
||||
Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
|
||||
),
|
||||
@@ -6478,14 +6504,14 @@ mod tests {
|
||||
}
|
||||
DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors,
|
||||
extra_factors: extra_factors.into(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
@@ -6537,14 +6563,14 @@ mod tests {
|
||||
.iter()
|
||||
.map(|date| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
@@ -6831,14 +6857,14 @@ mod tests {
|
||||
.zip(factors)
|
||||
.map(|(date, factor)| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(factor),
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
@@ -6929,14 +6955,14 @@ mod tests {
|
||||
.iter()
|
||||
.map(|date| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: (*date != dates[3]).then_some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
@@ -7008,7 +7034,7 @@ mod tests {
|
||||
};
|
||||
let market = |symbol: &str, prev_close: f64, close: f64| DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: prev_close,
|
||||
open: prev_close,
|
||||
@@ -7032,18 +7058,18 @@ mod tests {
|
||||
let factor =
|
||||
|symbol: &str, market_cap_bn: f64, free_float_cap_bn: f64| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
};
|
||||
let candidate = |symbol: &str| CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -7108,7 +7134,7 @@ mod tests {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -7131,14 +7157,14 @@ mod tests {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 9.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![BenchmarkSnapshot {
|
||||
@@ -7193,7 +7219,7 @@ mod tests {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -7216,18 +7242,18 @@ mod tests {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 9.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -7268,14 +7294,14 @@ mod tests {
|
||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
let factor = DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 12.0,
|
||||
free_float_cap_bn: 4.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
};
|
||||
|
||||
assert!((decision_market_cap_bn(&factor) - 12.0).abs() < 1e-9);
|
||||
|
||||
+107
-28
@@ -122,6 +122,7 @@ impl DailyEquityPoint {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BacktestResult {
|
||||
pub capacity_audit: crate::execution_capacity::CapacityAuditSummary,
|
||||
pub strategy_name: String,
|
||||
pub equity_curve: Vec<DailyEquityPoint>,
|
||||
pub benchmark_series: Vec<BenchmarkSnapshot>,
|
||||
@@ -168,6 +169,8 @@ pub enum BacktestTerminalAssetClass {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BacktestTerminalAudit {
|
||||
#[serde(default, skip_serializing_if = "is_zero_count")]
|
||||
pub deferred_etf_target_count: usize,
|
||||
pub status: BacktestTerminalStatus,
|
||||
pub last_execution_date: Option<NaiveDate>,
|
||||
pub stock_open_order_count: usize,
|
||||
@@ -184,6 +187,7 @@ pub struct BacktestTerminalAudit {
|
||||
impl Default for BacktestTerminalAudit {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
deferred_etf_target_count: 0,
|
||||
status: BacktestTerminalStatus::Clean,
|
||||
last_execution_date: None,
|
||||
stock_open_order_count: 0,
|
||||
@@ -199,6 +203,8 @@ impl Default for BacktestTerminalAudit {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero_count(value: &usize) -> bool { *value == 0 }
|
||||
|
||||
impl BacktestTerminalAudit {
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.status == BacktestTerminalStatus::Clean
|
||||
@@ -280,6 +286,7 @@ pub struct AnalyzerRiskSummary {
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AnalyzerReport {
|
||||
pub capacity_audit: crate::execution_capacity::CapacityAuditSummary,
|
||||
pub strategy_name: String,
|
||||
pub trades: Vec<AnalyzerTradeRow>,
|
||||
pub positions: Vec<AnalyzerPositionRow>,
|
||||
@@ -294,6 +301,7 @@ pub struct AnalyzerReport {
|
||||
impl BacktestResult {
|
||||
pub fn analyzer_report(&self) -> AnalyzerReport {
|
||||
AnalyzerReport {
|
||||
capacity_audit: self.capacity_audit.clone(),
|
||||
strategy_name: self.strategy_name.clone(),
|
||||
trades: self
|
||||
.fills
|
||||
@@ -738,6 +746,15 @@ where
|
||||
if self.execution_quote_loader.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let cumulative_conditions = decision.order_intents.iter().any(|intent| {
|
||||
matches!(intent.unwrapped(), OrderIntent::StockPool { contract }
|
||||
if crate::stock_pool_quote_facts::requires_session_totals(&contract.rule))
|
||||
});
|
||||
if cumulative_conditions && (self.broker.execution_price_field() != PriceField::Close
|
||||
|| start_time.is_some() || self.broker.intraday_execution_start_time().is_some()) {
|
||||
let mut symbols = execution_quote_symbols_for_decision(decision, portfolio, open_orders);
|
||||
self.load_missing_execution_quotes(execution_date, None, None, &mut symbols)?;
|
||||
}
|
||||
let submission_time = start_time.or_else(|| self.broker.intraday_execution_start_time());
|
||||
let post_close_window = self.broker.post_close_execution_quote_window_for_order(
|
||||
execution_date,
|
||||
@@ -784,6 +801,17 @@ where
|
||||
end_time: Option<NaiveTime>,
|
||||
symbols: &mut BTreeSet<String>,
|
||||
) -> 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();
|
||||
for symbol in symbols.iter() {
|
||||
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
|
||||
@@ -810,6 +838,9 @@ where
|
||||
}
|
||||
*symbols = available;
|
||||
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);
|
||||
if self.execution_quote_request_cache.contains(&request_key) {
|
||||
return false;
|
||||
@@ -854,6 +885,7 @@ where
|
||||
}
|
||||
self.data.add_execution_quotes(quotes);
|
||||
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)?;
|
||||
}
|
||||
for symbol in requested_symbols {
|
||||
@@ -890,7 +922,7 @@ where
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1392,6 +1424,7 @@ where
|
||||
let status = if open_order_count == 0
|
||||
&& pending_cash_flow_count == 0
|
||||
&& cash_receivable_count == 0
|
||||
&& self.broker.pending_etf_target_count() == 0
|
||||
{
|
||||
BacktestTerminalStatus::Clean
|
||||
} else {
|
||||
@@ -1399,6 +1432,7 @@ where
|
||||
};
|
||||
|
||||
BacktestTerminalAudit {
|
||||
deferred_etf_target_count: self.broker.pending_etf_target_count(),
|
||||
status,
|
||||
last_execution_date,
|
||||
stock_open_order_count,
|
||||
@@ -2102,6 +2136,7 @@ where
|
||||
.map(|(execution_date, _)| *execution_date)
|
||||
.collect::<Vec<_>>();
|
||||
let mut result = BacktestResult {
|
||||
capacity_audit: self.broker.capacity_audit_summary(),
|
||||
strategy_name: self.strategy.name().to_string(),
|
||||
benchmark_series: self
|
||||
.data
|
||||
@@ -2200,7 +2235,7 @@ where
|
||||
.and_then(|(_, decision_slot)| *decision_slot);
|
||||
let Some((decision_index, decision_date)) = decision_slot else {
|
||||
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(
|
||||
execution_date,
|
||||
&self.data,
|
||||
@@ -2562,6 +2597,9 @@ where
|
||||
"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(
|
||||
&mut self.strategy,
|
||||
&mut self.process_event_bus,
|
||||
@@ -2850,11 +2888,11 @@ where
|
||||
)?;
|
||||
|
||||
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions)
|
||||
|| (self.broker.has_open_orders() && self.broker.matching_type_uses_intraday_quotes())
|
||||
|| (self.broker.has_open_orders() && self.broker.drives_resting_quote_clock())
|
||||
{
|
||||
let unfiltered_minute_stream = self.subscriptions.is_empty();
|
||||
let mut full_minute_symbols = self.subscriptions.clone();
|
||||
if self.broker.matching_type_uses_intraday_quotes() {
|
||||
if self.broker.drives_resting_quote_clock() {
|
||||
full_minute_symbols.extend(self.broker.open_order_views().into_iter().map(|order| order.symbol));
|
||||
}
|
||||
if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() {
|
||||
@@ -2900,18 +2938,26 @@ where
|
||||
.into_iter()
|
||||
.peekable();
|
||||
let mut minute_group = Vec::new();
|
||||
let mut last_minute_timestamp = None;
|
||||
// Merge the immutable quote stream with clock events. Equal
|
||||
// timestamps form one event; scheduled callbacks run before
|
||||
// `on_minute` below.
|
||||
loop {
|
||||
let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp);
|
||||
let next_schedule_timestamp = minute_schedule_timestamps.peek().copied();
|
||||
let next_expiry_timestamp = self.broker.next_day_order_expiry(execution_date)
|
||||
.map(|time| execution_date.and_time(time))
|
||||
.filter(|time| last_minute_timestamp.is_none_or(|last| last < *time));
|
||||
let Some(minute_timestamp) =
|
||||
next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp)
|
||||
next_minute_event_timestamp(
|
||||
next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp),
|
||||
next_expiry_timestamp,
|
||||
)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let minute_time = minute_timestamp.time();
|
||||
last_minute_timestamp = Some(minute_timestamp);
|
||||
minute_group.clear();
|
||||
while minute_quotes
|
||||
.peek()
|
||||
@@ -3117,7 +3163,7 @@ where
|
||||
.map(|order| order.symbol)
|
||||
.filter(|symbol| !full_minute_symbols.contains(symbol))
|
||||
.collect::<BTreeSet<_>>();
|
||||
if !newly_pending.is_empty() && self.broker.matching_type_uses_intraday_quotes() {
|
||||
if !newly_pending.is_empty() && self.broker.drives_resting_quote_clock() {
|
||||
full_minute_symbols.extend(newly_pending.iter().cloned());
|
||||
if self.execution_quote_loader.is_some() {
|
||||
self.load_missing_execution_quotes(execution_date, None, None, &mut newly_pending)?;
|
||||
@@ -3415,6 +3461,16 @@ where
|
||||
execution_date,
|
||||
);
|
||||
let daily_fill_count = result.fills.len() - day_fill_start;
|
||||
for audit in self.broker.audit_completed_session_capacity(execution_date, &self.data)? {
|
||||
result.capacity_audit.observe(&audit);
|
||||
// Keep every audit in the durable event store, independent of
|
||||
// debug phase retention. It never changes earlier executions.
|
||||
result.process_events.push(ProcessEvent {
|
||||
date: execution_date, kind: ProcessEventKind::SessionCapacityAudit,
|
||||
order_id: None, symbol: Some(audit.symbol.clone()), side: None,
|
||||
detail: serde_json::to_string(&audit).map_err(|error| BacktestError::Execution(error.to_string()))?,
|
||||
});
|
||||
}
|
||||
let daily_order_count = result.order_events.len() - day_order_start;
|
||||
let execution_risk_decisions =
|
||||
risk_decisions_from_order_events(&result.order_events[day_order_start..]);
|
||||
@@ -3619,6 +3675,8 @@ where
|
||||
|
||||
let split_ratio = action.split_ratio();
|
||||
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 position = portfolio
|
||||
.position_mut_if_exists(&action.symbol)
|
||||
@@ -5376,7 +5434,7 @@ mod tests {
|
||||
fn market(date: NaiveDate, open: f64, close: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: Some(format!("{date} 15:00:00")),
|
||||
day_open: open,
|
||||
open,
|
||||
@@ -5430,21 +5488,21 @@ mod tests {
|
||||
fn factor(date: NaiveDate) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
symbol: SYMBOL.into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 12.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(date: NaiveDate) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
symbol: SYMBOL.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -5747,7 +5805,7 @@ mod tests {
|
||||
fn physical_on_day_rules_keep_each_actual_submission_time() {
|
||||
let date = d(2026, 7, 6);
|
||||
let quotes = vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("morning timestamp"),
|
||||
@@ -5760,7 +5818,7 @@ mod tests {
|
||||
amount_delta: 110_000.0,
|
||||
trading_phase: Some("continuous_auction".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 19, 0).expect("future timestamp"),
|
||||
@@ -5773,7 +5831,7 @@ mod tests {
|
||||
amount_delta: 990_000.0,
|
||||
trading_phase: Some("continuous_auction".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
timestamp: date.and_hms_opt(15, 10, 0).expect("post-close timestamp"),
|
||||
@@ -5896,41 +5954,57 @@ mod tests {
|
||||
}], ..StrategyDecision::default() })
|
||||
}
|
||||
}
|
||||
for partial in [false, true] {
|
||||
let date = d(2026, 6, 1);
|
||||
let quote = |hour, minute, price| IntradayExecutionQuote {
|
||||
for scenario in 0..5 {
|
||||
let partial = scenario == 1;
|
||||
let closing_only = matches!(scenario,2|3);
|
||||
let delayed = scenario == 4;
|
||||
let date = if closing_only { d(2026, 7, 6) } else if delayed { d(2026, 6, 2) } else { d(2026, 6, 1) };
|
||||
let quote = |hour, minute, price| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date, symbol: SYMBOL.into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
|
||||
last_price: price, bid1: price, ask1: price, bid1_volume: 10_000, ask1_volume: 10_000,
|
||||
volume_delta: 10_000, amount_delta: price * 10_000.0, trading_phase: None,
|
||||
};
|
||||
let first = quote(9, 30, if partial { 9.8 } else { 10.2 });
|
||||
let earlier = quote(9, 29, 9.0);
|
||||
let later = quote(10, 0, 9.8);
|
||||
let last = quote(10, 1, 9.8);
|
||||
let mut data = dataset_from_market_and_candidates(vec![market(date, 10.2, 9.8)], vec![candidate(date)]);
|
||||
let unchanged = quote(9, 45, 10.2);
|
||||
let later = quote(10, 0, if closing_only { 10.2 } else { 9.8 });
|
||||
let last = if closing_only { quote(15, 0, if scenario == 2 { 9.8 } else { 10.2 }) } else { quote(10, 1, 9.8) };
|
||||
let mut post_close = quote(15, 5, 9.7);
|
||||
post_close.trading_phase = Some("post_close_fixed_price".into());
|
||||
let prior = date.pred_opt().unwrap();
|
||||
let markets = if delayed {vec![market(prior,10.2,10.2),market(date,10.2,9.8)]} else {vec![market(date,10.2,9.8)]};
|
||||
let candidates = if delayed {vec![candidate(prior),candidate(date)]} else {vec![candidate(date)]};
|
||||
let mut data = dataset_from_market_and_candidates(markets,candidates);
|
||||
data.add_execution_quotes(vec![first.clone()]);
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose)
|
||||
.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
|
||||
.with_matching_type(if delayed {MatchingType::NextBarOpen} else {MatchingType::CurrentBarClose})
|
||||
.with_volume_limit(partial).with_volume_percent(0.01).with_liquidity_limit(false).with_inactive_limit(false);
|
||||
let broker = if delayed {broker} else {broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9,30,0).unwrap())};
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = Arc::clone(&requests);
|
||||
let mut engine = BacktestEngine::new(data, RestingLimit { quantity: if partial { 300 } else { 100 } }, broker, BacktestConfig {
|
||||
initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(date), end_date: Some(date),
|
||||
decision_lag_trading_days: 0, execution_price_field: PriceField::Close,
|
||||
initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(if delayed {prior} else {date}), end_date: Some(date),
|
||||
decision_lag_trading_days: usize::from(delayed), execution_price_field: if delayed {PriceField::Open} else {PriceField::Close},
|
||||
}).with_execution_quote_loader(move |request| {
|
||||
captured.lock().unwrap().push((request.start_time, request.end_time));
|
||||
Ok(vec![earlier.clone(), first.clone(), later.clone(), last.clone()])
|
||||
Ok(vec![earlier.clone(), first.clone(), unchanged.clone(), later.clone(), last.clone(), post_close.clone()])
|
||||
});
|
||||
let result = engine.run().unwrap();
|
||||
if scenario == 3 {
|
||||
assert!(result.fills.is_empty(), "continuous DAY order must not migrate to post-close execution");
|
||||
assert_eq!(result.order_events.len(), 2, "only initial pending and expiry are state changes");
|
||||
assert_eq!(result.order_events.last().unwrap().status, crate::OrderStatus::Expired);
|
||||
continue;
|
||||
}
|
||||
assert_eq!(result.fills.len(), if partial { 3 } else { 1 }, "resting DAY order must match later actual quotes: {:?}", result.order_events);
|
||||
assert_eq!(result.fills[0].execution_timestamp, if partial { date.and_hms_opt(9, 30, 0) } else { date.and_hms_opt(10, 0, 0) });
|
||||
assert_eq!(result.fills[0].execution_timestamp, if partial { date.and_hms_opt(9, 30, 0) } else if closing_only { date.and_hms_opt(15, 0, 0) } else { date.and_hms_opt(10, 0, 0) });
|
||||
assert_eq!(result.fills[0].price, 9.8);
|
||||
assert_eq!(result.fills[0].quantity, 100);
|
||||
assert_eq!(result.fills.iter().map(|fill| fill.quantity).sum::<u32>(), if partial { 300 } else { 100 });
|
||||
assert!(result.fills.iter().all(|fill| fill.execution_timestamp >= date.and_hms_opt(9, 30, 0)));
|
||||
assert_eq!(requests.lock().unwrap().as_slice(), &[(None, None)]);
|
||||
assert!(!result.order_events.iter().any(|order| order.status == crate::OrderStatus::Expired));
|
||||
assert_eq!(result.order_events.len(), if partial { 3 } else { 2 }, "unchanged pending attempts must not emit state transitions");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6008,7 +6082,7 @@ mod tests {
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_hms_opt(15, 5, 0).expect("valid timestamp"),
|
||||
@@ -7014,6 +7088,7 @@ mod tests {
|
||||
let third = d(2025, 1, 6);
|
||||
let fourth = d(2025, 1, 7);
|
||||
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
|
||||
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_volume_limit(true)
|
||||
.with_volume_percent(0.25);
|
||||
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
||||
@@ -7041,12 +7116,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_sell_volume_limit_rejects_execution_day_zero_volume() {
|
||||
fn next_bar_open_session_audit_flags_zero_volume_without_rewriting_fills() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let third = d(2025, 1, 6);
|
||||
let fourth = d(2025, 1, 7);
|
||||
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
|
||||
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_volume_limit(true)
|
||||
.with_volume_percent(0.25);
|
||||
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
||||
@@ -7067,7 +7143,10 @@ mod tests {
|
||||
broker,
|
||||
);
|
||||
|
||||
assert_round_trip_sell_canceled_with_reason(&result, "daily volume limit");
|
||||
assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Sell && fill.date == fourth));
|
||||
assert_eq!(result.capacity_audit.audit_passed, Some(false));
|
||||
assert_eq!(result.capacity_audit.failed_symbol_sessions, 1);
|
||||
assert!(result.process_events.iter().any(|event| event.kind == crate::ProcessEventKind::SessionCapacityAudit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,8 @@ pub enum ProcessEventKind {
|
||||
AccountDepositWithdraw,
|
||||
AccountFinanceRepay,
|
||||
AccountManagementFee,
|
||||
SessionCapacityAudit,
|
||||
EtfExecutionFallback,
|
||||
}
|
||||
|
||||
impl ProcessEventKind {
|
||||
@@ -362,6 +364,8 @@ impl ProcessEventKind {
|
||||
Self::AccountDepositWithdraw => "account_deposit_withdraw",
|
||||
Self::AccountFinanceRepay => "account_finance_repay",
|
||||
Self::AccountManagementFee => "account_management_fee",
|
||||
Self::SessionCapacityAudit => "session_capacity_audit",
|
||||
Self::EtfExecutionFallback => "etf_execution_fallback",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +397,8 @@ impl ProcessEventKind {
|
||||
| Self::AccountDepositWithdraw
|
||||
| Self::AccountFinanceRepay
|
||||
| Self::AccountManagementFee
|
||||
| Self::SessionCapacityAudit
|
||||
| Self::EtfExecutionFallback
|
||||
| Self::Settlement
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,19 @@ pub enum VolumeCapacityMode {
|
||||
SessionCapacityAudit,
|
||||
}
|
||||
|
||||
impl VolumeCapacityMode {
|
||||
pub fn validate(self, enabled: bool, has_execution_observations: bool) -> Result<(), CapacityError> {
|
||||
if !enabled { return Ok(()); }
|
||||
match self {
|
||||
Self::ExecutionObservation if !has_execution_observations => Err(CapacityError::MissingObservation),
|
||||
Self::CompletedBar => Err(CapacityError::MissingCompletedBar),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limits_execution_quantity(self) -> bool { self != Self::SessionCapacityAudit }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
pub enum CapacityError {
|
||||
#[error("execution capacity ratio must be finite and in (0, 1]")]
|
||||
@@ -26,6 +39,28 @@ pub enum CapacityError {
|
||||
WrongSession,
|
||||
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
|
||||
MissingObservation,
|
||||
#[error("completed_bar capacity requires declared bar end and availability; an undated daily total is not a completed observation")]
|
||||
MissingCompletedBar,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CapacityAuditSummary {
|
||||
pub mode: VolumeCapacityMode,
|
||||
pub enabled: bool,
|
||||
pub participation_rate: f64,
|
||||
pub audited_symbol_sessions: usize,
|
||||
pub failed_symbol_sessions: usize,
|
||||
pub audit_passed: Option<bool>,
|
||||
pub execution_time_capacity_proven: bool,
|
||||
}
|
||||
|
||||
impl CapacityAuditSummary {
|
||||
pub fn observe(&mut self, audit: &SessionCapacityAudit) {
|
||||
self.audited_symbol_sessions += 1;
|
||||
self.failed_symbol_sessions += usize::from(!audit.passed);
|
||||
self.audit_passed = Some(self.failed_symbol_sessions == 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
|
||||
|
||||
@@ -2,11 +2,13 @@ pub mod broker;
|
||||
pub mod calendar;
|
||||
pub mod cost;
|
||||
pub mod data;
|
||||
mod numeric_factors;
|
||||
pub mod daily_patterns;
|
||||
pub mod pattern_context;
|
||||
pub mod session_events;
|
||||
pub mod factor_events;
|
||||
pub mod execution_capacity;
|
||||
mod etf_execution;
|
||||
mod execution_schedule;
|
||||
mod factor_event_catalog;
|
||||
pub mod factor_cross_section;
|
||||
@@ -33,7 +35,9 @@ pub mod stock_pool_candidates;
|
||||
pub mod stock_pool_indicators;
|
||||
pub mod stock_pool_execution;
|
||||
pub mod stock_pool_index_policy;
|
||||
pub mod stock_pool_market_cap;
|
||||
pub mod stock_pool_state;
|
||||
pub mod stock_pool_quote_facts;
|
||||
pub mod signal_contract;
|
||||
pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::ops::Index;
|
||||
|
||||
use serde::de::{MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// Sorted numeric fields stored contiguously, without a tree node per snapshot.
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
pub struct NumericFactorMap {
|
||||
entries: Vec<(Cow<'static, str>, f64)>,
|
||||
}
|
||||
|
||||
impl NumericFactorMap {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key))
|
||||
.ok()
|
||||
.map(|index| &self.entries[index].1)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, key: &str) -> Option<&mut f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key))
|
||||
.ok()
|
||||
.map(|index| &mut self.entries[index].1)
|
||||
}
|
||||
|
||||
pub fn contains_key(&self, key: &str) -> bool {
|
||||
self.get(key).is_some()
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, key: Cow<'static, str>, value: f64) -> Option<f64> {
|
||||
if self
|
||||
.entries
|
||||
.last()
|
||||
.is_none_or(|(last, _)| last.as_ref() < key.as_ref())
|
||||
{
|
||||
self.entries.push((key, value));
|
||||
return None;
|
||||
}
|
||||
match self
|
||||
.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key.as_ref()))
|
||||
{
|
||||
Ok(index) => Some(std::mem::replace(&mut self.entries[index].1, value)),
|
||||
Err(index) => {
|
||||
self.entries.insert(index, (key, value));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> Option<f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key))
|
||||
.ok()
|
||||
.map(|index| self.entries.remove(index).1)
|
||||
}
|
||||
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Cow<'static, str>, &mut f64) -> bool) {
|
||||
self.entries.retain_mut(|(key, value)| keep(key, value));
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> Iter<'_> {
|
||||
Iter(self.entries.iter())
|
||||
}
|
||||
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &Cow<'static, str>> + ExactSizeIterator {
|
||||
self.entries.iter().map(|(key, _)| key)
|
||||
}
|
||||
pub fn values(&self) -> impl DoubleEndedIterator<Item = &f64> + ExactSizeIterator {
|
||||
self.entries.iter().map(|(_, value)| value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for NumericFactorMap {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_map().entries(self).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<&str> for NumericFactorMap {
|
||||
type Output = f64;
|
||||
fn index(&self, key: &str) -> &Self::Output {
|
||||
self.get(key).expect("numeric factor key not found")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Iter<'a>(std::slice::Iter<'a, (Cow<'static, str>, f64)>);
|
||||
impl<'a> Iterator for Iter<'a> {
|
||||
type Item = (&'a Cow<'static, str>, &'a f64);
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.0.next().map(|(k, v)| (k, v))
|
||||
}
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
self.0.size_hint()
|
||||
}
|
||||
}
|
||||
impl DoubleEndedIterator for Iter<'_> {
|
||||
fn next_back(&mut self) -> Option<Self::Item> {
|
||||
self.0.next_back().map(|(k, v)| (k, v))
|
||||
}
|
||||
}
|
||||
impl ExactSizeIterator for Iter<'_> {}
|
||||
impl<'a> IntoIterator for &'a NumericFactorMap {
|
||||
type Item = (&'a Cow<'static, str>, &'a f64);
|
||||
type IntoIter = Iter<'a>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
impl IntoIterator for NumericFactorMap {
|
||||
type Item = (Cow<'static, str>, f64);
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.entries.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap {
|
||||
fn from_iter<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(iter: T) -> Self {
|
||||
let mut entries: Vec<_> = iter.into_iter().collect();
|
||||
// Stable sorting preserves last-value-wins for repeated input keys.
|
||||
if !entries.windows(2).all(|pair| pair[0].0 <= pair[1].0) {
|
||||
entries.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
}
|
||||
entries.dedup_by(|later, earlier| {
|
||||
if later.0 == earlier.0 {
|
||||
earlier.1 = later.1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
Self { entries }
|
||||
}
|
||||
}
|
||||
impl Extend<(Cow<'static, str>, f64)> for NumericFactorMap {
|
||||
fn extend<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(&mut self, iter: T) {
|
||||
let mut incoming: Self = iter.into_iter().collect();
|
||||
if incoming.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.is_empty() {
|
||||
*self = incoming;
|
||||
return;
|
||||
}
|
||||
if self.entries.last().unwrap().0 < incoming.entries[0].0 {
|
||||
self.entries.append(&mut incoming.entries);
|
||||
return;
|
||||
}
|
||||
// Merge sorted sets in linear time; wide factor batches must not shift
|
||||
// the existing vector once per field. Existing keys keep their identity.
|
||||
let mut merged = Vec::with_capacity(self.len() + incoming.len());
|
||||
let mut old = std::mem::take(&mut self.entries).into_iter().peekable();
|
||||
let mut new = incoming.entries.into_iter().peekable();
|
||||
while let (Some(left), Some(right)) = (old.peek(), new.peek()) {
|
||||
match left.0.cmp(&right.0) {
|
||||
std::cmp::Ordering::Less => merged.push(old.next().unwrap()),
|
||||
std::cmp::Ordering::Greater => merged.push(new.next().unwrap()),
|
||||
std::cmp::Ordering::Equal => {
|
||||
let (key, _) = old.next().unwrap();
|
||||
merged.push((key, new.next().unwrap().1));
|
||||
}
|
||||
}
|
||||
}
|
||||
merged.extend(old);
|
||||
merged.extend(new);
|
||||
self.entries = merged;
|
||||
}
|
||||
}
|
||||
impl<const N: usize> From<[(Cow<'static, str>, f64); N]> for NumericFactorMap {
|
||||
fn from(entries: [(Cow<'static, str>, f64); N]) -> Self {
|
||||
entries.into_iter().collect()
|
||||
}
|
||||
}
|
||||
impl From<BTreeMap<Cow<'static, str>, f64>> for NumericFactorMap {
|
||||
fn from(entries: BTreeMap<Cow<'static, str>, f64>) -> Self {
|
||||
Self {
|
||||
entries: entries.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for NumericFactorMap {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut map = serializer.serialize_map(Some(self.len()))?;
|
||||
for (key, value) in self {
|
||||
map.serialize_entry(key, value)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for NumericFactorMap {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct Fields;
|
||||
impl<'de> Visitor<'de> for Fields {
|
||||
type Value = NumericFactorMap;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("a numeric factor map")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
|
||||
let mut entries = Vec::new();
|
||||
while let Some((key, value)) = map.next_entry::<String, f64>()? {
|
||||
entries.push((Cow::Owned(key), value));
|
||||
}
|
||||
Ok(entries.into_iter().collect())
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Fields)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn updates_order_removal_and_values_match_tree_map() {
|
||||
let mut flat = NumericFactorMap::new();
|
||||
let mut tree = BTreeMap::new();
|
||||
let mut seed = 71_u64;
|
||||
for index in 0..10000 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let key: Cow<'static, str> = Cow::Owned(format!("factor_{:04}", (seed >> 32) % 1000));
|
||||
let value = (index as f64 - 5000.0) / 7.0;
|
||||
if index % 11 == 0 {
|
||||
assert_eq!(flat.remove(key.as_ref()), tree.remove(key.as_ref()));
|
||||
} else {
|
||||
assert_eq!(flat.insert(key.clone(), value), tree.insert(key, value));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
);
|
||||
flat.retain(|_, value| *value > 100.0);
|
||||
tree.retain(|_, value| *value > 100.0);
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
std::mem::size_of::<NumericFactorMap>(),
|
||||
std::mem::size_of_val(&tree)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_load_is_sorted_and_keeps_last_value_for_each_field() {
|
||||
let input = vec![
|
||||
(Cow::Borrowed("z"), 2.0),
|
||||
(Cow::Borrowed("a"), 1.0),
|
||||
(Cow::Borrowed("z"), 3.0),
|
||||
(Cow::Borrowed("z"), 4.0),
|
||||
];
|
||||
let flat: NumericFactorMap = input.clone().into_iter().collect();
|
||||
let tree: BTreeMap<_, _> = input.into_iter().collect();
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(flat["z"], 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialization_keeps_the_map_contract_and_precise_numbers() {
|
||||
let input = [
|
||||
(Cow::Borrowed("zero"), -0.0),
|
||||
(Cow::Borrowed("tiny"), 1.0000000000000002),
|
||||
(Cow::Borrowed("large"), 9.123456789123456e20),
|
||||
];
|
||||
let flat = NumericFactorMap::from(input.clone());
|
||||
let tree = BTreeMap::from(input);
|
||||
let json = serde_json::to_string(&flat).unwrap();
|
||||
assert_eq!(json, serde_json::to_string(&tree).unwrap());
|
||||
let decoded: NumericFactorMap = serde_json::from_str(&json).unwrap();
|
||||
for (key, value) in &flat {
|
||||
assert_eq!(value.to_bits(), decoded[key.as_ref()].to_bits());
|
||||
}
|
||||
let duplicate: NumericFactorMap = serde_json::from_str(r#"{"x":1,"x":2}"#).unwrap();
|
||||
assert_eq!(duplicate["x"], 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_does_not_share_mutable_values() {
|
||||
let original = NumericFactorMap::from([(Cow::Borrowed("signal"), 1.0)]);
|
||||
let mut copy = original.clone();
|
||||
*copy.get_mut("signal").unwrap() = 0.0;
|
||||
copy.insert(Cow::Borrowed("other"), 2.0);
|
||||
assert_eq!(original["signal"], 1.0);
|
||||
assert!(!original.contains_key("other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_batch_merge_matches_tree_and_preserves_old_key_ownership() {
|
||||
let entries = (0..4096)
|
||||
.map(|index| (Cow::Owned(format!("f_{index:05}")), index as f64))
|
||||
.collect::<Vec<_>>();
|
||||
let mut flat: NumericFactorMap = entries.clone().into_iter().collect();
|
||||
let mut tree = BTreeMap::from_iter(entries);
|
||||
flat.insert(Cow::Borrowed("shared"), -0.0);
|
||||
tree.insert(Cow::Borrowed("shared"), -0.0);
|
||||
let incoming = (2048..8192)
|
||||
.rev()
|
||||
.map(|index| (Cow::Owned(format!("f_{index:05}")), -(index as f64)))
|
||||
.chain([(Cow::Owned("shared".to_owned()), 1.0)])
|
||||
.collect::<Vec<_>>();
|
||||
flat.extend(incoming.clone());
|
||||
tree.extend(incoming);
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
);
|
||||
assert!(matches!(flat.keys().last(), Some(Cow::Borrowed("shared"))));
|
||||
flat.extend([(Cow::Borrowed("zz"), f64::NAN)]);
|
||||
assert!(flat["zz"].is_nan());
|
||||
flat.extend(std::iter::empty());
|
||||
assert_eq!(flat.len(), tree.len() + 1);
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,11 @@ pub fn build_dataset_context(
|
||||
}
|
||||
|
||||
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
|
||||
// A runner bundle also contains source/extract copies. Follow the same
|
||||
// authoritative spec selection as the execution loader, not those copies.
|
||||
if let Some(spec) = value.get("strategySpec").or_else(|| value.get("strategy_spec")) {
|
||||
return specs_in_value(spec);
|
||||
}
|
||||
let mut specs = Vec::new();
|
||||
match value {
|
||||
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
|
||||
@@ -221,7 +226,15 @@ pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
|
||||
}
|
||||
}
|
||||
Value::Object(items) => {
|
||||
for v in items.values() {
|
||||
let typed_pool = items.get("stockPool").or_else(|| items.get("stock_pool"))
|
||||
.is_some_and(Value::is_object);
|
||||
for (key, v) in items {
|
||||
// The executable pool already supplies decoded expressions.
|
||||
// Its display/source serialization escapes those expressions
|
||||
// one more time and is not another executable program.
|
||||
if typed_pool && matches!(key.as_str(), "sourceCode" | "source_code") {
|
||||
continue;
|
||||
}
|
||||
specs.extend(specs_in_value(v)?);
|
||||
}
|
||||
}
|
||||
@@ -248,6 +261,31 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn structured_pool_conditions_are_not_rescanned_inside_serialized_source_code() {
|
||||
let pattern = json!({"template":"expression","parameters":{"history_window":20},
|
||||
"expression":{"kind":"operator","name":"GT","args":[{"kind":"field","name":"amount"},{"kind":"number","value":0}]}});
|
||||
let expr = format!("pattern_signal({})", serde_json::to_string(&pattern.to_string()).unwrap());
|
||||
let pool = json!({"schema_version":1,"pool_id":"fixture","version_id":"v1","members":[],
|
||||
"allocation_policy":{},"timing_policy":{},"stop_take_policy":{},"out_of_pool_policy":"hold",
|
||||
"exit_signals":[{"role":"risk_exit","when_expr":expr,"remaining_position_bps":5000,"reason":"fixture"}]});
|
||||
let source = format!("stock_pool.config({pool})");
|
||||
for (pool_key, source_key) in [("stockPool", "sourceCode"), ("stock_pool", "source_code")] {
|
||||
let value = json!({pool_key:pool,source_key:source,"runtimeExpressions":{"trading":{"buyFilterExpr":expr}}});
|
||||
assert_eq!(specs_in_value(&value).unwrap().len(), 2);
|
||||
for wrapper in ["strategySpec", "strategy_spec"] {
|
||||
let bundle = json!({wrapper:value,"strategy_source":{"source_code":source},
|
||||
"strategy_extract":{"parameters":{"source_code":source}}});
|
||||
assert_eq!(specs_in_value(&bundle).unwrap().len(), 2);
|
||||
}
|
||||
let mut invalid = value.clone();
|
||||
invalid[pool_key]["exit_signals"][0]["when_expr"] = json!("pattern_signal(not-json)");
|
||||
assert!(specs_in_value(&invalid).is_err(), "invalid actual conditions must still fail");
|
||||
assert!(specs_in_value(&json!({"strategySpec":invalid})).is_err());
|
||||
}
|
||||
assert_eq!(specs_in_value(&json!({"sourceCode":format!("risk.stop_loss({expr})")})).unwrap().len(),1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
|
||||
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
|
||||
@@ -298,7 +336,7 @@ mod tests {
|
||||
][n][i];
|
||||
market.push(DailyMarketSnapshot {
|
||||
date: *d,
|
||||
symbol: s.to_string(),
|
||||
symbol: (*s).into(),
|
||||
timestamp: None,
|
||||
day_open: c,
|
||||
open: c,
|
||||
@@ -321,7 +359,7 @@ mod tests {
|
||||
});
|
||||
factors.push(DailyFactorSnapshot {
|
||||
date: *d,
|
||||
symbol: s.to_string(),
|
||||
symbol: (*s).into(),
|
||||
market_cap_bn: 1.,
|
||||
free_float_cap_bn: 1.,
|
||||
pe_ttm: 10.,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,14 @@ impl PlatformExprStrategy {
|
||||
.as_ref()
|
||||
.ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))?
|
||||
.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(
|
||||
&program.allocation_policy,
|
||||
&program.stop_take_policy,
|
||||
@@ -78,17 +86,29 @@ impl PlatformExprStrategy {
|
||||
closes,
|
||||
});
|
||||
}
|
||||
let rule = pool::normalize_stock_pool_execution_rule(
|
||||
let rule = pool::normalize_stock_pool_execution_rule_with_exit_roles(
|
||||
Some(&program.timing_policy),
|
||||
!self.config.buy_filter_expr.trim().is_empty(),
|
||||
!self.config.stop_loss_expr.trim().is_empty()
|
||||
|| !self.config.take_profit_expr.trim().is_empty()
|
||||
|| !self.config.position_target_rules.is_empty(),
|
||||
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::OrdinarySell),
|
||||
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::RiskExit),
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
if self.config.in_skip_window(ctx.decision_date) {
|
||||
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 (market_date, universe_date, factor_date) = self.selection_dates(ctx);
|
||||
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 symbol in native_exits {
|
||||
constraints.position_target_bps.insert(symbol, 0);
|
||||
}
|
||||
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);
|
||||
for (role, targets) in self.current_position_target_rules_by_role(ctx, ctx.decision_date, factor_date, &day)? {
|
||||
let output = match role { pool::StockPoolExitRole::OrdinarySell => &mut constraints.position_target_bps, pool::StockPoolExitRole::RiskExit => &mut constraints.independent_position_target_bps };
|
||||
for (symbol, (bps, _)) in targets { output.insert(symbol, bps); }
|
||||
}
|
||||
let limit = constraints.target_holding_count.unwrap_or(ranked.len());
|
||||
let final_symbols = ranked
|
||||
.iter()
|
||||
.filter(|symbol| !constraints.position_target_bps.contains_key(*symbol))
|
||||
.take(limit)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
@@ -91,6 +91,8 @@ pub struct StrategyRebalanceSpec {
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyExecutionSpec {
|
||||
#[serde(default, alias = "volume_capacity_mode")]
|
||||
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
|
||||
#[serde(default)]
|
||||
pub frequency: Option<String>,
|
||||
#[serde(default, alias = "matching_type")]
|
||||
@@ -164,9 +166,22 @@ pub struct StrategyExecutionSpec {
|
||||
pub sell_then_buy_delay_slippage_rate: Option<f64>,
|
||||
}
|
||||
|
||||
impl StrategyRuntimeSpec {
|
||||
pub fn volume_capacity_mode(&self) -> Result<crate::execution_capacity::VolumeCapacityMode, String> {
|
||||
let engine = self.engine_config.as_ref().and_then(|config| config.volume_capacity_mode);
|
||||
let execution = self.execution.as_ref().and_then(|config| config.volume_capacity_mode);
|
||||
if engine.zip(execution).is_some_and(|(a, b)| a != b) {
|
||||
return Err("conflicting engine/execution volumeCapacityMode".into());
|
||||
}
|
||||
Ok(execution.or(engine).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyEngineConfig {
|
||||
#[serde(default, alias = "volume_capacity_mode")]
|
||||
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
|
||||
#[serde(default)]
|
||||
pub frequency: Option<String>,
|
||||
#[serde(default, alias = "template_id")]
|
||||
@@ -1822,6 +1837,7 @@ pub fn platform_expr_config_from_spec(
|
||||
strategy_spec: Option<&StrategyRuntimeSpec>,
|
||||
) -> Result<PlatformExprStrategyConfig, String> {
|
||||
let mut cfg = PlatformExprStrategyConfig::generic();
|
||||
cfg.volume_capacity_mode = strategy_spec.map(StrategyRuntimeSpec::volume_capacity_mode).transpose()?.unwrap_or_default();
|
||||
cfg.strategy_name = strategy_id.to_string();
|
||||
if !signal_symbol.trim().is_empty() {
|
||||
cfg.signal_symbol = signal_symbol.trim().to_string();
|
||||
@@ -2323,6 +2339,7 @@ pub fn platform_expr_config_from_spec(
|
||||
));
|
||||
}
|
||||
cfg.position_target_rules.push(PlatformPositionTargetRule {
|
||||
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
|
||||
when_expr: when_expr.to_string(),
|
||||
remaining_position_bps: rule.remaining_position_bps,
|
||||
reason: rule
|
||||
@@ -2696,9 +2713,14 @@ pub fn platform_expr_config_from_spec(
|
||||
}
|
||||
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())}
|
||||
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_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,secondary_sell)?;
|
||||
pool.validate(secondary_buy,false)?;
|
||||
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.hold_until_exit_enabled=false;
|
||||
cfg.daily_top_up_enabled=false;
|
||||
@@ -3440,6 +3462,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
cfg.position_target_rules,
|
||||
vec![PlatformPositionTargetRule {
|
||||
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
|
||||
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
|
||||
remaining_position_bps: 5000,
|
||||
reason: "factor_reduce_position".to_string(),
|
||||
|
||||
@@ -732,6 +732,16 @@ impl PortfolioState {
|
||||
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 {
|
||||
self.initial_cash.to_f64()
|
||||
}
|
||||
@@ -1428,8 +1438,6 @@ mod tests {
|
||||
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||
PriceField,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn cash_ledger_accumulates_micro_yuan_exactly() {
|
||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||
@@ -1631,7 +1639,7 @@ mod tests {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: prev_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: None,
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -1654,7 +1662,7 @@ mod tests {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: None,
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -1678,18 +1686,18 @@ mod tests {
|
||||
],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 50.0,
|
||||
free_float_cap_bn: 45.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -1744,7 +1752,7 @@ mod tests {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: None,
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -1767,18 +1775,18 @@ mod tests {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 50.0,
|
||||
free_float_cap_bn: 45.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -1830,7 +1838,7 @@ mod tests {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date: prev_date,
|
||||
symbol: "601028.SH".to_string(),
|
||||
symbol: "601028.SH".into(),
|
||||
timestamp: None,
|
||||
day_open: 10.2,
|
||||
open: 10.2,
|
||||
@@ -1913,7 +1921,7 @@ mod tests {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: buy_date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: None,
|
||||
day_open: 2.99,
|
||||
open: 2.99,
|
||||
@@ -1936,7 +1944,7 @@ mod tests {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: next_date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: None,
|
||||
day_open: 3.03,
|
||||
open: 3.03,
|
||||
|
||||
@@ -869,7 +869,7 @@ mod tests {
|
||||
fn candidate(date: NaiveDate) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: "002633.SZ".to_string(),
|
||||
symbol: "002633.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -885,7 +885,7 @@ mod tests {
|
||||
fn market(date: NaiveDate, last_price: f64, lower_limit: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "002633.SZ".to_string(),
|
||||
symbol: "002633.SZ".into(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: last_price,
|
||||
open: last_price,
|
||||
@@ -944,7 +944,7 @@ mod tests {
|
||||
let mut candidate=candidate(day);
|
||||
let mut snapshot=market(day,1.2,0.5);
|
||||
snapshot.lower_limit=0.01;snapshot.upper_limit=10.;
|
||||
let instrument=Instrument{symbol:candidate.symbol.clone(),name:"fixture fund".into(),board:"ETF".into(),round_lot:100,listed_at:Some(d(2024,1,2)),delisted_at:None,status:"active".into()};
|
||||
let instrument=Instrument{symbol:candidate.symbol.to_string(),name:"fixture fund".into(),board:"ETF".into(),round_lot:100,listed_at:Some(d(2024,1,2)),delisted_at:None,status:"active".into()};
|
||||
let config=FidcRiskControlConfig::default();
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),None);
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.,&config),Some("invalid execution price"));
|
||||
@@ -1047,7 +1047,7 @@ mod tests {
|
||||
config
|
||||
.static_rules
|
||||
.blacklisted_symbols
|
||||
.insert(candidate.symbol.clone());
|
||||
.insert(candidate.symbol.to_string());
|
||||
|
||||
let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
@@ -1092,7 +1092,7 @@ mod tests {
|
||||
let mut candidate = candidate(date);
|
||||
let config = FidcRiskControlConfig::default();
|
||||
for symbol in ["688001.SH", "689001.SH", "000001.SZ"] {
|
||||
candidate.symbol = symbol.to_string();
|
||||
candidate.symbol = symbol.into();
|
||||
for is_kcb in [false, true] {
|
||||
candidate.is_kcb = is_kcb;
|
||||
let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
@@ -1200,7 +1200,7 @@ mod tests {
|
||||
fn configurable_bjse_filter_can_be_disabled() {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.symbol = "430047.BJ".to_string();
|
||||
candidate.symbol = "430047.BJ".into();
|
||||
candidate.allow_sell = true;
|
||||
let market = market(date, 6.27, 5.63);
|
||||
let default_selection =
|
||||
@@ -1232,7 +1232,7 @@ mod tests {
|
||||
fn concrete_kcb_reason_wins_over_generic_missing_risk_state() {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.symbol = "688506.SH".to_string();
|
||||
candidate.symbol = "688506.SH".into();
|
||||
candidate.is_kcb = true;
|
||||
candidate.risk_level_code = Some("missing_risk_state".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
|
||||
@@ -39,6 +39,22 @@ pub enum QuoteConditionScope {
|
||||
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> {
|
||||
let object = policy
|
||||
.as_object()
|
||||
@@ -399,6 +415,8 @@ pub struct StockPoolExecutionRule {
|
||||
pub sell_condition_scope: Option<QuoteConditionScope>,
|
||||
#[serde(skip)]
|
||||
pub secondary_sell_condition: bool,
|
||||
#[serde(skip)]
|
||||
pub independent_sell_condition: bool,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::holding_policy::deserialize_optional_policy"
|
||||
@@ -476,6 +494,10 @@ pub struct StockPoolDecisionConstraints {
|
||||
pub default_stop_loss: Option<Decimal>,
|
||||
pub default_take_profit: Option<Decimal>,
|
||||
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 same_day_sold_symbols: BTreeSet<String>,
|
||||
pub automatic_permissions: BTreeMap<String, crate::holding_policy::AutomaticTradePermission>,
|
||||
@@ -508,6 +530,8 @@ pub struct StockPoolPlanRow {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
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 rows: Vec<StockPoolPlanRow>,
|
||||
pub budget: Decimal,
|
||||
@@ -549,6 +573,8 @@ pub struct StockPoolProgram {
|
||||
pub timing_policy: Value,
|
||||
pub stop_take_policy: Value,
|
||||
pub out_of_pool_policy: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub exit_signals: Vec<StockPoolExitSignal>,
|
||||
}
|
||||
|
||||
impl StockPoolProgram {
|
||||
@@ -562,10 +588,19 @@ impl StockPoolProgram {
|
||||
normalize_stock_pool_members(&self.members)?;
|
||||
stock_pool_funding_from_configuration(&self.allocation_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),
|
||||
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!(
|
||||
self.out_of_pool_policy.as_str(),
|
||||
@@ -583,6 +618,7 @@ impl Default for StockPoolExecutionRule {
|
||||
buy_condition_scope: None,
|
||||
sell_condition_scope: None,
|
||||
secondary_sell_condition: false,
|
||||
independent_sell_condition: false,
|
||||
automatic_trade_protection: Default::default(),
|
||||
schema_version: STOCK_POOL_SCHEMA_VERSION,
|
||||
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();
|
||||
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 {
|
||||
if permission.max_holding_exit {
|
||||
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() {
|
||||
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, ¤t)?;
|
||||
for symbol in constraints.frozen_positions.keys() {
|
||||
effective_position_targets.remove(symbol);
|
||||
@@ -863,37 +922,49 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
.then(|| symbol.clone())
|
||||
})
|
||||
.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 sell_condition_denials = BTreeSet::new();
|
||||
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
|
||||
.iter()
|
||||
.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())
|
||||
.collect::<Vec<_>>();
|
||||
let qualified = quote_condition_results(
|
||||
let qualified = if ordinary_enabled { quote_condition_results(
|
||||
&rule.sell_condition,
|
||||
rule.sell_condition_scope,
|
||||
&held,
|
||||
"e_map,
|
||||
)?;
|
||||
)? } else { BTreeMap::new() };
|
||||
for symbol in held {
|
||||
if global_stop_hits.contains(&symbol)
|
||||
|| constraints
|
||||
.automatic_permissions
|
||||
.get(&symbol)
|
||||
.is_some_and(|permission| permission.max_holding_exit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let permitted = qualified.get(&symbol) == Some(&true)
|
||||
let permitted = ordinary_enabled && qualified.get(&symbol) == Some(&true)
|
||||
&& (!rule.secondary_sell_condition
|
||||
|| constraints.position_target_bps.contains_key(&symbol));
|
||||
if !permitted {
|
||||
sell_condition_denials.insert(symbol.clone());
|
||||
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 {
|
||||
quote_sell_exits.insert(symbol.clone());
|
||||
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 =
|
||||
normalize_symbol_set(&same_day_sold_symbols.iter().cloned().collect::<Vec<_>>())?;
|
||||
let mut rebuy_exclusions = stop_take_exits.clone();
|
||||
rebuy_exclusions.extend(effective_position_targets.keys().cloned());
|
||||
rebuy_exclusions.extend(
|
||||
normalized_same_day_sold
|
||||
.iter()
|
||||
@@ -1031,7 +1103,12 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
}
|
||||
let mut planning_symbols = active_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());
|
||||
}
|
||||
}
|
||||
@@ -1323,24 +1400,11 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
}
|
||||
|
||||
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) {
|
||||
return Err(format!(
|
||||
"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
|
||||
.get(symbol)
|
||||
.map(|value| value.0)
|
||||
@@ -1362,10 +1426,11 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
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,
|
||||
)
|
||||
};
|
||||
}.min(current_quantity);
|
||||
let desired_reduction = (current_quantity - requested_target).max(Decimal::ZERO);
|
||||
let executable = if *target_bps == 0 {
|
||||
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 {
|
||||
(
|
||||
"FACTOR_EXIT_ALREADY_SATISFIED",
|
||||
"生产因子持仓动作命中,当前无持仓",
|
||||
"持仓退出规则命中,当前无持仓",
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
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 {
|
||||
(
|
||||
"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 stop_take_exits.contains(symbol) {
|
||||
"止损/止盈触发,覆盖较弱的减仓目标"
|
||||
} else if constraints.independent_position_target_bps.get(symbol) == Some(target_bps) {
|
||||
"独立风险退出条件命中"
|
||||
} else if *target_bps == 0 {
|
||||
"生产因子退出条件命中"
|
||||
} 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() {
|
||||
let caps = index_cap::remaining_index_targets(
|
||||
¤t,
|
||||
@@ -1890,7 +1987,15 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
.into_iter()
|
||||
.sum();
|
||||
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 {
|
||||
position_action_bases,
|
||||
market_timing,
|
||||
rows,
|
||||
budget,
|
||||
@@ -2167,6 +2272,15 @@ pub fn normalize_stock_pool_execution_rule(
|
||||
raw: Option<&Value>,
|
||||
secondary_buy_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> {
|
||||
let mut rule = match raw {
|
||||
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}"))?,
|
||||
};
|
||||
rule.secondary_sell_condition = secondary_sell_condition;
|
||||
rule.independent_sell_condition = independent_sell_condition;
|
||||
rule.automatic_trade_protection.validate()?;
|
||||
if rule.schema_version != STOCK_POOL_SCHEMA_VERSION {
|
||||
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());
|
||||
}
|
||||
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()
|
||||
&& parse_stock_pool_condition(&rule.sell_condition).is_none())
|
||||
{
|
||||
|
||||
@@ -509,6 +509,16 @@ fn condition_plan(
|
||||
quotes: &[MarketSnapshot],
|
||||
constraints: &StockPoolDecisionConstraints,
|
||||
) -> 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
|
||||
.iter()
|
||||
.map(|position| {
|
||||
@@ -541,7 +551,6 @@ fn condition_plan(
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[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)],"es(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],"es(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)],"es(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)],"es(1),&constraints);
|
||||
assert_eq!(exit.rows[0].delta_quantity,Decimal::from(-500),"{exit:?}");
|
||||
let unheld=condition_plan(&selection(1,1),&rule,&[],"es(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)],"es(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]
|
||||
fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() {
|
||||
let mut constraints = StockPoolDecisionConstraints::default();
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Configurable index-to-market-cap band. Values are CNY, not implicit yi.
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IndexMarketCapPolicy {
|
||||
pub schema_version: u32,
|
||||
pub index_code: String,
|
||||
pub field: String,
|
||||
pub value_unit: String,
|
||||
pub index_low: f64,
|
||||
pub index_high: f64,
|
||||
pub lower_at_low: f64,
|
||||
pub lower_at_high: f64,
|
||||
pub upper_at_low: f64,
|
||||
pub upper_at_high: f64,
|
||||
}
|
||||
|
||||
impl IndexMarketCapPolicy {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.schema_version != 1 || self.value_unit != "CNY"
|
||||
|| !matches!(self.field.as_str(), "market_cap" | "float_market_cap")
|
||||
{ return Err("index_market_cap_contract_invalid".into()); }
|
||||
let index = self.index_code.split_once('.').is_some_and(|(code, exchange)| {
|
||||
(6..=12).contains(&code.len())
|
||||
&& code.bytes().all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
|
||||
&& matches!(exchange, "SH" | "SZ" | "CSI" | "CNI")
|
||||
});
|
||||
if !index { return Err("index_market_cap_index_invalid".into()); }
|
||||
if [self.index_low,self.index_high,self.lower_at_low,self.lower_at_high,self.upper_at_low,self.upper_at_high]
|
||||
.iter().any(|value| !value.is_finite() || *value <= 0.)
|
||||
|| self.index_low >= self.index_high || self.lower_at_low > self.upper_at_low
|
||||
|| self.lower_at_high > self.upper_at_high
|
||||
{ return Err("index_market_cap_bounds_invalid".into()); }
|
||||
Ok(())
|
||||
}
|
||||
pub fn band(&self, close: f64) -> Result<(f64, f64), String> {
|
||||
self.validate()?;
|
||||
if !close.is_finite() || close <= 0. { return Err("index_market_cap_close_invalid".into()); }
|
||||
let t = (close.clamp(self.index_low,self.index_high) - self.index_low) / (self.index_high-self.index_low);
|
||||
Ok((self.lower_at_low + t*(self.lower_at_high-self.lower_at_low),
|
||||
self.upper_at_low + t*(self.upper_at_high-self.upper_at_low)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IndexMarketCapRow { pub date: NaiveDate, pub close: f64 }
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
pub policy: IndexMarketCapPolicy,
|
||||
pub official_dates: Vec<NaiveDate>,
|
||||
pub index_code: String,
|
||||
pub closes: Vec<IndexMarketCapRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Band { pub date: NaiveDate, pub index_close: f64, pub lower: f64, pub upper: f64 }
|
||||
|
||||
pub fn implementation_sha256() -> String { format!("{:x}", Sha256::digest(include_bytes!("stock_pool_market_cap.rs"))) }
|
||||
|
||||
pub fn evaluate(input: &Request) -> Result<Vec<Band>, String> {
|
||||
input.policy.validate()?;
|
||||
if input.index_code != input.policy.index_code || input.official_dates.is_empty()
|
||||
|| input.official_dates.len() > 4000 || input.official_dates.len() != input.closes.len()
|
||||
|| input.official_dates.windows(2).any(|pair| pair[0]>=pair[1])
|
||||
|| input.closes.iter().zip(&input.official_dates).any(|(row, day)| row.date != *day)
|
||||
{ return Err("index_market_cap_calendar_or_identity_mismatch".into()); }
|
||||
input.closes.iter().map(|row| {
|
||||
let (lower,upper)=input.policy.band(row.close)?;
|
||||
Ok(Band{date:row.date,index_close:row.close,lower,upper})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn policy()->IndexMarketCapPolicy {
|
||||
serde_json::from_value(serde_json::json!({"schema_version":1,"index_code":"000300.SH","field":"market_cap","value_unit":"CNY",
|
||||
"index_low":4000,"index_high":6000,"lower_at_low":2000000000_f64,"lower_at_high":3000000000_f64,
|
||||
"upper_at_low":5000000000_f64,"upper_at_high":8000000000_f64})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn interpolates_declared_endpoints_and_clamps_without_business_defaults(){
|
||||
assert_eq!(policy().band(3000.).unwrap(),(2e9,5e9));
|
||||
assert_eq!(policy().band(5000.).unwrap(),(2.5e9,6.5e9));
|
||||
assert_eq!(policy().band(7000.).unwrap(),(3e9,8e9));
|
||||
let mut decreasing=policy();decreasing.lower_at_low=3e9;decreasing.lower_at_high=2e9;
|
||||
assert_eq!(decreasing.band(5000.).unwrap(),(2.5e9,6.5e9));
|
||||
assert!(policy().band(f64::NAN).is_err());
|
||||
let mut invalid=policy();invalid.value_unit="亿元".into();assert!(invalid.validate().is_err());
|
||||
invalid=policy();invalid.index_high=4000.;assert!(invalid.validate().is_err());
|
||||
invalid=policy();invalid.lower_at_low=9e9;assert!(invalid.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn missing_duplicate_or_mismatched_index_inputs_do_not_shrink_the_calendar(){
|
||||
let day=NaiveDate::from_ymd_opt(2026,9,11).unwrap();
|
||||
let mut input=Request{policy:policy(),official_dates:vec![day],index_code:"000300.SH".into(),closes:vec![IndexMarketCapRow{date:day,close:5000.}]};
|
||||
assert_eq!(evaluate(&input).unwrap()[0].lower,2.5e9);
|
||||
input.official_dates.push(day);assert!(evaluate(&input).is_err());input.official_dates.pop();
|
||||
input.index_code="932000.CSI".into();assert!(evaluate(&input).is_err());
|
||||
input.index_code="000300.SH".into();input.closes.clear();assert!(evaluate(&input).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Condition facts are distinct from the quote's per-observation fill capacity.
|
||||
//! Only a complete, declared raw-minute prefix can prove a session total.
|
||||
use std::collections::BTreeMap;
|
||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, Timelike};
|
||||
use rust_decimal::Decimal;
|
||||
use crate::data::IntradayExecutionQuote;
|
||||
use crate::stock_pool_execution::{parse_stock_pool_condition, StockPoolExecutionRule};
|
||||
|
||||
pub fn requires_session_totals(rule: &StockPoolExecutionRule) -> bool {
|
||||
[rule.buy_condition.as_str(), if rule.sell_trigger_mode == "condition" { rule.sell_condition.as_str() } else { "" }].into_iter().any(|condition| {
|
||||
parse_stock_pool_condition(condition).is_some_and(|(_, field, _, _)| matches!(field.as_str(), "volume" | "amount"))
|
||||
})
|
||||
}
|
||||
|
||||
/// The cash-equity minute feed includes the opening observation and a separate
|
||||
/// post-close segment. Trading eligibility remains owned by the dated rules.
|
||||
fn next_minute(time: NaiveTime) -> Option<NaiveTime> {
|
||||
let minute = time.hour() * 60 + time.minute();
|
||||
let next = match minute {
|
||||
570..=689 | 781..=899 | 906..=929 => minute + 1,
|
||||
690 => 781,
|
||||
900 => 906,
|
||||
_ => return None,
|
||||
};
|
||||
NaiveTime::from_hms_opt(next / 60, next % 60, 0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct SessionTotalsCache {
|
||||
pub date: Option<NaiveDate>,
|
||||
pub symbols: BTreeMap<String, MinutePrefix>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct MinutePrefix {
|
||||
values: BTreeMap<NaiveTime, (Decimal, Decimal)>,
|
||||
failure: String,
|
||||
}
|
||||
|
||||
impl MinutePrefix {
|
||||
pub fn build(date: NaiveDate, symbol: &str, quotes: &[IntradayExecutionQuote]) -> Self {
|
||||
let mut values = BTreeMap::new();
|
||||
let mut expected = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
|
||||
let mut volume = 0_u64;
|
||||
let mut amount = Decimal::ZERO;
|
||||
let mut failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
|
||||
for quote in quotes {
|
||||
let time = quote.timestamp.time();
|
||||
if quote.date != date || quote.timestamp.date() != date || quote.symbol != symbol {
|
||||
failure = format!("stock_pool_session_prefix_identity_invalid:{symbol}:{date}");
|
||||
break;
|
||||
}
|
||||
if time != expected {
|
||||
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:expected={expected}:observed={time}");
|
||||
break;
|
||||
}
|
||||
if quote.observation_kind != crate::data::QuoteObservationKind::MinuteBar {
|
||||
failure = format!("stock_pool_session_prefix_basis_unverified:{symbol}:{date}:{time}");
|
||||
break;
|
||||
}
|
||||
let Some(next_volume) = volume.checked_add(quote.volume_delta) else {
|
||||
failure = format!("stock_pool_session_volume_overflow:{symbol}:{date}:{time}");
|
||||
break;
|
||||
};
|
||||
let delta = if quote.amount_delta.is_finite() && quote.amount_delta >= 0.0 {
|
||||
quote.amount_delta.to_string().parse::<Decimal>().ok()
|
||||
} else { None };
|
||||
let Some(next_amount) = delta.and_then(|delta| amount.checked_add(delta)) else {
|
||||
failure = format!("stock_pool_session_amount_invalid:{symbol}:{date}:{time}");
|
||||
break;
|
||||
};
|
||||
volume = next_volume;
|
||||
amount = next_amount;
|
||||
values.insert(time, (Decimal::from(volume), amount));
|
||||
let Some(next) = next_minute(time) else { break };
|
||||
expected = next;
|
||||
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
|
||||
}
|
||||
Self { values, failure }
|
||||
}
|
||||
|
||||
pub fn at(&self, at: NaiveDateTime) -> Result<(Decimal, Decimal), String> {
|
||||
let time = at.time().with_second(0).unwrap().with_nanosecond(0).unwrap();
|
||||
self.values.get(&time).copied().ok_or_else(|| self.failure.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn quote(hour: u32, minute: u32, volume: u64, amount: f64) -> IntradayExecutionQuote {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 11).unwrap();
|
||||
IntradayExecutionQuote { observation_kind: crate::data::QuoteObservationKind::MinuteBar, date, symbol: "000001.SZ".into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
|
||||
last_price: 10., bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: volume, amount_delta: amount, trading_phase: Some("minute_execution_prices:raw-minute".into()) }
|
||||
}
|
||||
#[test]
|
||||
fn totals_use_only_the_complete_observed_prefix_and_keep_decimal_amounts() {
|
||||
let mut rows = vec![quote(9,30,100,10.01), quote(9,31,0,0.), quote(9,32,200,20.02)];
|
||||
let prefix = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
|
||||
assert_eq!(prefix.at(rows[1].timestamp).unwrap(), (100.into(), Decimal::new(1001,2)));
|
||||
assert_eq!(prefix.at(rows[2].timestamp).unwrap(), (300.into(), Decimal::new(3003,2)));
|
||||
rows[2].volume_delta = 999999;
|
||||
rows[2].amount_delta = f64::NAN;
|
||||
let changed = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
|
||||
assert_eq!(changed.at(rows[1].timestamp).unwrap(), prefix.at(rows[1].timestamp).unwrap());
|
||||
assert!(changed.at(rows[2].timestamp).unwrap_err().contains("amount_invalid"));
|
||||
}
|
||||
#[test]
|
||||
fn sparse_unverified_and_overflowing_quotes_cannot_be_called_session_totals() {
|
||||
let first = quote(9,30,100,1000.);
|
||||
for rows in [vec![quote(9,31,100,1000.)], vec![first.clone(), quote(9,32,100,1000.)]] {
|
||||
let prefix = MinutePrefix::build(first.date, "000001.SZ", &rows);
|
||||
assert!(prefix.at(rows.last().unwrap().timestamp).unwrap_err().contains("prefix_missing"));
|
||||
}
|
||||
let mut unknown = first.clone(); unknown.observation_kind = Default::default();
|
||||
assert!(MinutePrefix::build(first.date, "000001.SZ", &[unknown]).at(first.timestamp).unwrap_err().contains("basis_unverified"));
|
||||
let rows = [quote(9,30,u64::MAX,0.), quote(9,31,1,0.)];
|
||||
assert!(MinutePrefix::build(first.date, "000001.SZ", &rows).at(rows[1].timestamp).unwrap_err().contains("volume_overflow"));
|
||||
}
|
||||
#[test]
|
||||
fn lunch_and_post_close_gaps_follow_the_minute_feed_segments() {
|
||||
let mut rows = Vec::new(); let mut time = NaiveTime::from_hms_opt(9,30,0).unwrap();
|
||||
loop {
|
||||
rows.push(quote(time.hour(), time.minute(), 1, 0.01));
|
||||
let Some(next) = next_minute(time) else { break }; time=next;
|
||||
}
|
||||
let prefix=MinutePrefix::build(rows[0].date,"000001.SZ",&rows);
|
||||
assert_eq!(prefix.at(rows.last().unwrap().timestamp).unwrap(), (Decimal::from(rows.len()), Decimal::new(rows.len() as i64,2)));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==12));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time()==NaiveTime::from_hms_opt(13,0,0).unwrap()));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==15 && (1..6).contains(&row.timestamp.time().minute())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires FIDC_SESSION_PREFIX_SOURCE_JSON from the frozen Source minute response"]
|
||||
fn real_source_session_prefix_matches_observed_checkpoints() {
|
||||
let path=std::env::var("FIDC_SESSION_PREFIX_SOURCE_JSON").expect("explicit Source evidence path");
|
||||
let rows:Vec<IntradayExecutionQuote>=serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
let date=NaiveDate::from_ymd_opt(2026,9,8).unwrap();
|
||||
assert_eq!(rows.len(),242);
|
||||
let prefix=MinutePrefix::build(date,"000063.SZ",&rows);
|
||||
for (hour,minute,volume,amount) in [(9,30,512700,17103672),(9,31,2296631,76576756),(9,32,2983531,99471024),(11,30,27868847,928167630),(13,1,28495518,948994890),(15,0,45625008,1518115100)] {
|
||||
assert_eq!(prefix.at(date.and_hms_opt(hour,minute,0).unwrap()).unwrap(),(Decimal::from(volume),Decimal::from(amount)));
|
||||
}
|
||||
assert!(prefix.at(date.and_hms_opt(15,30,0).unwrap()).unwrap_err().contains("prefix_missing"),"one final aggregate is not a verified intraday prefix");
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,14 @@ pub struct StockPoolEntryProgress {
|
||||
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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StockPoolExecutionState {
|
||||
@@ -34,6 +42,10 @@ pub struct StockPoolExecutionState {
|
||||
pub last_target_weights: BTreeMap<String, i32>,
|
||||
/// First signal excluding an actually held member; not an acquisition date.
|
||||
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> {
|
||||
@@ -53,6 +65,7 @@ impl Default for StockPoolExecutionState {
|
||||
entries: BTreeMap::new(),
|
||||
last_target_weights: BTreeMap::new(),
|
||||
removed_since: BTreeMap::new(),
|
||||
position_action_bases: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +75,7 @@ impl StockPoolExecutionState {
|
||||
if self.schema_version != 1
|
||||
|| self.entries.len() > 10000
|
||||
|| self.removed_since.len() > 10000
|
||||
|| self.position_action_bases.len() > 10000
|
||||
{
|
||||
return Err("stock_pool_execution_state_invalid_schema_or_size".into());
|
||||
}
|
||||
@@ -70,6 +84,7 @@ impl StockPoolExecutionState {
|
||||
.keys()
|
||||
.chain(self.removed_since.keys())
|
||||
.chain(self.last_target_weights.keys())
|
||||
.chain(self.position_action_bases.keys())
|
||||
{
|
||||
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
|
||||
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());
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -186,7 +207,7 @@ impl StockPoolExecutionState {
|
||||
self.record_targets(
|
||||
decision_date,
|
||||
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,
|
||||
target_weight_bps: row.target_weight_bps,
|
||||
target_value: row.target_value,
|
||||
@@ -194,7 +215,67 @@ impl StockPoolExecutionState {
|
||||
target_quantity: row.target_quantity,
|
||||
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>(
|
||||
@@ -213,6 +294,9 @@ impl StockPoolExecutionState {
|
||||
}
|
||||
let mut next = self.clone();
|
||||
for row in rows {
|
||||
if row.status == "AUTOMATIC_TRADE_PROTECTED" {
|
||||
continue;
|
||||
}
|
||||
if row.target_weight_bps > 0 {
|
||||
next.last_target_weights
|
||||
.insert(row.symbol.into(), row.target_weight_bps);
|
||||
|
||||
@@ -3184,7 +3184,7 @@ mod tests {
|
||||
.enumerate()
|
||||
.map(|(index, date)| DailyMarketSnapshot {
|
||||
date: *date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: 10.0 + index as f64,
|
||||
open: 10.0 + index as f64,
|
||||
@@ -3210,21 +3210,21 @@ mod tests {
|
||||
.iter()
|
||||
.map(|date| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 9.0,
|
||||
pe_ttm: 12.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let candidate_rows = dates
|
||||
.iter()
|
||||
.map(|date| CandidateEligibility {
|
||||
date: *date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
|
||||
@@ -83,7 +83,7 @@ impl SelectionContext<'_> {
|
||||
for factor in self.data.factor_snapshot_rows_on(self.decision_date) {
|
||||
if self
|
||||
.dynamic_universe
|
||||
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol))
|
||||
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(factor.symbol.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -307,7 +307,7 @@ mod tests {
|
||||
fn market(symbol: &str, price: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: d(),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some("2025-01-02 10:00:00".to_string()),
|
||||
day_open: price,
|
||||
open: price,
|
||||
@@ -333,7 +333,7 @@ mod tests {
|
||||
fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date: d(),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn: market_cap_bn,
|
||||
pe_ttm: 10.0,
|
||||
@@ -347,7 +347,7 @@ mod tests {
|
||||
fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date: d(),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
|
||||
@@ -107,6 +107,7 @@ fn action(quantity: &str, when: &str) -> PlatformTradeAction {
|
||||
}
|
||||
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false;
|
||||
@@ -119,7 +120,7 @@ fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
|
||||
action("-100", "decision_date >= \"2026-09-14\""),
|
||||
];
|
||||
config.matching_type = MatchingType::CurrentBarClose;
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
BacktestEngine::new(
|
||||
data(),
|
||||
@@ -276,6 +277,7 @@ fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
|
||||
)
|
||||
.unwrap();
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.strategy_name = "protection_test".into();
|
||||
@@ -294,7 +296,7 @@ fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let result = BacktestEngine::new(
|
||||
dataset,
|
||||
|
||||
@@ -15,7 +15,7 @@ fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
fn candidate() -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date: d(2024, 1, 3),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -31,7 +31,7 @@ fn candidate() -> CandidateEligibility {
|
||||
fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: d(2024, 1, 3),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2024-01-03 10:18:00".to_string()),
|
||||
day_open: open,
|
||||
open,
|
||||
@@ -256,7 +256,7 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
|
||||
|
||||
let snapshot = DailyMarketSnapshot {
|
||||
date: d(2024, 4, 7),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2024-04-07 10:18:00".to_string()),
|
||||
day_open: 2.53,
|
||||
open: 2.53,
|
||||
|
||||
@@ -113,7 +113,7 @@ impl Strategy for BuyAndHoldStrategy {
|
||||
fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -139,21 +139,21 @@ fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
|
||||
fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stock_candidate(date: NaiveDate) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -195,7 +195,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: buy_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-01 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -218,7 +218,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -241,7 +241,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: payable_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -266,42 +266,42 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: buy_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: payable_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: buy_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -314,7 +314,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -327,7 +327,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: payable_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -394,7 +394,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
.with_minimum_commission(0.0),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
),
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
|
||||
BacktestConfig {
|
||||
initial_cash: 11_008.0,
|
||||
benchmark_code: "000300.SH".to_string(),
|
||||
@@ -475,7 +475,7 @@ fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
),
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
|
||||
BacktestConfig {
|
||||
initial_cash: 11_008.0,
|
||||
benchmark_code: "000300.SH".to_string(),
|
||||
|
||||
@@ -105,7 +105,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
fixture_instruments(),
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some(format!("{date} 15:00:00")),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -128,7 +128,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -139,7 +139,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -170,7 +170,7 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Close,
|
||||
)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
@@ -196,7 +196,7 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(t(10, 17, 59)),
|
||||
@@ -235,7 +235,7 @@ fn engine_skips_decision_quote_symbol_plan_without_loader() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Close,
|
||||
)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
@@ -269,7 +269,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -292,7 +292,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -317,7 +317,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -328,7 +328,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -341,7 +341,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -354,7 +354,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -391,7 +391,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
@@ -411,7 +411,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(t(10, 39, 59)),
|
||||
@@ -439,7 +439,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -462,7 +462,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -487,7 +487,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -498,7 +498,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -511,7 +511,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -524,7 +524,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -556,7 +556,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: first.and_time(t(10, 39, 59)),
|
||||
@@ -569,7 +569,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: second.and_time(t(10, 39, 59)),
|
||||
@@ -590,7 +590,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
@@ -674,7 +674,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -697,7 +697,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -722,7 +722,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -733,7 +733,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -746,7 +746,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -759,7 +759,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -796,7 +796,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
@@ -826,7 +826,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(start_time) - Duration::seconds(1),
|
||||
|
||||
@@ -71,7 +71,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -94,7 +94,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 5.0,
|
||||
open: 5.0,
|
||||
@@ -117,7 +117,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 5.05,
|
||||
open: 5.05,
|
||||
@@ -140,7 +140,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: 5.1,
|
||||
open: 5.1,
|
||||
@@ -165,53 +165,53 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 30.0,
|
||||
free_float_cap_bn: 28.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 30.5,
|
||||
free_float_cap_bn: 28.5,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 31.0,
|
||||
free_float_cap_bn: 29.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -224,7 +224,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -237,7 +237,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -250,7 +250,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -295,7 +295,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
BuyThenHoldStrategy,
|
||||
@@ -364,7 +364,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -387,7 +387,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 20.0,
|
||||
open: 20.0,
|
||||
@@ -410,7 +410,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 21.0,
|
||||
open: 21.0,
|
||||
@@ -435,42 +435,42 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 30.0,
|
||||
free_float_cap_bn: 28.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 31.0,
|
||||
free_float_cap_bn: 29.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -483,7 +483,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -496,7 +496,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -548,7 +548,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
BuyThenHoldStrategy,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
|
||||
.iter()
|
||||
.map(|date| DailyMarketSnapshot {
|
||||
date: *date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: None,
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -55,7 +55,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
|
||||
let session_start = date.and_hms_opt(9, 30, 0).expect("valid session start");
|
||||
for offset in 0..bars_per_day {
|
||||
let timestamp = session_start + Duration::minutes(offset as i64);
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: *date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
timestamp,
|
||||
|
||||
@@ -7,6 +7,7 @@ use fidc_core::{
|
||||
PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use fidc_core::IntradayExecutionQuote;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn day(n: u32) -> NaiveDate {
|
||||
@@ -66,7 +67,7 @@ fn data_with_fund_rules(
|
||||
};
|
||||
market.push(DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: code(n),
|
||||
symbol: code(n).into(),
|
||||
timestamp: None,
|
||||
day_open: price,
|
||||
open: price,
|
||||
@@ -89,7 +90,7 @@ fn data_with_fund_rules(
|
||||
});
|
||||
candidates.push(CandidateEligibility {
|
||||
date,
|
||||
symbol: code(n),
|
||||
symbol: code(n).into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -131,7 +132,7 @@ fn data_with_fund_rules(
|
||||
.flat_map(|date| {
|
||||
(1..=2).map(move |n| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: code(n),
|
||||
symbol: code(n).into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
@@ -142,7 +143,13 @@ fn data_with_fund_rules(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
DataSet::from_components(instruments, market, factors, candidates, benchmarks).unwrap()
|
||||
let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: row.date, symbol: row.symbol.to_string(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(),
|
||||
last_price: row.open, bid1: row.open, ask1: row.open, bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: row.volume, amount_delta: row.open * row.volume as f64,
|
||||
trading_phase: Some("synthetic_observation_fixture".into()),
|
||||
}).collect();
|
||||
DataSet::from_components_with_actions_and_quotes(instruments, market, factors, candidates, benchmarks, vec![], quotes).unwrap()
|
||||
}
|
||||
fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
let mut risk = FidcRiskControlConfig::default();
|
||||
@@ -159,6 +166,7 @@ fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRule
|
||||
ChinaEquityRuleHooks,
|
||||
)
|
||||
.with_matching_type(MatchingType::NextBarOpen)
|
||||
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap())
|
||||
.with_risk_config(risk)
|
||||
}
|
||||
fn contract(signal: NaiveDate, target: usize, preserve: bool) -> FrozenStockPoolIntent {
|
||||
@@ -268,6 +276,7 @@ fn mixed_fund_and_stock_round_trip_uses_declared_ticks_and_asset_specific_fees()
|
||||
let mut costs = ChinaAShareCostModel::default();
|
||||
costs.set_transfer_fee_rate(0.00001);
|
||||
let broker = BrokerSimulator::new(costs, ChinaEquityRuleHooks)
|
||||
.with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::NextBarOpen);
|
||||
let mut account = PortfolioState::new(30_000.);
|
||||
let mut entry = contract(day(2), 1, false);
|
||||
@@ -437,11 +446,122 @@ fn actual_fill_protection_is_evaluated_on_execution_date() {
|
||||
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]
|
||||
fn daily_execution_price_does_not_satisfy_an_unobserved_order_book_condition() {
|
||||
let data = data(false);
|
||||
for field in ["bid1", "ask1"] {
|
||||
let broker = broker(false);
|
||||
let mut account = PortfolioState::new(30000.);
|
||||
let mut intent = contract(day(2), 1, false);
|
||||
intent.rule.trigger_mode = "condition".into();
|
||||
intent.rule.buy_condition = format!("{field}>0");
|
||||
let result = broker.execute_with_event_dates(day(5), day(2), day(2), &mut account, &data, &decision(intent));
|
||||
assert!(result.unwrap_err().to_string().contains(field));
|
||||
assert!(account.positions().is_empty());
|
||||
assert_eq!(account.cash(), 30000.);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cumulative_conditions_do_not_consume_future_bars_or_inflate_fill_capacity() {
|
||||
let mut data = data(false);
|
||||
let mut quotes = Vec::new();
|
||||
for n in 1..=2 {
|
||||
let price = if n == 1 {20.} else {10.};
|
||||
for (minute, volume) in [(30,600), (31,0), (32,400)] {
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
|
||||
date: day(5), symbol: code(n), timestamp: day(5).and_hms_opt(9,minute,0).unwrap(),
|
||||
last_price: price, bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: volume, amount_delta: volume as f64 * price, trading_phase: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
data.replace_execution_quotes(quotes.clone());
|
||||
let at = chrono::NaiveTime::from_hms_opt(9,32,0).unwrap();
|
||||
for condition in ["volume>=1000", "amount>=20000"] {
|
||||
let broker=broker(true).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(at);
|
||||
let mut account=PortfolioState::new(30000.);
|
||||
let mut intent=contract(day(5),1,false);
|
||||
intent.rule.buy_condition=condition.into();intent.rule.trigger_mode="condition".into();
|
||||
let report=broker.execute_with_event_dates(day(5),day(5),day(5),&mut account,&data,&decision(intent)).unwrap();
|
||||
assert_eq!(report.fill_events.iter().map(|fill|fill.quantity).sum::<u32>(),100,"{condition}: {report:?}");
|
||||
assert_eq!(data.execution_quotes_on(day(5),&code(1))[2].volume_delta,400);
|
||||
}
|
||||
let mut future=quotes.last().unwrap().clone();future.symbol=code(1);future.timestamp=day(5).and_hms_opt(9,33,0).unwrap();future.volume_delta=9000;future.amount_delta=180000.;
|
||||
data.add_execution_quotes(vec![future]);
|
||||
let broker=broker(false).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(at);
|
||||
let mut account=PortfolioState::new(30000.);
|
||||
let mut intent=contract(day(5),1,false);intent.rule.buy_condition="volume>1000".into();intent.rule.trigger_mode="condition".into();
|
||||
let report=broker.execute_with_event_dates(day(5),day(5),day(5),&mut account,&data,&decision(intent)).unwrap();
|
||||
assert!(report.fill_events.is_empty(),"future volume must not satisfy this signal: {report:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_total_cache_is_invalidated_without_mutating_other_dataset_clones() {
|
||||
let mut original=data(false);
|
||||
let quote=IntradayExecutionQuote { observation_kind:fidc_core::data::QuoteObservationKind::MinuteBar,date:day(5),symbol:code(1),timestamp:day(5).and_hms_opt(9,30,0).unwrap(),last_price:20.,bid1:0.,ask1:0.,bid1_volume:0,ask1_volume:0,volume_delta:100,amount_delta:2000.,trading_phase:None };
|
||||
original.replace_execution_quotes(vec![quote.clone()]);
|
||||
assert_eq!(original.execution_session_totals(&code(1),quote.timestamp).unwrap().0,Decimal::from(100));
|
||||
let mut changed=original.clone();let mut next=quote.clone();next.timestamp=day(5).and_hms_opt(9,31,0).unwrap();
|
||||
changed.add_execution_quotes(vec![next.clone()]);
|
||||
assert_eq!(changed.execution_session_totals(&code(1),next.timestamp).unwrap().0,Decimal::from(200));
|
||||
assert!(original.execution_session_totals(&code(1),next.timestamp).is_err());
|
||||
changed.remove_execution_quotes_on_date(day(5));
|
||||
assert!(changed.execution_session_totals(&code(1),quote.timestamp).is_err());
|
||||
assert_eq!(original.execution_session_totals(&code(1),quote.timestamp).unwrap().0,Decimal::from(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
|
||||
let intent = contract(day(2), 1, false);
|
||||
for quote_condition in ["", "price<5"] {
|
||||
let program = StockPoolProgram {
|
||||
exit_signals: vec![],
|
||||
schema_version: 1,
|
||||
pool_id: "pool-fixture".into(),
|
||||
version_id: "version-fixture".into(),
|
||||
@@ -508,6 +628,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]
|
||||
fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() {
|
||||
// Generated by OmniQuant's actual handoff and compiler, not a hand-written
|
||||
@@ -630,3 +778,199 @@ fn next_day_outside_policy_executes_after_the_first_exclusion_signal() {
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
|
||||
let members = vec![member()];
|
||||
|
||||
@@ -35,7 +35,7 @@ fn market(
|
||||
) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: d(date),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: None,
|
||||
day_open: open,
|
||||
open,
|
||||
@@ -66,7 +66,7 @@ fn factor(
|
||||
) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date: d(date),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
pe_ttm: 18.0,
|
||||
@@ -87,7 +87,7 @@ fn candidate(
|
||||
) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date: d(date),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing,
|
||||
|
||||
Reference in New Issue
Block a user