Compare commits

..

7 Commits

29 changed files with 2085 additions and 1119 deletions
Generated
+30
View File
@@ -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"
+1
View File
@@ -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
+15 -15
View File
@@ -8299,7 +8299,7 @@ mod tests {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 09:33:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -8425,7 +8425,7 @@ mod tests {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -8505,7 +8505,7 @@ mod tests {
.iter()
.map(|symbol| {
let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string();
snapshot.symbol = (*symbol).into();
if block_exit && *symbol == "000001.SZ" {
snapshot.day_open = snapshot.lower_limit;
snapshot.open = snapshot.lower_limit;
@@ -8520,7 +8520,7 @@ mod tests {
.iter()
.map(|symbol| {
let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string();
candidate.symbol = (*symbol).into();
candidate
})
.collect::<Vec<_>>();
@@ -10479,7 +10479,7 @@ mod tests {
.iter()
.map(|symbol| {
let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string();
snapshot.symbol = (*symbol).into();
if *symbol == "000001.SZ" {
snapshot.day_open = 9.0;
snapshot.open = 9.0;
@@ -10497,7 +10497,7 @@ mod tests {
.iter()
.map(|symbol| {
let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string();
candidate.symbol = (*symbol).into();
candidate
})
.collect::<Vec<_>>();
@@ -10631,7 +10631,7 @@ mod tests {
.iter()
.map(|symbol| {
let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string();
snapshot.symbol = (*symbol).into();
snapshot
})
.collect::<Vec<_>>();
@@ -10639,7 +10639,7 @@ mod tests {
.iter()
.map(|symbol| {
let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string();
candidate.symbol = (*symbol).into();
candidate
})
.collect::<Vec<_>>();
@@ -10696,7 +10696,7 @@ mod tests {
.iter()
.map(|symbol| {
let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string();
snapshot.symbol = (*symbol).into();
snapshot
})
.collect::<Vec<_>>();
@@ -10704,7 +10704,7 @@ mod tests {
.iter()
.map(|symbol| {
let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string();
candidate.symbol = (*symbol).into();
candidate
})
.collect::<Vec<_>>();
@@ -10834,7 +10834,7 @@ mod tests {
.iter()
.map(|symbol| {
let mut snapshot = limit_test_snapshot();
snapshot.symbol = (*symbol).to_string();
snapshot.symbol = (*symbol).into();
snapshot
})
.collect::<Vec<_>>();
@@ -10842,7 +10842,7 @@ mod tests {
.iter()
.map(|symbol| {
let mut candidate = limit_test_candidate(true, true);
candidate.symbol = (*symbol).to_string();
candidate.symbol = (*symbol).into();
candidate
})
.collect::<Vec<_>>();
@@ -11623,7 +11623,7 @@ mod tests {
.with_volume_limit(false)
.with_liquidity_limit(false);
let mut snapshot = limit_test_snapshot();
snapshot.symbol = symbol.to_string();
snapshot.symbol = symbol.into();
snapshot.last_price = 4.21;
snapshot.close = 4.21;
snapshot.bid1 = 4.20;
@@ -11708,7 +11708,7 @@ mod tests {
.with_inactive_limit(false);
let snapshot = DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: Some("2023-05-08 15:00:00".to_string()),
day_open: 5.86,
open: 5.86,
@@ -11756,7 +11756,7 @@ mod tests {
Vec::new(),
vec![CandidateEligibility {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -343,6 +343,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,
+64 -63
View File
@@ -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,
@@ -1204,7 +1205,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],
@@ -2865,7 +2866,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 +2960,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 +3052,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 +3174,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 +3525,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 +4323,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 +4384,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 +4393,7 @@ fn normalize_factor_snapshots(
{
return Err(DataSetError::InvalidBackwardAdjustmentFactor {
date: snapshot.date,
symbol: snapshot.symbol,
symbol: snapshot.symbol.to_string(),
value,
});
}
@@ -4428,7 +4429,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 +4511,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 +4943,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 +5013,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 +5026,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 +5043,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 +5071,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,
@@ -5352,10 +5353,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 +5367,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,
@@ -5534,12 +5535,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 +5551,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 +5774,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 +5978,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 +5987,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 +6001,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,
@@ -6289,14 +6290,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 +6313,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 +6330,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 +6352,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 +6365,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 +6383,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 +6479,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 +6538,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 +6832,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 +6930,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 +7009,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 +7033,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 +7109,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 +7132,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 +7194,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 +7217,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 +7269,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);
+6 -4
View File
@@ -3666,6 +3666,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)
@@ -5423,7 +5425,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,
@@ -5477,21 +5479,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,
+1
View File
@@ -2,6 +2,7 @@ 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;
+339
View File
@@ -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);
}
}
+30 -3
View File
@@ -221,7 +221,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 +256,25 @@ 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);
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_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 +325,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 +348,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 -17
View File
@@ -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,12 +86,11 @@ 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) {
@@ -133,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();
@@ -2339,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
@@ -2712,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;
@@ -3456,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(),
+22 -14
View File
@@ -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,
+7 -7
View File
@@ -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);
+148 -33
View File
@@ -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, &current)?;
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,
&quote_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(
&current,
@@ -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)],&quotes(1),&invalid).unwrap_err().contains("must be below 10000"));
}
#[test]
fn ordinary_sell_keeps_required_quote_failures_and_zero_stop_is_not_an_exit() {
let rule = normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})), false, false).unwrap();
let mut market = quotes(1); market[0].last_price = 9.into(); market[0].volume = None;
for stop in [None, Some(Decimal::ZERO)] {
let constraints = StockPoolDecisionConstraints { default_stop_loss: stop, ..Default::default() };
assert_eq!(condition_plan_result(&selection(1,1), &rule, &[position(1)], &market, &constraints).unwrap_err(), "condition requires volume");
}
}
#[test]
fn quote_field_operator_side_and_scope_matrix_matches_the_configured_predicate() {
let market = quotes(2);
for (field, threshold) in [("price", "10"), ("last", "10"), ("change_pct", "0"), ("volume", "1000000"), ("amount", "10000000"), ("bid1", "10"), ("ask1", "10")] {
for (operator, matched) in [(">",false), (">=",true), ("<",false), ("<=",true), ("==",true), ("!=",false)] {
for scope in [QuoteConditionScope::PerSymbol, QuoteConditionScope::AllTargets, QuoteConditionScope::AnyTarget] {
for side in [OrderSide::Buy, OrderSide::Sell] {
let condition = format!("{field}{operator}{threshold}");
let config = if side == OrderSide::Buy {
json!({"trigger_mode":"condition","buy_condition":condition,"buy_condition_scope":scope})
} else {
json!({"sell_trigger_mode":"condition","sell_condition":condition,"sell_condition_scope":scope})
};
let rule = normalize_stock_pool_execution_rule(Some(&config), false, false).unwrap();
let held = if side == OrderSide::Sell { vec![position(1),position(2)] } else { vec![] };
let plan = condition_plan(&selection(2,2), &rule, &held, &market, &StockPoolDecisionConstraints::default());
assert_eq!(plan.rows.iter().map(|row| &row.symbol).collect::<BTreeSet<_>>().len(), plan.rows.len(), "duplicate target ownership: {plan:?}");
let orders = plan.rows.iter().filter(|row|row.side==Some(side)).count();
assert_eq!(orders, if matched {2} else {0}, "{side:?}/{scope:?}/{condition}: {plan:?}");
if side == OrderSide::Sell && matched {
assert_eq!(plan.estimated_sell_amount, Decimal::from(20000), "{plan:?}");
assert_eq!(plan.estimated_cash_after, Decimal::from(40000), "{plan:?}");
}
}
}
}
}
}
#[test]
fn typed_exit_roles_merge_only_satisfied_ordinary_actions_with_independent_risk() {
for risk in [None,Some(0),Some(5000)] {
for ordinary in [None,Some(0),Some(7500)] {
for quote in ["","price<9","price>9"] {
for locked in [false,true] {
for closable in [0,400,1000] {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":quote})),false,true,true).unwrap();
let mut constraints=StockPoolDecisionConstraints {portfolio_policy:Some(StockPoolPortfolioPolicy{schema_version:1,membership:MembershipPolicy::RetainHoldings,rebalance_weights:false}),..Default::default()};
if let Some(target)=ordinary {constraints.position_target_bps.insert(symbol(1),target);}
if let Some(target)=risk {constraints.independent_position_target_bps.insert(symbol(1),target);}
if locked {constraints.automatic_permissions.insert(symbol(1),crate::holding_policy::AutomaticTradePermission{sell_denial:Some("automatic_trade_locked"),buy_denial:Some("automatic_trade_locked"),..Default::default()});}
let mut held=position(1);held.closable_quantity=closable.into();
let plan=condition_plan(&selection(1,1),&rule,&[held],&quotes(1),&constraints);
assert_eq!(plan.rows.len(),1,"{risk:?}/{ordinary:?}/{quote}: {plan:?}");
let ordinary=if quote=="price<9" {None} else {ordinary};
let target_bps=risk.into_iter().chain(ordinary).min().unwrap_or(10000);
let desired=if target_bps==0 {0} else {(1000*target_bps/10000)/100*100};
let sold=if locked {0} else {(1000-desired).min(closable)};
assert_eq!(plan.rows[0].delta_quantity,-Decimal::from(sold),"{risk:?}/{ordinary:?}/{quote}: {plan:?}");
}
}
}
}
}
}
#[test]
fn risk_only_configuration_never_turns_into_an_unconditional_ordinary_exit() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition"})),false,false,true).unwrap();
let mut constraints=StockPoolDecisionConstraints::default();
let hold=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&constraints);
assert_ne!(hold.rows[0].side,Some(OrderSide::Sell),"a risk-only configuration must not manufacture an exit: {hold:?}");
constraints.independent_position_target_bps.insert(symbol(1),5000);
let exit=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&constraints);
assert_eq!(exit.rows[0].delta_quantity,Decimal::from(-500),"{exit:?}");
let unheld=condition_plan(&selection(1,1),&rule,&[],&quotes(1),&constraints);
assert_eq!(unheld.rows[0].side,Some(OrderSide::Buy),"an exit-only rule must not secretly become a selection/buy filter: {unheld:?}");
}
#[test]
fn quote_only_exit_still_works_when_independent_risk_rules_are_configured() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"price>9"})),false,false,true).unwrap();
let plan=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&StockPoolDecisionConstraints::default());
assert_eq!(plan.rows.len(),1);assert_eq!(plan.rows[0].delta_quantity,Decimal::from(-1000),"{plan:?}");
}
#[test]
fn independent_full_exit_has_no_ordinary_quote_dependency_but_partial_risk_does_not_fake_missing_facts() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>100"})),false,true,true).unwrap();
let mut market=quotes(1);market[0].volume=None;
let mut constraints=StockPoolDecisionConstraints {position_target_bps:BTreeMap::from([(symbol(1),0)]),independent_position_target_bps:BTreeMap::from([(symbol(1),0)]),..Default::default()};
let complete=condition_plan(&selection(1,1),&rule,&[position(1)],&market,&constraints);
assert_eq!(complete.rows[0].delta_quantity,Decimal::from(-1000));
constraints.independent_position_target_bps.insert(symbol(1),5000);
assert!(condition_plan_result(&selection(1,1),&rule,&[position(1)],&market,&constraints).unwrap_err().contains("requires volume"));
}
#[test]
fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() {
let mut constraints = StockPoolDecisionConstraints::default();
+86 -2
View File
@@ -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);
+4 -4
View File
@@ -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,
+4 -4
View File
@@ -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,
+3 -3
View File
@@ -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,
+16 -16
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
+28 -28
View File
@@ -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,
@@ -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,
+95 -95
View File
@@ -49,7 +49,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
}],
vec![DailyMarketSnapshot {
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,
@@ -72,18 +72,18 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
}],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 100.0,
free_float_cap_bn: 80.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,
@@ -109,7 +109,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
fn market_row(date: NaiveDate, symbol: &str, open: f64, close: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: open,
open,
@@ -139,7 +139,7 @@ fn factor_row(
) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn: 100.0,
free_float_cap_bn: 80.0,
pe_ttm: 10.0,
@@ -153,7 +153,7 @@ fn factor_row(
fn candidate_row(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,
@@ -219,7 +219,7 @@ fn two_day_futures_data() -> DataSet {
("stock_connect_north_bound".into(), 1.0),
("industry_citics_l1".into(), 10.0),
("fundamental_net_profit".into(), 99.0),
]),
]).into(),
),
factor_row(
d2,
@@ -232,7 +232,7 @@ fn two_day_futures_data() -> DataSet {
("stock_connect_north_bound".into(), 1.0),
("industry_citics_l1".into(), 10.0),
("fundamental_net_profit".into(), 101.0),
]),
]).into(),
),
],
vec![
@@ -1094,7 +1094,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
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,
@@ -1117,7 +1117,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
},
DailyMarketSnapshot {
date: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 10.1,
open: 10.1,
@@ -1142,31 +1142,31 @@ fn engine_runs_strategy_hooks_in_daily_order() {
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: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 21.0,
free_float_cap_bn: 19.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,
@@ -1179,7 +1179,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
},
CandidateEligibility {
date: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -1337,7 +1337,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
}],
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 09:25:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -1360,18 +1360,18 @@ fn engine_executes_open_auction_decisions_before_on_day() {
}],
vec![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(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -1438,7 +1438,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
}],
vec![DailyMarketSnapshot {
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,
@@ -1461,18 +1461,18 @@ fn engine_executes_futures_order_intents_against_future_account() {
}],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 100.0,
free_float_cap_bn: 80.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,
@@ -1935,7 +1935,7 @@ fn engine_rejects_futures_orders_when_trading_phase_is_closed() {
},
],
vec![market_row(date, "000001.SZ", 10.0, 10.0), future_market],
vec![factor_row(date, "000001.SZ", BTreeMap::new())],
vec![factor_row(date, "000001.SZ", BTreeMap::new().into())],
vec![candidate_row(date, "000001.SZ")],
vec![benchmark_row(date)],
Vec::new(),
@@ -2011,7 +2011,7 @@ fn engine_sweeps_futures_order_book_depth_when_available() {
market_row(date, "000001.SZ", 10.0, 10.0),
market_row(date, "IF2501", 4000.0, 4000.0),
],
vec![factor_row(date, "000001.SZ", BTreeMap::new())],
vec![factor_row(date, "000001.SZ", BTreeMap::new().into())],
vec![candidate_row(date, "000001.SZ")],
vec![benchmark_row(date)],
Vec::new(),
@@ -2154,7 +2154,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
}).collect(),
vec![DailyMarketSnapshot {
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,
@@ -2177,18 +2177,18 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
}, market_row(date, "000002.SZ", 20.0, 20.4)],
vec![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(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -2446,7 +2446,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
.map(
|(date, open, close, prev_close, volume)| DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: open,
open,
@@ -2473,14 +2473,14 @@ fn strategy_context_exposes_engine_native_data_helpers() {
.into_iter()
.map(|date| 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(),
})
.collect::<Vec<_>>();
let candidates = [
@@ -2491,7 +2491,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
.into_iter()
.map(|(date, is_paused, is_st)| CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st,
is_star_st: false,
is_new_listing: false,
@@ -2619,7 +2619,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
}],
vec![DailyMarketSnapshot {
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,
@@ -2642,18 +2642,18 @@ fn strategy_context_exposes_final_order_runtime_view() {
}],
vec![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(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -2838,7 +2838,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
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,
@@ -2861,7 +2861,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
},
DailyMarketSnapshot {
date: date2,
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,
@@ -2886,31 +2886,31 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
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: date2,
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: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -2923,7 +2923,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
},
CandidateEligibility {
date: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -3035,7 +3035,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
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,
@@ -3058,7 +3058,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
},
DailyMarketSnapshot {
date: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 9.7,
open: 9.7,
@@ -3083,31 +3083,31 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
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: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 21.0,
free_float_cap_bn: 19.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,
@@ -3120,7 +3120,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
},
CandidateEligibility {
date: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -3211,7 +3211,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
vec![
DailyMarketSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-30 09:25:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -3234,7 +3234,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
},
DailyMarketSnapshot {
date: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-31 09:25:00".to_string()),
day_open: 10.1,
open: 10.1,
@@ -3257,7 +3257,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
},
DailyMarketSnapshot {
date: date3,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-02-03 09:25:00".to_string()),
day_open: 10.2,
open: 10.2,
@@ -3282,42 +3282,42 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
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: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 21.0,
free_float_cap_bn: 19.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: date3,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 22.0,
free_float_cap_bn: 20.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,
@@ -3330,7 +3330,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
},
CandidateEligibility {
date: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -3343,7 +3343,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
},
CandidateEligibility {
date: date3,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -3469,7 +3469,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
vec![
DailyMarketSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-30 09:25:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -3492,7 +3492,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
},
DailyMarketSnapshot {
date: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-31 09:25:00".to_string()),
day_open: 10.1,
open: 10.1,
@@ -3515,7 +3515,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
},
DailyMarketSnapshot {
date: date3,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-02-03 09:25:00".to_string()),
day_open: 10.2,
open: 10.2,
@@ -3540,42 +3540,42 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
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: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 21.0,
free_float_cap_bn: 19.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: date3,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 22.0,
free_float_cap_bn: 20.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,
@@ -3588,7 +3588,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
},
CandidateEligibility {
date: date2,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -3601,7 +3601,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
},
CandidateEligibility {
date: date3,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -3855,7 +3855,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
[
DailyMarketSnapshot {
date: *date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: 10.0,
open: 10.0,
@@ -3878,7 +3878,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
},
DailyMarketSnapshot {
date: *date,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: 20.0,
open: 20.0,
@@ -3908,25 +3908,25 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
[
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: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: *date,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
market_cap_bn: 12.0,
free_float_cap_bn: 10.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(),
},
]
})
@@ -3937,7 +3937,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
[
CandidateEligibility {
date: *date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -3950,7 +3950,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
},
CandidateEligibility {
date: *date,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -4041,7 +4041,7 @@ fn engine_exposes_current_process_context_to_strategies() {
}],
vec![DailyMarketSnapshot {
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,
@@ -4064,18 +4064,18 @@ fn engine_exposes_current_process_context_to_strategies() {
}],
vec![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(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -4191,8 +4191,8 @@ fn engine_rejects_an_unexplained_missing_holding_close() {
market_row(date2, "000001.SZ", 20.0, 20.2),
],
vec![
factor_row(date1, "601028.SH", BTreeMap::new()),
factor_row(date2, "000001.SZ", BTreeMap::new()),
factor_row(date1, "601028.SH", BTreeMap::new().into()),
factor_row(date2, "000001.SZ", BTreeMap::new().into()),
],
vec![
candidate_row(date1, "601028.SH"),
@@ -4269,8 +4269,8 @@ fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
market_row(date2, "000001.SZ", 20.0, 20.2),
],
vec![
factor_row(date1, "601028.SH", BTreeMap::new()),
factor_row(date2, "000001.SZ", BTreeMap::new()),
factor_row(date1, "601028.SH", BTreeMap::new().into()),
factor_row(date2, "000001.SZ", BTreeMap::new().into()),
],
vec![
candidate_row(date1, "601028.SH"),
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,
@@ -66,7 +66,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 +89,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 +131,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.,
@@ -143,7 +143,7 @@ fn data_with_fund_rules(
})
.collect();
let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote {
date: row.date, symbol: row.symbol.clone(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(),
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()),
@@ -445,11 +445,56 @@ 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 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(),
@@ -516,6 +561,34 @@ fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translat
}
}
#[test]
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
for (ordinary, risk, quote, sold) in [
(Some(0),None,"price<1",0),
(None,Some(0),"price<1",3000),
(Some(0),Some(5000),"price<1",1500),
(Some(0),Some(5000),"price>1",3000),
(None,Some(5000),"",1500),
] {
let exits=ordinary.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::OrdinarySell,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"ordinary fixture".into()})
.chain(risk.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::RiskExit,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"risk fixture".into()})).collect::<Vec<_>>();
let program=StockPoolProgram{schema_version:1,pool_id:"typed-exits".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","sell_trigger_mode":"condition","sell_condition":quote}),
stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),exit_signals:exits};
let mut config=platform_expr_config_from_value("typed-exits","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
config.stock_filter_expr="close>0".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));
config.matching_type=MatchingType::CurrentBarClose;
let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose),BacktestConfig{
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Close,
}).run().unwrap();
assert_eq!(result.fills.iter().filter(|fill|fill.date==day(2)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Buy).map(|fill|fill.quantity).sum::<u32>(),3000,"exit-only criteria must not suppress a new entry: {result:?}");
let sold_quantity=result.fills.iter().filter(|fill|fill.date==day(5)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Sell).map(|fill|fill.quantity).sum::<u32>();
assert_eq!(sold_quantity,sold,"ordinary={ordinary:?} risk={risk:?} quote={quote}: {result:?}");
}
}
#[test]
fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() {
// Generated by OmniQuant's actual handoff and compiler, not a hand-written
@@ -739,6 +812,7 @@ 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"}),
@@ -773,7 +847,7 @@ fn etf_signal_budget_does_not_read_the_current_sessions_future_close() {
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{schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
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();
@@ -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()];
+3 -3
View File
@@ -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,