Compare commits
147 Commits
7f60bfac1d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ea5fae69d | |||
| 214872dfbf | |||
| 2b64fb7c7e | |||
| 438757ab54 | |||
| 67f15f12ca | |||
| 992d0e063c | |||
| 428434d98d | |||
| 20b07ddd7d | |||
| c094e78bef | |||
| 57345e8230 | |||
| d5d67102ac | |||
| 30a4071ee0 | |||
| 942ba84ca5 | |||
| ab3c821e59 | |||
| 1953e92b7b | |||
| 9cc625409f | |||
| 558d92fe23 | |||
| 0aef8f9491 | |||
| e275f4632d | |||
| 5166916926 | |||
| 56859dbe32 | |||
| 1272e427a1 | |||
| e396c895dc | |||
| f7d0889bbc | |||
| 9b84f3a1b9 | |||
| b1520fcca0 | |||
| bb51d91b76 | |||
| 2c43feec3e | |||
| 825de1d886 | |||
| 7397a2d69f | |||
| 7951ba67e3 | |||
| 2fcacb4313 | |||
| 5e480cd69b | |||
| bfbbac8952 | |||
| bb04864436 | |||
| b87e1b4a02 | |||
| 185ed49fe2 | |||
| a30face86a | |||
| 188376b75a | |||
| 6a98d9b0bd | |||
| a562a8e2ed | |||
| 215c4046d1 | |||
| d30c93989c | |||
| 4554f92fb4 | |||
| 556ed9b848 | |||
| 344e7e90c2 | |||
| c64bf16c8b | |||
| ce5ef3b77d | |||
| 8f47ee3679 | |||
| f15f229a09 | |||
| da12cdddd4 | |||
| 0bb47812e5 | |||
| 203e17ce87 | |||
| 32a417d6d1 | |||
| 2de7127f02 | |||
| a5a9688599 | |||
| ad49fc89d3 | |||
| 6329a8a0da | |||
| 69b793cbb8 | |||
| f8f01a0987 | |||
| 4ce52a7af6 | |||
| ca0471799b | |||
| 0febd3d644 | |||
| c085730ca5 | |||
| 538edb907d | |||
| a854a4ec02 | |||
| ec098c6d39 | |||
| 17bac07a86 | |||
| 63da6bb1bd | |||
| e847ecd54c | |||
| 51ee4a3f54 | |||
| 8c4156948e | |||
| 89f8bb32d0 | |||
| e0a7eb8972 | |||
| 12da5a4704 | |||
| 749b5e3b9c | |||
| e74e2226d5 | |||
| 5a1534e51e | |||
| 22451300b1 | |||
| a50e59ab1d | |||
| 6d86eab021 | |||
| 6b306eecf2 | |||
| 90857fae0a | |||
| b37ebb81f1 | |||
| e37b8e1265 | |||
| 3ef4029c4a | |||
| cb189e3de4 | |||
| 4fee8e1d07 | |||
| 64298f09c1 | |||
| 60bfa28ef0 | |||
| 8c5a2ef611 | |||
| db1ffb5918 | |||
| fe39a75e6e | |||
| 1d33b29c27 | |||
| 831edfc8c6 | |||
| c3ab279d7d | |||
| 6820b63d56 | |||
| fdc099c960 | |||
| afb531da59 | |||
| 7c867f1788 | |||
| a3415095a7 | |||
| 3e3bebf3e0 | |||
| 99a21324db | |||
| 92d5801f63 | |||
| 551421818b | |||
| de1373deae | |||
| f9f9706900 | |||
| 74105f0fde | |||
| 72c667d790 | |||
| 82ed5b3e0b | |||
| b489abba6b | |||
| 203a20592a | |||
| 7059d3a8d0 | |||
| 289448d196 | |||
| c557656040 | |||
| 7189998699 | |||
| 00c9042c15 | |||
| d3c986e1f2 | |||
| 2f61bd8e57 | |||
| 61ad4119cf | |||
| ab31006d01 | |||
| 584a38c7a7 | |||
| 1d817c7f50 | |||
| b327bb074e | |||
| 8ed2b0df7f | |||
| a21ac83f21 | |||
| 13e15cc7c4 | |||
| ad6e168303 | |||
| 73627b1b2d | |||
| 1219b42046 | |||
| ba2470aefe | |||
| 8543c3ab6d | |||
| 9a16ceefbb | |||
| 549595c1c6 | |||
| 8125ea2e3b | |||
| 3e4270729b | |||
| 9aa156eb2a | |||
| 4e3ae3b378 | |||
| 339f85c27b | |||
| aff7fa309c | |||
| cb02041b3b | |||
| e8ecc037c9 | |||
| 652531ac63 | |||
| a8ffd36150 | |||
| 143a021067 | |||
| 487e1a38aa | |||
| e70d637ade |
+2119
-73
File diff suppressed because it is too large
Load Diff
+140
-106
@@ -448,49 +448,12 @@ pub struct EligibleUniverseSnapshot {
|
||||
pub free_float_cap_bn: f64,
|
||||
}
|
||||
|
||||
pub fn decision_adjusted_cap_bn(
|
||||
factor_date: NaiveDate,
|
||||
raw_cap_bn: f64,
|
||||
market: &DailyMarketSnapshot,
|
||||
) -> f64 {
|
||||
if !raw_cap_bn.is_finite() || raw_cap_bn <= 0.0 {
|
||||
return f64::NAN;
|
||||
}
|
||||
if factor_date != market.date {
|
||||
return raw_cap_bn;
|
||||
}
|
||||
if !market.close.is_finite()
|
||||
|| market.close <= 0.0
|
||||
|| !market.prev_close.is_finite()
|
||||
|| market.prev_close <= 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
raw_cap_bn * market.prev_close / market.close
|
||||
pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
|
||||
factor.market_cap_bn
|
||||
}
|
||||
|
||||
fn factor_market_cap_is_decision_adjusted(factor: &DailyFactorSnapshot) -> bool {
|
||||
factor
|
||||
.extra_factors
|
||||
.get("__market_cap_decision_adjusted")
|
||||
.is_some_and(|value| value.is_finite() && *value > 0.0)
|
||||
}
|
||||
|
||||
pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot, market: &DailyMarketSnapshot) -> f64 {
|
||||
if factor_market_cap_is_decision_adjusted(factor) {
|
||||
return factor.market_cap_bn;
|
||||
}
|
||||
decision_adjusted_cap_bn(factor.date, factor.market_cap_bn, market)
|
||||
}
|
||||
|
||||
pub fn decision_free_float_cap_bn(
|
||||
factor: &DailyFactorSnapshot,
|
||||
market: &DailyMarketSnapshot,
|
||||
) -> f64 {
|
||||
if factor_market_cap_is_decision_adjusted(factor) {
|
||||
return factor.free_float_cap_bn;
|
||||
}
|
||||
decision_adjusted_cap_bn(factor.date, factor.free_float_cap_bn, market)
|
||||
pub fn decision_free_float_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
|
||||
factor.free_float_cap_bn
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -2493,14 +2456,7 @@ impl DataSet {
|
||||
|
||||
pub fn eligible_universe_on(&self, date: NaiveDate) -> &[EligibleUniverseSnapshot] {
|
||||
self.eligible_universe_by_date
|
||||
.get_or_init(|| {
|
||||
build_eligible_universe(
|
||||
&self.factor_by_date,
|
||||
&self.candidate_by_date,
|
||||
&self.market_by_date,
|
||||
&self.instruments,
|
||||
)
|
||||
})
|
||||
.get_or_init(|| build_eligible_universe(&self.factor_by_date, &self.market_by_date))
|
||||
.get(&date)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
@@ -3033,22 +2989,12 @@ fn build_order_book_depth_index(
|
||||
|
||||
fn build_eligible_universe(
|
||||
factor_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyFactorSnapshot>>>,
|
||||
candidate_by_date: &BTreeMap<NaiveDate, Vec<Arc<CandidateEligibility>>>,
|
||||
market_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyMarketSnapshot>>>,
|
||||
instruments: &HashMap<String, Instrument>,
|
||||
) -> BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>> {
|
||||
let mut per_date = BTreeMap::<NaiveDate, Vec<EligibleUniverseSnapshot>>::new();
|
||||
let risk_config = FidcRiskControlConfig::default();
|
||||
|
||||
for (date, factors) in factor_by_date {
|
||||
let rows = build_eligible_universe_for_date_from_factors(
|
||||
*date,
|
||||
factors,
|
||||
candidate_by_date,
|
||||
market_by_date,
|
||||
instruments,
|
||||
&risk_config,
|
||||
);
|
||||
for date in factor_by_date.keys() {
|
||||
let rows = build_fundamental_universe_for_date(*date, factor_by_date, market_by_date);
|
||||
per_date.insert(*date, rows);
|
||||
}
|
||||
|
||||
@@ -3065,20 +3011,21 @@ fn build_fundamental_universe_for_date(
|
||||
return rows;
|
||||
};
|
||||
for factor in factors {
|
||||
let Some(market) = market_by_date
|
||||
if market_by_date
|
||||
.get(&date)
|
||||
.and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()))
|
||||
else {
|
||||
.is_none()
|
||||
{
|
||||
continue;
|
||||
};
|
||||
let market_cap_bn = decision_market_cap_bn(factor, market);
|
||||
}
|
||||
let market_cap_bn = decision_market_cap_bn(factor);
|
||||
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
||||
continue;
|
||||
}
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
symbol: factor.symbol.clone(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn: decision_free_float_cap_bn(factor, market),
|
||||
free_float_cap_bn: decision_free_float_cap_bn(factor),
|
||||
});
|
||||
}
|
||||
rows.sort_by(|left, right| {
|
||||
@@ -3126,11 +3073,15 @@ fn build_eligible_universe_for_date_from_factors(
|
||||
if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let Some(candidate) = candidate_by_date
|
||||
let synthetic_candidate;
|
||||
let candidate = if let Some(candidate) = candidate_by_date
|
||||
.get(&date)
|
||||
.and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()))
|
||||
else {
|
||||
continue;
|
||||
{
|
||||
candidate
|
||||
} else {
|
||||
synthetic_candidate = missing_candidate_risk_state(date, &factor.symbol);
|
||||
&synthetic_candidate
|
||||
};
|
||||
let Some(market) = market_by_date
|
||||
.get(&date)
|
||||
@@ -3149,11 +3100,11 @@ fn build_eligible_universe_for_date_from_factors(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let market_cap_bn = decision_market_cap_bn(factor, market);
|
||||
let market_cap_bn = decision_market_cap_bn(factor);
|
||||
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let free_float_cap_bn = decision_free_float_cap_bn(factor, market);
|
||||
let free_float_cap_bn = decision_free_float_cap_bn(factor);
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
symbol: factor.symbol.clone(),
|
||||
market_cap_bn,
|
||||
@@ -3169,6 +3120,25 @@ fn build_eligible_universe_for_date_from_factors(
|
||||
rows
|
||||
}
|
||||
|
||||
pub(crate) fn missing_candidate_risk_state(date: NaiveDate, symbol: &str) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: Some(
|
||||
"missing_risk_state:is_st,is_star_st,is_paused,listed_days,is_kcb,is_one_yuan"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn instrument_passes_baseline_selection(instrument: Option<&Instrument>, date: NaiveDate) -> bool {
|
||||
ChinaAShareRiskControl::instrument_rejection_reason(instrument, date).is_none()
|
||||
@@ -3438,11 +3408,95 @@ mod tests {
|
||||
|
||||
let rows = data.eligible_universe_on(date);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].symbol, "000001.SZ");
|
||||
assert!((rows[0].market_cap_bn - 6.0).abs() < 1e-9);
|
||||
assert!((rows[0].free_float_cap_bn - 2.0).abs() < 1e-9);
|
||||
assert_eq!(rows[1].symbol, "000002.SZ");
|
||||
assert!((rows[1].market_cap_bn - 10.0).abs() < 1e-9);
|
||||
assert_eq!(rows[0].symbol, "000002.SZ");
|
||||
assert!((rows[0].market_cap_bn - 10.0).abs() < 1e-9);
|
||||
assert_eq!(rows[1].symbol, "000001.SZ");
|
||||
assert!((rows[1].market_cap_bn - 12.0).abs() < 1e-9);
|
||||
assert!((rows[1].free_float_cap_bn - 4.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eligible_universe_does_not_require_candidate_risk_state_when_selection_risk_is_disabled() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
let symbol = "000001.SZ";
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.2,
|
||||
low: 9.8,
|
||||
close: 10.1,
|
||||
last_price: 10.1,
|
||||
bid1: 10.0,
|
||||
ask1: 10.1,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 1_000,
|
||||
ask1_volume: 1_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
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),
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 101.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
assert_eq!(
|
||||
data.eligible_universe_on(date)
|
||||
.iter()
|
||||
.map(|row| row.symbol.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![symbol]
|
||||
);
|
||||
assert_eq!(
|
||||
data.eligible_universe_on_with_risk_config(date, &FidcRiskControlConfig::default())
|
||||
.iter()
|
||||
.map(|row| row.symbol.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![symbol],
|
||||
"execution-risk defaults must not make selection depend on candidate risk facts"
|
||||
);
|
||||
|
||||
let mut selection_risk_config = FidcRiskControlConfig::default();
|
||||
selection_risk_config.static_rules.reject_st_selection = true;
|
||||
assert!(
|
||||
data.eligible_universe_on_with_risk_config(date, &selection_risk_config)
|
||||
.is_empty(),
|
||||
"explicit selection risk must reject when required candidate facts are missing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3516,8 +3570,13 @@ mod tests {
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
assert!(data.eligible_universe_on(date).is_empty());
|
||||
assert_eq!(data.eligible_universe_on(date).len(), 1);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
assert!(
|
||||
data.eligible_universe_on_with_risk_config(date, &risk_config)
|
||||
.is_empty()
|
||||
);
|
||||
risk_config.static_rules.reject_kcb_selection = false;
|
||||
let rows = data.eligible_universe_on_with_risk_config(date, &risk_config);
|
||||
|
||||
@@ -3526,33 +3585,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_market_cap_keeps_pre_adjusted_factor() {
|
||||
fn decision_market_cap_uses_factor_date_snapshot_without_price_reconstruction() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
let market = DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 20.0,
|
||||
low: 10.0,
|
||||
close: 20.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 1_000,
|
||||
ask1_volume: 1_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
};
|
||||
let mut extra_factors = BTreeMap::new();
|
||||
extra_factors.insert("__market_cap_decision_adjusted".to_string(), 1.0);
|
||||
let factor = DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
@@ -3561,11 +3595,11 @@ mod tests {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors,
|
||||
extra_factors: BTreeMap::new(),
|
||||
};
|
||||
|
||||
assert!((decision_market_cap_bn(&factor, &market) - 12.0).abs() < 1e-9);
|
||||
assert!((decision_free_float_cap_bn(&factor, &market) - 4.0).abs() < 1e-9);
|
||||
assert!((decision_market_cap_bn(&factor) - 12.0).abs() < 1e-9);
|
||||
assert!((decision_free_float_cap_bn(&factor) - 4.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+369
-24
@@ -1192,6 +1192,9 @@ where
|
||||
});
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: Some(order_id),
|
||||
symbol: self
|
||||
.futures_open_orders
|
||||
@@ -1229,6 +1232,9 @@ where
|
||||
let mut report = FuturesExecutionReport::default();
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: Some(order_id),
|
||||
symbol: intent.symbol.clone(),
|
||||
side,
|
||||
@@ -1646,7 +1652,7 @@ where
|
||||
let mut portfolio = PortfolioState::new(self.config.initial_cash);
|
||||
let scheduler_calendar = self.data.calendar().clone();
|
||||
let scheduler = Scheduler::new(&scheduler_calendar);
|
||||
let execution_dates = self
|
||||
let calendar_dates = self
|
||||
.data
|
||||
.calendar()
|
||||
.iter()
|
||||
@@ -1657,11 +1663,41 @@ where
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.filter(|date| self.config.end_date.map(|end| *date <= end).unwrap_or(true))
|
||||
.filter(|date| {
|
||||
!self.data.factor_snapshots_on(*date).is_empty()
|
||||
&& !self.data.candidate_snapshots_on(*date).is_empty()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let has_decision_inputs = |date: NaiveDate| {
|
||||
!self.data.factor_snapshots_on(date).is_empty()
|
||||
&& !self.data.candidate_snapshots_on(date).is_empty()
|
||||
};
|
||||
let has_execution_market =
|
||||
|date: NaiveDate| !self.data.market_snapshots_on(date).is_empty();
|
||||
let mut execution_dates = Vec::new();
|
||||
let mut decision_slots = Vec::new();
|
||||
for (calendar_idx, execution_date) in calendar_dates.iter().copied().enumerate() {
|
||||
if self.config.decision_lag_trading_days == 0 {
|
||||
if has_decision_inputs(execution_date) {
|
||||
execution_dates.push(execution_date);
|
||||
decision_slots.push(Some((calendar_idx, execution_date)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !has_execution_market(execution_date) {
|
||||
continue;
|
||||
}
|
||||
let decision_slot = calendar_idx
|
||||
.checked_sub(self.config.decision_lag_trading_days)
|
||||
.map(|decision_idx| (decision_idx, calendar_dates[decision_idx]));
|
||||
match decision_slot {
|
||||
Some((_, decision_date)) if has_decision_inputs(decision_date) => {
|
||||
execution_dates.push(execution_date);
|
||||
decision_slots.push(decision_slot);
|
||||
}
|
||||
None => {
|
||||
execution_dates.push(execution_date);
|
||||
decision_slots.push(None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut result = BacktestResult {
|
||||
strategy_name: self.strategy.name().to_string(),
|
||||
benchmark_series: self
|
||||
@@ -1704,31 +1740,54 @@ where
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
);
|
||||
self.extend_result(&mut result, pending_cash_flow_report);
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
pending_cash_flow_report,
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
let corporate_action_report = self.apply_corporate_actions(
|
||||
execution_date,
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
)?;
|
||||
self.extend_result(&mut result, corporate_action_report);
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
corporate_action_report,
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
let receivable_report = self.settle_cash_receivables(
|
||||
execution_date,
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
)?;
|
||||
self.extend_result(&mut result, receivable_report);
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
receivable_report,
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
let delisting_report = self.settle_delisted_positions(
|
||||
execution_date,
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
)?;
|
||||
self.extend_result(&mut result, delisting_report);
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
delisting_report,
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
let futures_open_order_report = self.process_futures_open_orders(execution_date);
|
||||
self.extend_result(&mut result, futures_open_order_report);
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
futures_open_order_report,
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
|
||||
let decision_slot = execution_idx
|
||||
.checked_sub(self.config.decision_lag_trading_days)
|
||||
.map(|decision_idx| (decision_idx, execution_dates[decision_idx]));
|
||||
let decision_slot = decision_slots.get(execution_idx).copied().flatten();
|
||||
let Some((decision_index, decision_date)) = decision_slot else {
|
||||
let mut process_events = Vec::new();
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
@@ -1750,7 +1809,7 @@ where
|
||||
let day_fills = report.fill_events.clone();
|
||||
let broker_diagnostics = report.diagnostics.clone();
|
||||
let execution_risk_decisions = risk_decisions_from_order_events(&day_orders);
|
||||
self.extend_result(&mut result, report);
|
||||
self.extend_result(&mut result, report, execution_date, execution_date);
|
||||
result.risk_decisions.extend(execution_risk_decisions);
|
||||
|
||||
let benchmark =
|
||||
@@ -2014,8 +2073,10 @@ where
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
let mut report = self.broker.execute(
|
||||
let mut report = self.broker.execute_with_event_dates(
|
||||
execution_date,
|
||||
decision_date,
|
||||
decision_date,
|
||||
&mut portfolio,
|
||||
&self.data,
|
||||
&auction_decision,
|
||||
@@ -2260,9 +2321,14 @@ where
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
let mut intraday_report =
|
||||
self.broker
|
||||
.execute(execution_date, &mut portfolio, &self.data, &decision)?;
|
||||
let mut intraday_report = self.broker.execute_with_event_dates(
|
||||
execution_date,
|
||||
decision_date,
|
||||
decision_date,
|
||||
&mut portfolio,
|
||||
&self.data,
|
||||
&decision,
|
||||
)?;
|
||||
let post_intraday_open_orders = self.open_order_views();
|
||||
publish_process_events(
|
||||
&mut self.strategy,
|
||||
@@ -2426,8 +2492,10 @@ where
|
||||
Some(minute_time),
|
||||
Some(minute_time),
|
||||
)?;
|
||||
let mut minute_report = self.broker.execute_between(
|
||||
let mut minute_report = self.broker.execute_between_with_event_dates(
|
||||
execution_date,
|
||||
decision_date,
|
||||
decision_date,
|
||||
&mut portfolio,
|
||||
&self.data,
|
||||
&minute_decision,
|
||||
@@ -2750,7 +2818,7 @@ where
|
||||
let day_fills = report.fill_events.clone();
|
||||
let broker_diagnostics = report.diagnostics.clone();
|
||||
let execution_risk_decisions = risk_decisions_from_order_events(&day_orders);
|
||||
self.extend_result(&mut result, report);
|
||||
self.extend_result(&mut result, report, decision_date, execution_date);
|
||||
result.risk_decisions.extend(decision.risk_decisions);
|
||||
result.risk_decisions.extend(execution_risk_decisions);
|
||||
|
||||
@@ -2838,8 +2906,11 @@ where
|
||||
fn extend_result(
|
||||
&self,
|
||||
result: &mut BacktestResult,
|
||||
report: BrokerExecutionReport,
|
||||
mut report: BrokerExecutionReport,
|
||||
decision_date: NaiveDate,
|
||||
execution_date: NaiveDate,
|
||||
) -> BrokerExecutionReport {
|
||||
annotate_broker_report_dates(&mut report, decision_date, decision_date, execution_date);
|
||||
result.order_events.extend(report.order_events.clone());
|
||||
result.fills.extend(report.fill_events.clone());
|
||||
result
|
||||
@@ -3063,6 +3134,9 @@ where
|
||||
);
|
||||
report.fill_events.push(FillEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: None,
|
||||
symbol: receivable.symbol.clone(),
|
||||
side: OrderSide::Buy,
|
||||
@@ -3366,6 +3440,9 @@ where
|
||||
notes.push(reason.clone());
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: None,
|
||||
symbol: symbol.clone(),
|
||||
side: OrderSide::Sell,
|
||||
@@ -3376,6 +3453,9 @@ where
|
||||
});
|
||||
report.fill_events.push(FillEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: None,
|
||||
symbol: symbol.clone(),
|
||||
side: OrderSide::Sell,
|
||||
@@ -3827,6 +3907,24 @@ fn merge_futures_report(target: &mut BrokerExecutionReport, incoming: FuturesExe
|
||||
target.diagnostics.extend(incoming.diagnostics);
|
||||
}
|
||||
|
||||
fn annotate_broker_report_dates(
|
||||
report: &mut BrokerExecutionReport,
|
||||
decision_date: NaiveDate,
|
||||
order_created_date: NaiveDate,
|
||||
execution_date: NaiveDate,
|
||||
) {
|
||||
for event in &mut report.order_events {
|
||||
event.decision_date.get_or_insert(decision_date);
|
||||
event.order_created_date.get_or_insert(order_created_date);
|
||||
event.execution_date.get_or_insert(execution_date);
|
||||
}
|
||||
for fill in &mut report.fill_events {
|
||||
fill.decision_date.get_or_insert(decision_date);
|
||||
fill.order_created_date.get_or_insert(order_created_date);
|
||||
fill.execution_date.get_or_insert(execution_date);
|
||||
}
|
||||
}
|
||||
|
||||
fn risk_decisions_from_order_events(order_events: &[OrderEvent]) -> Vec<FidcRiskDecisionAudit> {
|
||||
order_events
|
||||
.iter()
|
||||
@@ -4021,6 +4119,9 @@ fn futures_cancel_report(
|
||||
});
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: Some(order.order_id),
|
||||
symbol: order.intent.symbol.clone(),
|
||||
side,
|
||||
@@ -4150,6 +4251,78 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScheduledBuyOnDecisionDateStrategy {
|
||||
rule: ScheduleRule,
|
||||
decision_date: NaiveDate,
|
||||
}
|
||||
|
||||
impl Strategy for ScheduledBuyOnDecisionDateStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"scheduled_buy_on_decision_date"
|
||||
}
|
||||
|
||||
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||
vec![self.rule.clone()]
|
||||
}
|
||||
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
rule: &ScheduleRule,
|
||||
) -> Result<StrategyDecision, super::BacktestError> {
|
||||
assert_eq!(rule.name, self.rule.name);
|
||||
if ctx.decision_date != self.decision_date {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
quantity: 100,
|
||||
reason: "scheduled_decision_date_buy".to_string(),
|
||||
}],
|
||||
..StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScheduledTargetPortfolioSmartStrategy {
|
||||
rule: ScheduleRule,
|
||||
decision_date: NaiveDate,
|
||||
target_weights: BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
impl Strategy for ScheduledTargetPortfolioSmartStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"scheduled_target_portfolio_smart"
|
||||
}
|
||||
|
||||
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||
vec![self.rule.clone()]
|
||||
}
|
||||
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
rule: &ScheduleRule,
|
||||
) -> Result<StrategyDecision, super::BacktestError> {
|
||||
assert_eq!(rule.name, self.rule.name);
|
||||
if ctx.decision_date != self.decision_date {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::TargetPortfolioSmart {
|
||||
target_weights: self.target_weights.clone(),
|
||||
order_prices: None,
|
||||
valuation_prices: None,
|
||||
reason: "scheduled_target_portfolio_smart".to_string(),
|
||||
}],
|
||||
..StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScheduledEligibleUniverseBuyStrategy {
|
||||
rule: ScheduleRule,
|
||||
@@ -4779,9 +4952,103 @@ mod tests {
|
||||
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].date, d(2025, 1, 3));
|
||||
assert_eq!(result.fills[0].decision_date, Some(d(2025, 1, 2)));
|
||||
assert_eq!(result.fills[0].order_created_date, Some(d(2025, 1, 2)));
|
||||
assert_eq!(result.fills[0].execution_date, Some(d(2025, 1, 3)));
|
||||
assert_eq!(result.fills[0].price, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_target_portfolio_smart_sizes_with_execution_day_open() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let dataset = DataSet::from_components(
|
||||
vec![default_instrument()],
|
||||
vec![market(first, 10.0, 10.0), market(second, 12.0, 12.0)],
|
||||
vec![factor(first), factor(second)],
|
||||
vec![candidate(first), candidate(second)],
|
||||
vec![benchmark(first), benchmark(second)],
|
||||
)
|
||||
.expect("dataset");
|
||||
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default());
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 100_000.0,
|
||||
benchmark_code: "000852.SH".to_string(),
|
||||
start_date: Some(first),
|
||||
end_date: Some(second),
|
||||
decision_lag_trading_days: 1,
|
||||
execution_price_field: PriceField::Open,
|
||||
};
|
||||
let mut target_weights = BTreeMap::new();
|
||||
target_weights.insert(SYMBOL.to_string(), 1.0);
|
||||
|
||||
let result = BacktestEngine::new(
|
||||
dataset,
|
||||
ScheduledTargetPortfolioSmartStrategy {
|
||||
rule: ScheduleRule::daily("daily_target_portfolio", ScheduleStage::OnDay),
|
||||
decision_date: first,
|
||||
target_weights,
|
||||
},
|
||||
broker,
|
||||
config,
|
||||
)
|
||||
.run()
|
||||
.expect("backtest run");
|
||||
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].date, second);
|
||||
assert_eq!(result.fills[0].decision_date, Some(first));
|
||||
assert_eq!(result.fills[0].execution_date, Some(second));
|
||||
assert_eq!(result.fills[0].price, 12.0);
|
||||
assert_eq!(result.fills[0].quantity, 8_300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let third = d(2025, 1, 6);
|
||||
let dataset = DataSet::from_components(
|
||||
vec![default_instrument()],
|
||||
vec![
|
||||
market(first, 10.0, 11.5),
|
||||
market(second, 12.0, 13.0),
|
||||
market(third, 14.0, 15.0),
|
||||
],
|
||||
vec![factor(first), factor(second)],
|
||||
vec![candidate(first), candidate(second), candidate(third)],
|
||||
vec![benchmark(first), benchmark(second), benchmark(third)],
|
||||
)
|
||||
.expect("dataset");
|
||||
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default());
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 100_000.0,
|
||||
benchmark_code: "000852.SH".to_string(),
|
||||
start_date: Some(first),
|
||||
end_date: Some(third),
|
||||
decision_lag_trading_days: 1,
|
||||
execution_price_field: PriceField::Open,
|
||||
};
|
||||
|
||||
let result = BacktestEngine::new(
|
||||
dataset,
|
||||
ScheduledBuyOnDecisionDateStrategy {
|
||||
rule: ScheduleRule::daily("daily_signal", ScheduleStage::OnDay),
|
||||
decision_date: second,
|
||||
},
|
||||
broker,
|
||||
config,
|
||||
)
|
||||
.run()
|
||||
.expect("backtest run");
|
||||
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].date, third);
|
||||
assert_eq!(result.fills[0].decision_date, Some(second));
|
||||
assert_eq!(result.fills[0].execution_date, Some(third));
|
||||
assert_eq!(result.fills[0].price, 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_strategy_context_data_helpers_use_decision_date() {
|
||||
let first = d(2025, 1, 2);
|
||||
@@ -4817,9 +5084,22 @@ mod tests {
|
||||
.map(|snapshot| snapshot.close),
|
||||
Some(11.5)
|
||||
);
|
||||
assert_eq!(
|
||||
dataset
|
||||
.eligible_universe_on(first)
|
||||
.iter()
|
||||
.map(|row| row.symbol.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![SYMBOL.to_string()],
|
||||
"raw DataSet helper should not apply default selection risk"
|
||||
);
|
||||
let mut selection_risk_config = FidcRiskControlConfig::default();
|
||||
selection_risk_config.static_rules.reject_paused_selection = true;
|
||||
assert!(
|
||||
dataset.eligible_universe_on(first).is_empty(),
|
||||
"raw DataSet helper remains a risk-filtered universe"
|
||||
dataset
|
||||
.eligible_universe_on_with_risk_config(first, &selection_risk_config)
|
||||
.is_empty(),
|
||||
"explicit raw DataSet selection risk can still filter the universe"
|
||||
);
|
||||
assert_eq!(
|
||||
manual_ctx
|
||||
@@ -4832,7 +5112,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
manual_ctx
|
||||
.eligible_universe_on_with_risk_config(first, &FidcRiskControlConfig::default())
|
||||
.eligible_universe_on_with_risk_config(first, &selection_risk_config)
|
||||
.into_iter()
|
||||
.map(|row| row.symbol)
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -5025,6 +5305,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_execution_risk_uses_open_not_close_for_upper_limit_buy() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
||||
market(first, 10.0, 11.5),
|
||||
market_with_state(second, 11.8, 12.0, false, 12.0, 1.0),
|
||||
candidate(first),
|
||||
candidate(second),
|
||||
));
|
||||
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].date, second);
|
||||
assert_eq!(result.fills[0].price, 11.8);
|
||||
assert!(
|
||||
result
|
||||
.order_events
|
||||
.iter()
|
||||
.all(|event| !event.reason.contains("upper limit")),
|
||||
"{:?}",
|
||||
result.order_events
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_execution_risk_rejects_execution_day_st_state() {
|
||||
let first = d(2025, 1, 2);
|
||||
@@ -5159,6 +5463,47 @@ mod tests {
|
||||
assert_round_trip_sell_canceled_with_reason(&result, "open at or below lower limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_sell_risk_uses_open_not_close_for_lower_limit_sell() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let third = d(2025, 1, 6);
|
||||
let fourth = d(2025, 1, 7);
|
||||
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
||||
dataset_from_market_and_candidates(
|
||||
vec![
|
||||
market(first, 10.0, 10.5),
|
||||
market(second, 11.0, 11.5),
|
||||
market(third, 12.0, 12.5),
|
||||
market_with_state(fourth, 9.2, 9.0, false, 20.0, 9.0),
|
||||
],
|
||||
vec![
|
||||
candidate(first),
|
||||
candidate(second),
|
||||
candidate(third),
|
||||
candidate(fourth),
|
||||
],
|
||||
),
|
||||
scheduled_next_open_broker(FidcRiskControlConfig::default()),
|
||||
);
|
||||
|
||||
let sell_fill = result
|
||||
.fills
|
||||
.iter()
|
||||
.find(|fill| fill.side == OrderSide::Sell)
|
||||
.expect("sell should execute when next-open is above lower limit");
|
||||
assert_eq!(sell_fill.date, fourth);
|
||||
assert_eq!(sell_fill.price, 9.2);
|
||||
assert!(
|
||||
result
|
||||
.order_events
|
||||
.iter()
|
||||
.all(|event| !event.reason.contains("lower limit")),
|
||||
"{:?}",
|
||||
result.order_events
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_sell_respects_allow_sell_policy_on_execution_day() {
|
||||
let first = d(2025, 1, 2);
|
||||
|
||||
@@ -23,6 +23,33 @@ mod date_format {
|
||||
}
|
||||
}
|
||||
|
||||
mod optional_date_format {
|
||||
use chrono::NaiveDate;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
const FORMAT: &str = "%Y-%m-%d";
|
||||
|
||||
pub fn serialize<S>(date: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match date {
|
||||
Some(date) => serializer.serialize_some(&date.format(FORMAT).to_string()),
|
||||
None => serializer.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
value
|
||||
.map(|text| NaiveDate::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum OrderSide {
|
||||
Buy,
|
||||
@@ -63,6 +90,12 @@ impl OrderStatus {
|
||||
pub struct OrderEvent {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub decision_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub order_created_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub execution_date: Option<NaiveDate>,
|
||||
#[serde(default)]
|
||||
pub order_id: Option<u64>,
|
||||
pub symbol: String,
|
||||
@@ -77,6 +110,12 @@ pub struct OrderEvent {
|
||||
pub struct FillEvent {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub decision_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub order_created_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub execution_date: Option<NaiveDate>,
|
||||
#[serde(default)]
|
||||
pub order_id: Option<u64>,
|
||||
pub symbol: String,
|
||||
|
||||
@@ -746,6 +746,9 @@ impl FuturesAccountState {
|
||||
);
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
@@ -823,6 +826,9 @@ impl FuturesAccountState {
|
||||
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier;
|
||||
report.fill_events.push(FillEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol.clone(),
|
||||
side,
|
||||
@@ -889,6 +895,9 @@ impl FuturesAccountState {
|
||||
});
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
@@ -915,6 +924,9 @@ impl FuturesAccountState {
|
||||
);
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
|
||||
@@ -20,7 +20,8 @@ pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
|
||||
pub use broker::{
|
||||
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, SlippageModel,
|
||||
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, RebalanceCashMode,
|
||||
SlippageModel,
|
||||
};
|
||||
pub use calendar::TradingCalendar;
|
||||
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
|
||||
@@ -84,9 +85,10 @@ pub use strategy::{
|
||||
};
|
||||
pub use strategy_ai::{
|
||||
ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction,
|
||||
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiManual,
|
||||
StrategyAiOptimizeRequest, build_generation_prompt, build_optimization_prompt,
|
||||
built_in_strategy_manual, merge_catalog_into_manual, render_manual_markdown,
|
||||
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiHoldingCountContract,
|
||||
StrategyAiManual, StrategyAiOptimizeRequest, build_generation_prompt,
|
||||
build_optimization_prompt, built_in_strategy_manual, merge_catalog_into_manual,
|
||||
render_manual_markdown,
|
||||
};
|
||||
pub use universe::{
|
||||
BandRegime, DynamicMarketCapBandSelector, SelectionContext, SelectionDiagnostics,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -140,6 +140,7 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
|
||||
"ask1_volume",
|
||||
"turnover_ratio",
|
||||
"effective_turnover_ratio",
|
||||
"up_days_stock",
|
||||
"open",
|
||||
"high",
|
||||
"low",
|
||||
@@ -156,6 +157,7 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
|
||||
"is_st",
|
||||
"is_star_st",
|
||||
"is_kcb",
|
||||
"is_bjse",
|
||||
"is_one_yuan",
|
||||
"is_new_listing",
|
||||
"allow_buy",
|
||||
@@ -328,6 +330,7 @@ mod tests {
|
||||
"avg_cost",
|
||||
"current_price",
|
||||
"stock_ma_short",
|
||||
"up_days_stock",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(required),
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
|
||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
|
||||
PlatformUniverseActionKind, ScheduleTimeRule, SlippageModel,
|
||||
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -105,6 +105,10 @@ pub struct StrategyExecutionSpec {
|
||||
pub risk_policy: Option<StrategyRiskPolicySpec>,
|
||||
#[serde(default, alias = "strict_value_budget")]
|
||||
pub strict_value_budget: Option<bool>,
|
||||
#[serde(default, alias = "rebalance_cash_mode")]
|
||||
pub rebalance_cash_mode: Option<String>,
|
||||
#[serde(default, alias = "sell_then_buy_delay_slippage_rate")]
|
||||
pub sell_then_buy_delay_slippage_rate: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -114,6 +118,8 @@ pub struct StrategyEngineConfig {
|
||||
pub template_id: Option<String>,
|
||||
#[serde(default, alias = "profile_name")]
|
||||
pub profile_name: Option<String>,
|
||||
#[serde(default, alias = "compatibility_profile")]
|
||||
pub compatibility_profile: Option<String>,
|
||||
#[serde(default, alias = "benchmark_symbol")]
|
||||
pub benchmark_symbol: Option<String>,
|
||||
#[serde(default, alias = "signal_symbol")]
|
||||
@@ -173,10 +179,22 @@ pub struct StrategyEngineConfig {
|
||||
pub risk_policy: Option<StrategyRiskPolicySpec>,
|
||||
#[serde(default, alias = "strict_value_budget")]
|
||||
pub strict_value_budget: Option<bool>,
|
||||
#[serde(default, alias = "rebalance_cash_mode")]
|
||||
pub rebalance_cash_mode: Option<String>,
|
||||
#[serde(default, alias = "sell_then_buy_delay_slippage_rate")]
|
||||
pub sell_then_buy_delay_slippage_rate: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub dividend_reinvestment: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub weak_market_shrink_overweight_threshold: Option<f64>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "max_hold_days",
|
||||
alias = "max_holding_days",
|
||||
alias = "maxHoldDays",
|
||||
alias = "maxHoldingDays"
|
||||
)]
|
||||
pub max_holding_days: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub rebalance_schedule: Option<StrategyExpressionScheduleConfig>,
|
||||
#[serde(default)]
|
||||
@@ -236,6 +254,10 @@ pub struct StrategyRiskPolicySpec {
|
||||
pub forbid_same_day_rebuy_after_sell: Option<bool>,
|
||||
#[serde(default, alias = "blacklist_enabled")]
|
||||
pub blacklist_enabled: Option<bool>,
|
||||
#[serde(default, alias = "allow_market_orders")]
|
||||
pub allow_market_orders: Option<bool>,
|
||||
#[serde(default, alias = "live_trading_enabled")]
|
||||
pub live_trading_enabled: Option<bool>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "blacklisted_symbols",
|
||||
@@ -307,6 +329,8 @@ const RISK_POLICY_BOOL_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||
&["forbid_same_day_rebuy_after_sell"],
|
||||
),
|
||||
("blacklistEnabled", &["blacklist_enabled"]),
|
||||
("allowMarketOrders", &["allow_market_orders"]),
|
||||
("liveTradingEnabled", &["live_trading_enabled"]),
|
||||
("volumeLimitEnabled", &["volume_limit_enabled"]),
|
||||
("liquidityLimitEnabled", &["liquidity_limit_enabled"]),
|
||||
];
|
||||
@@ -364,6 +388,55 @@ fn parse_policy_bool(canonical: &str, value: Value) -> Result<bool, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn policy_value_semantically_equals(canonical: &str, left: &Value, right: &Value) -> bool {
|
||||
if left == right {
|
||||
return true;
|
||||
}
|
||||
if let (Some(left_number), Some(right_number)) =
|
||||
(policy_value_as_number(left), policy_value_as_number(right))
|
||||
{
|
||||
if canonical == "volumePercent" {
|
||||
return (normalize_percent_ratio_for_alias_compare(left_number)
|
||||
- normalize_percent_ratio_for_alias_compare(right_number))
|
||||
.abs()
|
||||
< 1e-12;
|
||||
}
|
||||
return (left_number - right_number).abs() < 1e-12;
|
||||
}
|
||||
if let (Some(left_text), Some(right_text)) = (left.as_str(), right.as_str()) {
|
||||
return left_text.trim() == right_text.trim();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn normalize_percent_ratio_for_alias_compare(value: f64) -> f64 {
|
||||
if value > 1.0 { value / 100.0 } else { value }
|
||||
}
|
||||
|
||||
fn policy_value_as_number(value: &Value) -> Option<f64> {
|
||||
match value {
|
||||
Value::Number(number) => number.as_f64(),
|
||||
Value::String(text) => text.trim().parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_conflicting_policy_values(canonical: &str, values: &[Value]) -> Result<(), String> {
|
||||
let Some(first) = values.first() else {
|
||||
return Ok(());
|
||||
};
|
||||
if values
|
||||
.iter()
|
||||
.skip(1)
|
||||
.any(|value| !policy_value_semantically_equals(canonical, first, value))
|
||||
{
|
||||
return Err(format!(
|
||||
"riskPolicy.{canonical} has conflicting alias values"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_blacklist_symbols_from_str(
|
||||
raw: &str,
|
||||
symbols: &mut Vec<Value>,
|
||||
@@ -384,17 +457,25 @@ fn normalize_risk_policy_object_aliases(
|
||||
for (canonical, aliases) in RISK_POLICY_BOOL_ALIAS_GROUPS {
|
||||
let values = remove_policy_alias_values(object, canonical, aliases);
|
||||
if !values.is_empty() {
|
||||
let mut merged = false;
|
||||
let mut parsed_values = Vec::with_capacity(values.len());
|
||||
for value in values {
|
||||
merged |= parse_policy_bool(canonical, value)?;
|
||||
parsed_values.push(parse_policy_bool(canonical, value)?);
|
||||
}
|
||||
object.insert((*canonical).to_string(), Value::Bool(merged));
|
||||
let first = parsed_values[0];
|
||||
if parsed_values.iter().any(|value| *value != first) {
|
||||
return Err(format!(
|
||||
"riskPolicy.{canonical} has conflicting alias values"
|
||||
));
|
||||
}
|
||||
object.insert((*canonical).to_string(), Value::Bool(first));
|
||||
}
|
||||
}
|
||||
|
||||
for (canonical, aliases) in RISK_POLICY_VALUE_ALIAS_GROUPS {
|
||||
let values = remove_policy_alias_values(object, canonical, aliases);
|
||||
if let Some(value) = values.into_iter().next() {
|
||||
if !values.is_empty() {
|
||||
reject_conflicting_policy_values(canonical, &values)?;
|
||||
let value = values.into_iter().next().expect("non-empty values");
|
||||
object.insert((*canonical).to_string(), value);
|
||||
}
|
||||
}
|
||||
@@ -610,9 +691,23 @@ pub struct StrategyExpressionTradingConfig {
|
||||
#[serde(default)]
|
||||
pub daily_top_up: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub daily_position_target_adjust: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub rebalance_existing_positions: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub selection_buffer_multiple: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub retry_empty_rebalance: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub weak_market_shrink_overweight_threshold: Option<f64>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "max_hold_days",
|
||||
alias = "max_holding_days",
|
||||
alias = "maxHoldDays",
|
||||
alias = "maxHoldingDays"
|
||||
)]
|
||||
pub max_holding_days: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub delayed_limit_open_exit: Option<bool>,
|
||||
#[serde(default)]
|
||||
@@ -789,12 +884,15 @@ fn apply_flat_risk_overrides(
|
||||
let value = normalize_percent_ratio(raw_value, "volume_percent")?;
|
||||
cfg.risk_config.trading_constraints.volume_percent = value;
|
||||
}
|
||||
cfg.quote_quantity_limit = cfg.risk_config.trading_constraints.volume_limit_enabled
|
||||
|| cfg.risk_config.trading_constraints.liquidity_limit_enabled
|
||||
|| cfg.matching_type != MatchingType::MinuteLast;
|
||||
sync_quote_quantity_limit(cfg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_quote_quantity_limit(cfg: &mut PlatformExprStrategyConfig) {
|
||||
cfg.quote_quantity_limit = cfg.risk_config.trading_constraints.liquidity_limit_enabled
|
||||
|| cfg.matching_type != MatchingType::MinuteLast;
|
||||
}
|
||||
|
||||
fn apply_risk_policy_overrides(
|
||||
cfg: &mut PlatformExprStrategyConfig,
|
||||
policy: Option<&StrategyRiskPolicySpec>,
|
||||
@@ -909,6 +1007,22 @@ fn normalize_model_name(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase().replace('-', "_")
|
||||
}
|
||||
|
||||
fn normalize_slippage_model_name(value: &str) -> String {
|
||||
match normalize_model_name(value).as_str() {
|
||||
"percent"
|
||||
| "percentage"
|
||||
| "rate"
|
||||
| "ratio"
|
||||
| "price_percent"
|
||||
| "price_percentage"
|
||||
| "price_rate"
|
||||
| "price_ratio_slippage"
|
||||
| "priceratioslippage" => "price_ratio".to_string(),
|
||||
"dynamic_volume_volatility" => "dynamic".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_matching_type(value: Option<&str>) -> Result<Option<MatchingType>, String> {
|
||||
let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else {
|
||||
return Ok(None);
|
||||
@@ -923,6 +1037,26 @@ fn parse_matching_type(value: Option<&str>) -> Result<Option<MatchingType>, Stri
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rebalance_cash_mode(value: Option<&str>) -> Result<Option<RebalanceCashMode>, String> {
|
||||
let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match normalize_model_name(raw).as_str() {
|
||||
"same_point_net" | "same_bar_net" | "same_execution_net" | "net_rebalance" | "net" => {
|
||||
Ok(Some(RebalanceCashMode::SamePointNet))
|
||||
}
|
||||
"sell_then_buy" | "sell_first" | "sell_first_buy_later" | "live_like" => {
|
||||
Ok(Some(RebalanceCashMode::SellThenBuy))
|
||||
}
|
||||
"pre_open_cash" | "existing_cash" | "cash_before_open" | "conservative" => {
|
||||
Ok(Some(RebalanceCashMode::PreOpenCash))
|
||||
}
|
||||
_ => Err(format!(
|
||||
"rebalanceCashMode only supports same_point_net, sell_then_buy, pre_open_cash: {raw}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_slippage_model(
|
||||
model: Option<&str>,
|
||||
value: Option<f64>,
|
||||
@@ -935,7 +1069,7 @@ fn parse_slippage_model(
|
||||
let volatility_coefficient = valid_non_negative(volatility_coefficient);
|
||||
let max_value = valid_non_negative(max_value);
|
||||
let model = model
|
||||
.map(normalize_model_name)
|
||||
.map(normalize_slippage_model_name)
|
||||
.filter(|item| !item.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
if value.is_some_and(|item| item > 0.0) {
|
||||
@@ -950,13 +1084,11 @@ fn parse_slippage_model(
|
||||
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
|
||||
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))),
|
||||
"limit_price" => Some(SlippageModel::LimitPrice),
|
||||
"dynamic" | "dynamic_volume_volatility" => {
|
||||
Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
|
||||
impact_coefficient.unwrap_or(0.5),
|
||||
volatility_coefficient.unwrap_or(0.3),
|
||||
max_value.or(value).unwrap_or(0.01),
|
||||
)))
|
||||
}
|
||||
"dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
|
||||
impact_coefficient.unwrap_or(0.5),
|
||||
volatility_coefficient.unwrap_or(0.3),
|
||||
max_value.or(value).unwrap_or(0.01),
|
||||
))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -964,16 +1096,24 @@ fn parse_slippage_model(
|
||||
fn apply_execution_behavior_overrides(
|
||||
cfg: &mut PlatformExprStrategyConfig,
|
||||
matching_type: Option<&str>,
|
||||
rebalance_cash_mode: Option<&str>,
|
||||
slippage_model: Option<&str>,
|
||||
slippage_value: Option<f64>,
|
||||
slippage_impact_coefficient: Option<f64>,
|
||||
slippage_volatility_coefficient: Option<f64>,
|
||||
slippage_max_value: Option<f64>,
|
||||
sell_then_buy_delay_slippage_rate: Option<f64>,
|
||||
strict_value_budget: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(matching_type) = parse_matching_type(matching_type)? {
|
||||
cfg.matching_type = matching_type;
|
||||
}
|
||||
if let Some(rebalance_cash_mode) = parse_rebalance_cash_mode(rebalance_cash_mode)? {
|
||||
cfg.rebalance_cash_mode = rebalance_cash_mode;
|
||||
}
|
||||
if cfg.matching_type == MatchingType::MinuteLast {
|
||||
cfg.rebalance_cash_mode = RebalanceCashMode::SellThenBuy;
|
||||
}
|
||||
if slippage_model.is_some()
|
||||
|| slippage_value.is_some()
|
||||
|| slippage_impact_coefficient.is_some()
|
||||
@@ -993,6 +1133,26 @@ fn apply_execution_behavior_overrides(
|
||||
if let Some(enabled) = strict_value_budget {
|
||||
cfg.strict_value_budget = enabled;
|
||||
}
|
||||
if let Some(rate) = sell_then_buy_delay_slippage_rate {
|
||||
if !rate.is_finite() || !(0.0..1.0).contains(&rate) {
|
||||
return Err(
|
||||
"sellThenBuyDelaySlippageRate must be a finite number in [0, 1)".to_string(),
|
||||
);
|
||||
}
|
||||
if cfg.rebalance_cash_mode != RebalanceCashMode::SellThenBuy {
|
||||
if rate != 0.0 {
|
||||
return Err(
|
||||
"sellThenBuyDelaySlippageRate can only be set when rebalanceCashMode is sell_then_buy"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
cfg.sell_then_buy_delay_slippage_rate = 0.0;
|
||||
} else {
|
||||
cfg.sell_then_buy_delay_slippage_rate = rate;
|
||||
}
|
||||
} else if cfg.rebalance_cash_mode != RebalanceCashMode::SellThenBuy {
|
||||
cfg.sell_then_buy_delay_slippage_rate = 0.0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1079,6 +1239,17 @@ fn stock_volume_ma_expr(days: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_index_throttle_exposure_expr(expr: &str) -> String {
|
||||
let mut normalized = expr.to_string();
|
||||
for suffix in ["short", "long", "5", "10", "20", "30"] {
|
||||
normalized = normalized.replace(
|
||||
&format!("benchmark_ma{suffix}"),
|
||||
&format!("signal_ma{suffix}"),
|
||||
);
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn infer_expression_windows(
|
||||
cfg: &mut PlatformExprStrategyConfig,
|
||||
benchmark_short_explicit: bool,
|
||||
@@ -1090,6 +1261,7 @@ fn infer_expression_windows(
|
||||
let mut benchmark_days = Vec::new();
|
||||
for expr in [&cfg.exposure_expr, &cfg.buy_scale_expr] {
|
||||
benchmark_days.extend(prefixed_ma_lookbacks(expr, "benchmark_ma"));
|
||||
benchmark_days.extend(prefixed_ma_lookbacks(expr, "signal_ma"));
|
||||
benchmark_days.extend(rolling_mean_lookbacks(expr, "benchmark_close"));
|
||||
}
|
||||
let benchmark_days = sorted_unique_positive(benchmark_days);
|
||||
@@ -1170,6 +1342,9 @@ pub fn platform_expr_config_from_spec(
|
||||
{
|
||||
cfg.weak_market_shrink_overweight_threshold = Some(threshold);
|
||||
}
|
||||
if let Some(days) = engine.max_holding_days.filter(|value| *value > 0) {
|
||||
cfg.max_holding_days = Some(days);
|
||||
}
|
||||
if let Some(schedule) = engine
|
||||
.rebalance_schedule
|
||||
.as_ref()
|
||||
@@ -1255,11 +1430,13 @@ pub fn platform_expr_config_from_spec(
|
||||
apply_execution_behavior_overrides(
|
||||
&mut cfg,
|
||||
engine.matching_type.as_deref(),
|
||||
engine.rebalance_cash_mode.as_deref(),
|
||||
engine.slippage_model.as_deref(),
|
||||
engine.slippage_value,
|
||||
engine.slippage_impact_coefficient,
|
||||
engine.slippage_volatility_coefficient,
|
||||
engine.slippage_max_value,
|
||||
engine.sell_then_buy_delay_slippage_rate,
|
||||
engine.strict_value_budget,
|
||||
)?;
|
||||
}
|
||||
@@ -1394,7 +1571,16 @@ pub fn platform_expr_config_from_spec(
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
cfg.exposure_expr = expr.clone();
|
||||
cfg.exposure_expr = if spec
|
||||
.engine_config
|
||||
.as_ref()
|
||||
.and_then(|engine| engine.index_throttle.as_ref())
|
||||
.is_some()
|
||||
{
|
||||
normalize_index_throttle_exposure_expr(expr)
|
||||
} else {
|
||||
expr.clone()
|
||||
};
|
||||
}
|
||||
if let Some(expr) = risk
|
||||
.stop_loss_expr
|
||||
@@ -1448,6 +1634,18 @@ pub fn platform_expr_config_from_spec(
|
||||
if let Some(enabled) = trading.daily_top_up {
|
||||
cfg.daily_top_up_enabled = enabled;
|
||||
}
|
||||
if let Some(enabled) = trading.daily_position_target_adjust {
|
||||
cfg.daily_position_target_adjust_enabled = enabled;
|
||||
}
|
||||
if let Some(enabled) = trading.rebalance_existing_positions {
|
||||
cfg.rebalance_existing_positions = enabled;
|
||||
}
|
||||
if let Some(multiple) = trading
|
||||
.selection_buffer_multiple
|
||||
.filter(|value| value.is_finite() && *value >= 1.0)
|
||||
{
|
||||
cfg.selection_buffer_multiple = multiple;
|
||||
}
|
||||
if let Some(enabled) = trading.retry_empty_rebalance {
|
||||
cfg.retry_empty_rebalance = enabled;
|
||||
}
|
||||
@@ -1457,6 +1655,9 @@ pub fn platform_expr_config_from_spec(
|
||||
{
|
||||
cfg.weak_market_shrink_overweight_threshold = Some(threshold);
|
||||
}
|
||||
if let Some(days) = trading.max_holding_days.filter(|value| *value > 0) {
|
||||
cfg.max_holding_days = Some(days);
|
||||
}
|
||||
if let Some(enabled) = trading.release_slot_on_exit_signal {
|
||||
cfg.release_slot_on_exit_signal = enabled;
|
||||
}
|
||||
@@ -1567,7 +1768,7 @@ pub fn platform_expr_config_from_spec(
|
||||
let defensive = index_throttle.defensive_exposure.unwrap_or(0.5);
|
||||
let full = index_throttle.full_exposure.unwrap_or(1.0);
|
||||
cfg.exposure_expr = format!(
|
||||
"benchmark_ma_short < benchmark_ma_long * {} ? {} : {}",
|
||||
"signal_ma_short < signal_ma_long * {} ? {} : {}",
|
||||
ratio, defensive, full
|
||||
);
|
||||
}
|
||||
@@ -1601,14 +1802,16 @@ pub fn platform_expr_config_from_spec(
|
||||
if !cfg.benchmark_symbol.trim().is_empty() {
|
||||
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
|
||||
}
|
||||
let aiquant_profile = is_aiquant_profile(
|
||||
spec.engine_config
|
||||
.as_ref()
|
||||
.and_then(|engine| engine.profile_name.as_deref()),
|
||||
);
|
||||
let aiquant_profile = spec.engine_config.as_ref().is_some_and(|engine| {
|
||||
is_aiquant_profile(engine.profile_name.as_deref())
|
||||
|| is_aiquant_profile(engine.compatibility_profile.as_deref())
|
||||
});
|
||||
if aiquant_profile {
|
||||
cfg.aiquant_transaction_cost = true;
|
||||
cfg.strict_value_budget = true;
|
||||
if !cfg.universe_exclude.iter().any(|item| item == "bjse") {
|
||||
cfg.universe_exclude.push("bjse".to_string());
|
||||
}
|
||||
let trading = spec
|
||||
.runtime_expressions
|
||||
.as_ref()
|
||||
@@ -1665,13 +1868,16 @@ pub fn platform_expr_config_from_spec(
|
||||
apply_execution_behavior_overrides(
|
||||
&mut cfg,
|
||||
execution.matching_type.as_deref(),
|
||||
execution.rebalance_cash_mode.as_deref(),
|
||||
execution.slippage_model.as_deref(),
|
||||
execution.slippage_value,
|
||||
execution.slippage_impact_coefficient,
|
||||
execution.slippage_volatility_coefficient,
|
||||
execution.slippage_max_value,
|
||||
execution.sell_then_buy_delay_slippage_rate,
|
||||
execution.strict_value_budget,
|
||||
)?;
|
||||
sync_quote_quantity_limit(&mut cfg);
|
||||
}
|
||||
if cfg.aiquant_transaction_cost
|
||||
&& cfg
|
||||
@@ -1717,7 +1923,15 @@ fn signal_rebalance_dates(rebalance: &StrategyRebalanceSpec) -> Option<BTreeSet<
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if frequency != "signal_dates" && frequency != "signal-dates" && frequency != "signal dates" {
|
||||
if !matches!(
|
||||
frequency.as_str(),
|
||||
"signal_dates"
|
||||
| "signal-dates"
|
||||
| "signal dates"
|
||||
| "daily_model_score_rank"
|
||||
| "dynamic_model_score_rank"
|
||||
| "model_rank_rotation"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let dates = rebalance
|
||||
@@ -2158,7 +2372,10 @@ mod tests {
|
||||
assert_eq!(cfg.signal_symbol, "000852.SH");
|
||||
assert_eq!(cfg.selection_limit_expr, "stocknum");
|
||||
assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20");
|
||||
assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
|
||||
assert_eq!(
|
||||
cfg.universe_exclude,
|
||||
["paused", "st", "kcb", "one_yuan", "bjse"]
|
||||
);
|
||||
assert!(!cfg.rotation_enabled);
|
||||
assert!(cfg.daily_top_up_enabled);
|
||||
assert!(cfg.retry_empty_rebalance);
|
||||
@@ -2185,6 +2402,24 @@ mod tests {
|
||||
assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_max_holding_days_from_engine_and_runtime_trading() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"maxHoldDays": 120
|
||||
},
|
||||
"runtimeExpressions": {
|
||||
"trading": {
|
||||
"max_holding_days": 90
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert_eq!(cfg.max_holding_days, Some(90));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_signal_dates_rebalance_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -2207,6 +2442,26 @@ mod tests {
|
||||
assert_eq!(cfg.signal_rebalance_dates.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_dynamic_model_score_dates_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
"rebalance": {
|
||||
"frequency": "daily_model_score_rank",
|
||||
"dates": ["2025-11-10", "2025-11-17"]
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert_eq!(
|
||||
cfg.signal_rebalance_dates,
|
||||
BTreeSet::from([
|
||||
NaiveDate::from_ymd_opt(2025, 11, 10).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2025, 11, 17).unwrap(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_execution_cost_overrides_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -2309,6 +2564,24 @@ mod tests {
|
||||
assert!(cfg.quote_quantity_limit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_limit_does_not_enable_quote_quantity_limit_for_minute_last() {
|
||||
let spec = serde_json::json!({
|
||||
"execution": {
|
||||
"matchingType": "minute_last",
|
||||
"volumeLimit": true,
|
||||
"liquidityLimit": false,
|
||||
"volumePercent": 0.25
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert!(cfg.risk_config.trading_constraints.volume_limit_enabled);
|
||||
assert!(!cfg.risk_config.trading_constraints.liquidity_limit_enabled);
|
||||
assert!(!cfg.quote_quantity_limit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_st_and_star_st_risk_policy_switches_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -2423,6 +2696,63 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_equivalent_risk_policy_alias_values() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"riskPolicy": {
|
||||
"rejectStBuy": false,
|
||||
"reject_st_buy": 0,
|
||||
"volumePercent": 0.25,
|
||||
"volume_percent": 25,
|
||||
"minimumCommission": 5,
|
||||
"minimum_commission": "5"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert!(!cfg.risk_config.static_rules.reject_st_buy);
|
||||
assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12);
|
||||
assert_eq!(cfg.risk_config.trading_constraints.minimum_commission, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_conflicting_risk_policy_alias_values() {
|
||||
let bool_conflict = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"riskPolicy": {
|
||||
"rejectStBuy": false,
|
||||
"reject_st_buy": true
|
||||
}
|
||||
}
|
||||
});
|
||||
let bool_error = platform_expr_config_from_value("", "", &bool_conflict)
|
||||
.expect_err("conflicting bool aliases must fail");
|
||||
assert!(
|
||||
bool_error
|
||||
.to_string()
|
||||
.contains("riskPolicy.rejectStBuy has conflicting alias values")
|
||||
);
|
||||
|
||||
let value_conflict = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"riskPolicy": {
|
||||
"minimumCommission": 5,
|
||||
"minimum_commission": 6
|
||||
}
|
||||
}
|
||||
});
|
||||
let value_error = platform_expr_config_from_value("", "", &value_conflict)
|
||||
.expect_err("conflicting value aliases must fail");
|
||||
assert!(
|
||||
value_error
|
||||
.to_string()
|
||||
.contains("riskPolicy.minimumCommission has conflicting alias values")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_execution_slippage_overrides_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -2448,6 +2778,78 @@ mod tests {
|
||||
assert!(cfg.strict_value_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_percent_slippage_alias_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
"execution": {
|
||||
"slippageModel": "percent",
|
||||
"slippageValue": 0.001
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert_eq!(cfg.slippage_model, SlippageModel::PriceRatio(0.001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rebalance_cash_mode_and_forces_minute_to_actual_sequence() {
|
||||
let spec = serde_json::json!({
|
||||
"execution": {
|
||||
"matchingType": "next_bar_open",
|
||||
"rebalanceCashMode": "pre_open_cash"
|
||||
}
|
||||
});
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert_eq!(cfg.matching_type, MatchingType::NextBarOpen);
|
||||
assert_eq!(cfg.rebalance_cash_mode, RebalanceCashMode::PreOpenCash);
|
||||
assert_eq!(cfg.sell_then_buy_delay_slippage_rate, 0.0);
|
||||
|
||||
let invalid_delay_spec = serde_json::json!({
|
||||
"execution": {
|
||||
"matchingType": "next_bar_open",
|
||||
"rebalanceCashMode": "pre_open_cash",
|
||||
"sellThenBuyDelaySlippageRate": 0.003
|
||||
}
|
||||
});
|
||||
let invalid_delay = platform_expr_config_from_value("", "", &invalid_delay_spec)
|
||||
.expect_err("delay slippage should be rejected outside sell_then_buy");
|
||||
assert!(invalid_delay.to_string().contains("can only be set"));
|
||||
|
||||
let override_to_pre_open_spec = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"rebalanceCashMode": "sell_then_buy",
|
||||
"sellThenBuyDelaySlippageRate": 0.003
|
||||
},
|
||||
"execution": {
|
||||
"matchingType": "next_bar_open",
|
||||
"rebalanceCashMode": "pre_open_cash"
|
||||
}
|
||||
});
|
||||
let override_to_pre_open =
|
||||
platform_expr_config_from_value("", "", &override_to_pre_open_spec).expect("config");
|
||||
assert_eq!(
|
||||
override_to_pre_open.rebalance_cash_mode,
|
||||
RebalanceCashMode::PreOpenCash
|
||||
);
|
||||
assert_eq!(override_to_pre_open.sell_then_buy_delay_slippage_rate, 0.0);
|
||||
|
||||
let minute_spec = serde_json::json!({
|
||||
"execution": {
|
||||
"matchingType": "minute_last",
|
||||
"rebalanceCashMode": "pre_open_cash"
|
||||
}
|
||||
});
|
||||
let minute_cfg = platform_expr_config_from_value("", "", &minute_spec).expect("config");
|
||||
|
||||
assert_eq!(minute_cfg.matching_type, MatchingType::MinuteLast);
|
||||
assert_eq!(
|
||||
minute_cfg.rebalance_cash_mode,
|
||||
RebalanceCashMode::SellThenBuy
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_spec_accepts_only_supported_matching_types() {
|
||||
for (raw, expected) in [
|
||||
@@ -2561,6 +2963,9 @@ mod tests {
|
||||
"runtimeExpressions": {
|
||||
"trading": {
|
||||
"dailyTopUp": false,
|
||||
"dailyPositionTargetAdjust": false,
|
||||
"rebalanceExistingPositions": true,
|
||||
"selectionBufferMultiple": 1.5,
|
||||
"retryEmptyRebalance": false
|
||||
}
|
||||
}
|
||||
@@ -2569,6 +2974,9 @@ mod tests {
|
||||
let cfg = platform_expr_config_from_value("", "", &explicit_off).expect("config");
|
||||
|
||||
assert!(!cfg.daily_top_up_enabled);
|
||||
assert!(!cfg.daily_position_target_adjust_enabled);
|
||||
assert!(cfg.rebalance_existing_positions);
|
||||
assert_eq!(cfg.selection_buffer_multiple, 1.5);
|
||||
assert!(!cfg.retry_empty_rebalance);
|
||||
}
|
||||
|
||||
@@ -2656,6 +3064,40 @@ mod tests {
|
||||
assert_eq!(cfg.stock_long_ma_days, 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_throttle_uses_signal_ma_not_performance_benchmark_ma() {
|
||||
let spec = serde_json::json!({
|
||||
"signalSymbol": "000852.SH",
|
||||
"benchmark": {
|
||||
"instrumentId": "932000.CSI"
|
||||
},
|
||||
"engineConfig": {
|
||||
"profileName": "aiquant",
|
||||
"indexThrottle": {
|
||||
"shortDays": 10,
|
||||
"longDays": 30,
|
||||
"defensiveExposure": 0.5,
|
||||
"fullExposure": 1.0
|
||||
}
|
||||
},
|
||||
"runtimeExpressions": {
|
||||
"risk": {
|
||||
"exposureExpr": "if benchmark_ma10 > benchmark_ma30 { 1.0 } else { 0.5 }"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("strategy88", "", &spec).expect("config");
|
||||
|
||||
assert_eq!(cfg.signal_symbol, "000852.SH");
|
||||
assert_eq!(
|
||||
cfg.exposure_expr,
|
||||
"if signal_ma10 > signal_ma30 { 1.0 } else { 0.5 }"
|
||||
);
|
||||
assert_eq!(cfg.benchmark_short_ma_days, 10);
|
||||
assert_eq!(cfg.benchmark_long_ma_days, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_daily_schedule_time_for_aiquant_execution_quotes() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -2699,6 +3141,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_aiquant_compatibility_profile_for_delayed_limit_exit() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"profileName": "cn_a_microcap_v1",
|
||||
"compatibilityProfile": "aiquant_rqalpha"
|
||||
},
|
||||
"rebalance": { "tradeTimes": ["09:31", "10:15"] },
|
||||
"runtimeExpressions": {
|
||||
"schedule": { "frequency": "daily", "time": "10:15" }
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert_eq!(
|
||||
cfg.intraday_execution_time,
|
||||
Some(NaiveTime::from_hms_opt(10, 15, 0).unwrap())
|
||||
);
|
||||
assert!(cfg.delayed_limit_open_exit_enabled);
|
||||
assert_eq!(
|
||||
cfg.delayed_limit_open_exit_time,
|
||||
Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_explicit_delayed_limit_open_exit() {
|
||||
let spec = serde_json::json!({
|
||||
|
||||
@@ -82,6 +82,8 @@ impl Position {
|
||||
return;
|
||||
}
|
||||
|
||||
let previous_quantity = self.quantity;
|
||||
let previous_average_cost = self.average_cost;
|
||||
self.lots.push(PositionLot {
|
||||
acquired_date: date,
|
||||
quantity,
|
||||
@@ -93,7 +95,18 @@ impl Position {
|
||||
self.day_trade_quantity_delta += quantity as i32;
|
||||
self.day_buy_quantity += quantity;
|
||||
self.day_buy_value += execution_price * quantity as f64;
|
||||
self.recalculate_average_cost();
|
||||
if previous_quantity > 0
|
||||
&& previous_average_cost.is_finite()
|
||||
&& previous_average_cost > 0.0
|
||||
&& execution_price.is_finite()
|
||||
&& execution_price > 0.0
|
||||
{
|
||||
self.average_cost = (previous_average_cost * previous_quantity as f64
|
||||
+ execution_price * quantity as f64)
|
||||
/ self.quantity as f64;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
self.refresh_day_pnl();
|
||||
}
|
||||
|
||||
@@ -117,6 +130,7 @@ impl Position {
|
||||
let mut remaining = quantity;
|
||||
let mut realized = 0.0;
|
||||
let mut realized_entry = 0.0;
|
||||
let average_cost_before_sell = self.average_cost;
|
||||
|
||||
while remaining > 0 {
|
||||
let Some(first_lot) = self.lots.first_mut() else {
|
||||
@@ -141,7 +155,13 @@ impl Position {
|
||||
self.day_trade_quantity_delta -= quantity as i32;
|
||||
self.day_sell_quantity += quantity;
|
||||
self.day_sell_value += execution_price * quantity as f64;
|
||||
self.recalculate_average_cost();
|
||||
if self.quantity == 0 {
|
||||
self.recalculate_average_cost();
|
||||
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
|
||||
self.average_cost = average_cost_before_sell;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
self.refresh_day_pnl();
|
||||
Ok(realized)
|
||||
}
|
||||
@@ -252,7 +272,11 @@ impl Position {
|
||||
}
|
||||
if let Some(lot) = self.lots.last_mut() {
|
||||
lot.price += cost / quantity as f64;
|
||||
self.recalculate_average_cost();
|
||||
if self.quantity > 0 && self.average_cost.is_finite() && self.average_cost > 0.0 {
|
||||
self.average_cost += cost / self.quantity as f64;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
}
|
||||
self.day_trade_cost += cost;
|
||||
self.refresh_day_pnl();
|
||||
@@ -370,7 +394,11 @@ impl Position {
|
||||
self.lots = scaled_lots;
|
||||
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
||||
self.last_price /= ratio;
|
||||
self.recalculate_average_cost();
|
||||
if self.average_cost.is_finite() && self.average_cost > 0.0 {
|
||||
self.average_cost /= ratio;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
self.day_split_ratio *= ratio;
|
||||
self.refresh_day_pnl();
|
||||
self.quantity as i32 - old_quantity as i32
|
||||
@@ -837,6 +865,7 @@ impl PortfolioState {
|
||||
|
||||
let old_quantity = old_position.quantity;
|
||||
let last_price = old_position.last_price;
|
||||
let old_average_cost = old_position.average_cost;
|
||||
let realized_pnl = old_position.realized_pnl;
|
||||
let realized_entry_pnl = old_position.realized_entry_pnl;
|
||||
let mut converted_lots = old_position
|
||||
@@ -872,6 +901,8 @@ impl PortfolioState {
|
||||
.positions
|
||||
.entry(new_symbol.to_string())
|
||||
.or_insert_with(|| Position::new(new_symbol));
|
||||
let successor_quantity_before = successor.quantity;
|
||||
let successor_average_cost_before = successor.average_cost;
|
||||
successor.lots.extend(converted_lots);
|
||||
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
||||
successor.realized_pnl += realized_pnl;
|
||||
@@ -879,7 +910,30 @@ impl PortfolioState {
|
||||
if converted_last_price > 0.0 {
|
||||
successor.last_price = converted_last_price;
|
||||
}
|
||||
successor.recalculate_average_cost();
|
||||
let converted_average_cost = if old_average_cost.is_finite()
|
||||
&& old_average_cost > 0.0
|
||||
&& ratio.is_finite()
|
||||
&& ratio > 0.0
|
||||
{
|
||||
Some(old_average_cost / ratio)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(converted_average_cost) = converted_average_cost {
|
||||
if successor_quantity_before > 0
|
||||
&& successor_average_cost_before.is_finite()
|
||||
&& successor_average_cost_before > 0.0
|
||||
{
|
||||
successor.average_cost = (successor_average_cost_before
|
||||
* successor_quantity_before as f64
|
||||
+ converted_average_cost * converted_quantity as f64)
|
||||
/ successor.quantity as f64;
|
||||
} else {
|
||||
successor.average_cost = converted_average_cost;
|
||||
}
|
||||
} else {
|
||||
successor.recalculate_average_cost();
|
||||
}
|
||||
successor.refresh_day_pnl();
|
||||
|
||||
Some(SuccessorConversionOutcome {
|
||||
@@ -955,6 +1009,45 @@ mod tests {
|
||||
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_sell_preserves_remaining_average_cost() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut position = Position::new("603958.SH");
|
||||
position.buy(date, 800, 18.0981);
|
||||
position.record_buy_trade_cost(800, 5.0);
|
||||
position.buy(date, 1700, 19.4694);
|
||||
position.record_buy_trade_cost(1700, 8.27451625);
|
||||
position.buy(date, 200, 18.4584);
|
||||
position.record_buy_trade_cost(200, 5.0);
|
||||
position.buy(date, 100, 17.8378);
|
||||
position.record_buy_trade_cost(100, 5.0);
|
||||
let average_cost_before = position.average_cost;
|
||||
|
||||
position.sell(2700, 16.8331).expect("partial sell");
|
||||
|
||||
assert_eq!(position.quantity, 100);
|
||||
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_after_partial_sell_continues_moving_average_cost_basis() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut position = Position::new("300405.SZ");
|
||||
position.buy(date, 100, 10.0);
|
||||
position.buy(date, 100, 5.0);
|
||||
assert!((position.average_cost - 7.5).abs() < 1e-12);
|
||||
|
||||
position.sell(100, 6.0).expect("partial sell");
|
||||
assert_eq!(position.quantity, 100);
|
||||
assert!((position.average_cost - 7.5).abs() < 1e-12);
|
||||
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||
|
||||
position.buy(date, 100, 5.0);
|
||||
assert_eq!(position.quantity, 200);
|
||||
assert!((position.average_cost - 6.25).abs() < 1e-12);
|
||||
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
|
||||
@@ -45,27 +45,27 @@ pub struct StaticRiskRuleConfig {
|
||||
impl Default for StaticRiskRuleConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
reject_st_selection: true,
|
||||
reject_st_selection: false,
|
||||
reject_st_buy: true,
|
||||
reject_star_st_selection: true,
|
||||
reject_star_st_selection: false,
|
||||
reject_star_st_buy: true,
|
||||
reject_paused_selection: true,
|
||||
reject_paused_selection: false,
|
||||
reject_paused_buy: true,
|
||||
reject_paused_sell: true,
|
||||
reject_inactive_selection: true,
|
||||
reject_inactive_selection: false,
|
||||
reject_inactive_buy: true,
|
||||
reject_inactive_sell: true,
|
||||
reject_new_listing_selection: true,
|
||||
reject_new_listing_selection: false,
|
||||
reject_new_listing_buy: true,
|
||||
reject_kcb_selection: true,
|
||||
reject_kcb_selection: false,
|
||||
reject_kcb_buy: true,
|
||||
reject_bjse_selection: true,
|
||||
reject_bjse_selection: false,
|
||||
reject_bjse_buy: true,
|
||||
reject_one_yuan_selection: true,
|
||||
reject_one_yuan_selection: false,
|
||||
reject_one_yuan_buy: true,
|
||||
respect_allow_buy_sell: true,
|
||||
reject_upper_limit_selection: true,
|
||||
reject_lower_limit_selection: true,
|
||||
reject_upper_limit_selection: false,
|
||||
reject_lower_limit_selection: false,
|
||||
reject_upper_limit_buy: true,
|
||||
reject_lower_limit_sell: true,
|
||||
forbid_same_day_rebuy_after_sell: true,
|
||||
@@ -223,6 +223,21 @@ impl ChinaAShareRiskControl {
|
||||
Self::instrument_rejection_reason(instrument, date)
|
||||
}
|
||||
|
||||
pub fn active_status_rejection_reason_with_config(
|
||||
date: NaiveDate,
|
||||
candidate: Option<&CandidateEligibility>,
|
||||
instrument: Option<&Instrument>,
|
||||
config: &FidcRiskControlConfig,
|
||||
scope: RiskCheckScope,
|
||||
) -> Option<&'static str> {
|
||||
if let Some(reason) =
|
||||
Self::instrument_rejection_reason_with_config(instrument, date, config, scope)
|
||||
{
|
||||
return Some(reason);
|
||||
}
|
||||
candidate.and_then(|candidate| candidate_active_status_rejection(candidate, config, scope))
|
||||
}
|
||||
|
||||
pub fn selection_rejection_reason(
|
||||
date: NaiveDate,
|
||||
candidate: &CandidateEligibility,
|
||||
@@ -255,11 +270,6 @@ impl ChinaAShareRiskControl {
|
||||
) {
|
||||
return Some(reason);
|
||||
}
|
||||
if config.static_rules.respect_allow_buy_sell
|
||||
&& (!candidate.allow_buy || !candidate.allow_sell)
|
||||
{
|
||||
return Some("trade_disabled");
|
||||
}
|
||||
let selection_price = market.price(PriceField::Last);
|
||||
if config.static_rules.reject_upper_limit_selection
|
||||
&& market.is_at_upper_limit_price(selection_price)
|
||||
@@ -506,10 +516,9 @@ impl ChinaAShareRiskControl {
|
||||
if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) {
|
||||
return Some("paused");
|
||||
}
|
||||
// `allow_sell` is derived from the daily candidate snapshot and may
|
||||
// reflect an open/close fallback rather than the actual execution price.
|
||||
// A sell order must be blocked by the execution price lower-limit check
|
||||
// below, while suspension and delisting are handled above.
|
||||
if config.static_rules.respect_allow_buy_sell && !candidate.allow_sell {
|
||||
return Some("sell_disabled");
|
||||
}
|
||||
if config.static_rules.reject_lower_limit_sell
|
||||
&& market.is_at_lower_limit_price(check_price)
|
||||
{
|
||||
@@ -590,8 +599,7 @@ fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlCon
|
||||
|| config.static_rules.reject_bjse_selection
|
||||
|| config.static_rules.reject_one_yuan_selection
|
||||
|| config.static_rules.reject_upper_limit_selection
|
||||
|| config.static_rules.reject_lower_limit_selection
|
||||
|| config.static_rules.respect_allow_buy_sell;
|
||||
|| config.static_rules.reject_lower_limit_selection;
|
||||
}
|
||||
missing_field_rejected(&fields, config, RiskCheckScope::Selection)
|
||||
}
|
||||
@@ -687,15 +695,13 @@ fn missing_single_field_rejected(
|
||||
RiskCheckScope::Sell => false,
|
||||
},
|
||||
"allow_buy" => match scope {
|
||||
RiskCheckScope::Selection | RiskCheckScope::Buy => {
|
||||
config.static_rules.respect_allow_buy_sell
|
||||
}
|
||||
RiskCheckScope::Selection => false,
|
||||
RiskCheckScope::Buy => config.static_rules.respect_allow_buy_sell,
|
||||
RiskCheckScope::Sell => false,
|
||||
},
|
||||
"allow_sell" => match scope {
|
||||
RiskCheckScope::Selection | RiskCheckScope::Sell => {
|
||||
config.static_rules.respect_allow_buy_sell
|
||||
}
|
||||
RiskCheckScope::Selection => false,
|
||||
RiskCheckScope::Sell => config.static_rules.respect_allow_buy_sell,
|
||||
RiskCheckScope::Buy => false,
|
||||
},
|
||||
"upper_limit" | "upper_limit_price" | "high_limit" | "high_limit_price" => match scope {
|
||||
@@ -720,7 +726,6 @@ fn missing_single_field_rejected(
|
||||
|| config.static_rules.reject_one_yuan_selection
|
||||
|| config.static_rules.reject_upper_limit_selection
|
||||
|| config.static_rules.reject_lower_limit_selection
|
||||
|| config.static_rules.respect_allow_buy_sell
|
||||
}
|
||||
RiskCheckScope::Buy => {
|
||||
config.static_rules.reject_st_buy
|
||||
@@ -809,7 +814,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sell_rejection_uses_execution_price_not_stale_allow_sell() {
|
||||
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
|
||||
let prev_date = d(2024, 4, 16);
|
||||
let date = d(2024, 4, 17);
|
||||
let candidate = candidate(date);
|
||||
@@ -825,14 +830,29 @@ mod tests {
|
||||
6.27,
|
||||
);
|
||||
|
||||
assert_eq!(reason, None);
|
||||
assert_eq!(reason, Some("sell_disabled"));
|
||||
|
||||
let mut relaxed = FidcRiskControlConfig::default();
|
||||
relaxed.static_rules.respect_allow_buy_sell = false;
|
||||
let relaxed_reason = ChinaAShareRiskControl::sell_rejection_reason_with_config(
|
||||
date,
|
||||
&candidate,
|
||||
&market,
|
||||
None,
|
||||
Some(&position),
|
||||
6.27,
|
||||
&relaxed,
|
||||
);
|
||||
|
||||
assert_eq!(relaxed_reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sell_rejection_blocks_execution_price_at_lower_limit() {
|
||||
let prev_date = d(2024, 4, 16);
|
||||
let date = d(2024, 4, 17);
|
||||
let candidate = candidate(date);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.allow_sell = true;
|
||||
let market = market(date, 5.63, 5.63);
|
||||
let position = position(prev_date);
|
||||
|
||||
@@ -879,13 +899,19 @@ mod tests {
|
||||
let default_reason =
|
||||
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_kcb_selection = true;
|
||||
let enabled_selection_reason =
|
||||
ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
config.static_rules.reject_kcb_selection = false;
|
||||
config.static_rules.reject_kcb_buy = false;
|
||||
let configured_reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, 6.27, &config,
|
||||
);
|
||||
|
||||
assert_eq!(default_reason, Some("kcb"));
|
||||
assert_eq!(default_reason, None);
|
||||
assert_eq!(enabled_selection_reason, Some("kcb"));
|
||||
assert_eq!(configured_reason, None);
|
||||
}
|
||||
|
||||
@@ -994,6 +1020,10 @@ mod tests {
|
||||
let default_buy =
|
||||
ChinaAShareRiskControl::buy_rejection_reason(date, &candidate, &market, None, 6.27);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_bjse_selection = true;
|
||||
let enabled_selection = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
config.static_rules.reject_bjse_selection = false;
|
||||
config.static_rules.reject_bjse_buy = false;
|
||||
let configured_selection = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
@@ -1003,8 +1033,9 @@ mod tests {
|
||||
date, &candidate, &market, None, 6.27, &config,
|
||||
);
|
||||
|
||||
assert_eq!(default_selection, Some("bjse"));
|
||||
assert_eq!(default_selection, None);
|
||||
assert_eq!(default_buy, Some("bjse"));
|
||||
assert_eq!(enabled_selection, Some("bjse"));
|
||||
assert_eq!(configured_selection, None);
|
||||
assert_eq!(configured_buy, None);
|
||||
}
|
||||
@@ -1016,13 +1047,11 @@ mod tests {
|
||||
candidate.symbol = "688506.SH".to_string();
|
||||
candidate.risk_level_code = Some("missing_risk_state".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_kcb_selection = true;
|
||||
|
||||
let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
date,
|
||||
&candidate,
|
||||
&market,
|
||||
None,
|
||||
&FidcRiskControlConfig::default(),
|
||||
date, &candidate, &market, None, &config,
|
||||
)
|
||||
.expect("kcb selection rejection");
|
||||
|
||||
@@ -1037,9 +1066,13 @@ mod tests {
|
||||
candidate.allow_sell = true;
|
||||
candidate.risk_level_code = Some("inactive_or_delisted".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_inactive_selection = true;
|
||||
|
||||
assert_eq!(
|
||||
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None),
|
||||
ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config
|
||||
),
|
||||
Some("inactive_or_delisted")
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1065,9 +1098,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_risk_state_rejects_selection_and_buy_when_static_filters_enabled() {
|
||||
fn missing_risk_state_default_selection_ignores_allow_flags_but_buy_rejects() {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.allow_sell = true;
|
||||
candidate.risk_level_code = Some("missing_risk_state:is_st,allow_buy".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
|
||||
@@ -1084,11 +1118,28 @@ mod tests {
|
||||
6.27,
|
||||
);
|
||||
|
||||
assert_eq!(selection_reason, Some("missing_risk_state"));
|
||||
assert_eq!(selection_reason, None);
|
||||
assert_eq!(buy_reason, Some("missing_risk_state"));
|
||||
assert_eq!(sell_reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_risk_state_selection_still_respects_configured_static_filters() {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.allow_sell = true;
|
||||
candidate.risk_level_code = Some("missing_risk_state:is_st,allow_buy".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_st_selection = true;
|
||||
|
||||
let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
|
||||
assert_eq!(selection_reason, Some("missing_risk_state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_risk_state_selection_audit_keeps_missing_fields_in_reason() {
|
||||
let date = d(2025, 1, 2);
|
||||
@@ -1097,12 +1148,11 @@ mod tests {
|
||||
Some("missing_risk_state:is_st,allow_buy,upper_limit_price".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_st_selection = true;
|
||||
config.static_rules.reject_upper_limit_selection = true;
|
||||
let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
date,
|
||||
&candidate,
|
||||
&market,
|
||||
None,
|
||||
&FidcRiskControlConfig::default(),
|
||||
date, &candidate, &market, None, &config,
|
||||
)
|
||||
.expect("missing risk state rejection");
|
||||
|
||||
@@ -1174,6 +1224,8 @@ mod tests {
|
||||
Some("missing_risk_state:upper_limit_price,lower_limit_price".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_upper_limit_selection = true;
|
||||
config.static_rules.reject_lower_limit_selection = true;
|
||||
config.static_rules.reject_upper_limit_selection = false;
|
||||
|
||||
let upper_disabled_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
@@ -1267,12 +1319,17 @@ mod tests {
|
||||
let default_reason =
|
||||
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_upper_limit_selection = true;
|
||||
let enabled_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
config.static_rules.reject_upper_limit_selection = false;
|
||||
let configured_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
|
||||
assert_eq!(default_reason, Some("upper_limit"));
|
||||
assert_eq!(default_reason, None);
|
||||
assert_eq!(enabled_reason, Some("upper_limit"));
|
||||
assert_eq!(configured_reason, None);
|
||||
}
|
||||
|
||||
@@ -1285,12 +1342,17 @@ mod tests {
|
||||
let default_reason =
|
||||
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_lower_limit_selection = true;
|
||||
let enabled_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
config.static_rules.reject_lower_limit_selection = false;
|
||||
let configured_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
|
||||
assert_eq!(default_reason, Some("lower_limit"));
|
||||
assert_eq!(default_reason, None);
|
||||
assert_eq!(enabled_reason, Some("lower_limit"));
|
||||
assert_eq!(configured_reason, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2035,7 +2035,6 @@ impl OmniMicroCapStrategy {
|
||||
Some(fill.quantity)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn projected_market_fillable_quantity(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
@@ -2047,18 +2046,19 @@ impl OmniMicroCapStrategy {
|
||||
minimum_order_quantity: u32,
|
||||
order_step_size: u32,
|
||||
allow_odd_lot_sell: bool,
|
||||
current_fill_quantity: u32,
|
||||
execution_state: &ProjectedExecutionState,
|
||||
) -> Option<u32> {
|
||||
if requested_qty == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
let snapshot = ctx.data.market(date, symbol)?;
|
||||
if snapshot.minute_volume == 0 {
|
||||
let constraints = self.config.risk_config.trading_constraints;
|
||||
if constraints.volume_limit_enabled && snapshot.minute_volume == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut max_fill = requested_qty;
|
||||
let constraints = self.config.risk_config.trading_constraints;
|
||||
if constraints.liquidity_limit_enabled {
|
||||
let top_level_liquidity = match side {
|
||||
OrderSide::Buy => snapshot.liquidity_for_buy(),
|
||||
@@ -2080,9 +2080,14 @@ impl OmniMicroCapStrategy {
|
||||
max_fill = max_fill.min(liquidity_limited);
|
||||
}
|
||||
|
||||
let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0);
|
||||
let consumed_turnover = execution_state
|
||||
.intraday_turnover
|
||||
.get(symbol)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(current_fill_quantity);
|
||||
if constraints.volume_limit_enabled {
|
||||
let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).round()
|
||||
let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).floor()
|
||||
as i64
|
||||
- consumed_turnover as i64;
|
||||
if raw_limit <= 0 {
|
||||
@@ -2130,6 +2135,23 @@ impl OmniMicroCapStrategy {
|
||||
return None;
|
||||
}
|
||||
|
||||
let requested_qty = self.projected_market_fillable_quantity(
|
||||
ctx,
|
||||
date,
|
||||
symbol,
|
||||
side,
|
||||
requested_qty,
|
||||
round_lot,
|
||||
minimum_order_quantity,
|
||||
order_step_size,
|
||||
allow_odd_lot_sell,
|
||||
0,
|
||||
execution_state,
|
||||
)?;
|
||||
if requested_qty == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(market) = ctx.data.market(date, symbol) {
|
||||
let execution_price = self.projected_execution_price(market, side);
|
||||
if execution_price.is_finite() && execution_price > 0.0 {
|
||||
@@ -2223,7 +2245,25 @@ impl OmniMicroCapStrategy {
|
||||
if remaining_qty == 0 {
|
||||
break;
|
||||
}
|
||||
let mut take_qty = remaining_qty.min(available_qty);
|
||||
let market_fillable_qty = self
|
||||
.projected_market_fillable_quantity(
|
||||
ctx,
|
||||
date,
|
||||
symbol,
|
||||
side,
|
||||
remaining_qty,
|
||||
round_lot,
|
||||
minimum_order_quantity,
|
||||
order_step_size,
|
||||
allow_odd_lot_sell,
|
||||
filled_qty,
|
||||
execution_state,
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if market_fillable_qty == 0 {
|
||||
break;
|
||||
}
|
||||
let mut take_qty = remaining_qty.min(available_qty).min(market_fillable_qty);
|
||||
if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) {
|
||||
take_qty =
|
||||
self.round_lot_quantity(take_qty, minimum_order_quantity, order_step_size);
|
||||
@@ -2474,11 +2514,7 @@ impl OmniMicroCapStrategy {
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
date: NaiveDate,
|
||||
defer_selection_risk: bool,
|
||||
) -> Vec<FidcRiskDecisionAudit> {
|
||||
if defer_selection_risk {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut decisions = Vec::new();
|
||||
for factor in ctx.data.factor_snapshots_on(date) {
|
||||
if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) {
|
||||
@@ -2538,13 +2574,8 @@ impl OmniMicroCapStrategy {
|
||||
date: NaiveDate,
|
||||
band_low: f64,
|
||||
band_high: f64,
|
||||
defer_selection_risk: bool,
|
||||
) -> Result<(Vec<String>, Vec<String>), BacktestError> {
|
||||
let universe = if defer_selection_risk {
|
||||
ctx.fundamental_universe_on(date)
|
||||
} else {
|
||||
ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config)
|
||||
};
|
||||
let universe = ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config);
|
||||
let mut diagnostics = Vec::new();
|
||||
let mut selected = Vec::new();
|
||||
let start = lower_bound_eligible(&universe, band_low);
|
||||
@@ -2553,12 +2584,8 @@ impl OmniMicroCapStrategy {
|
||||
if candidate.market_cap_bn > band_high {
|
||||
break;
|
||||
}
|
||||
let rejection = if defer_selection_risk {
|
||||
(!self.stock_passes_ma_filter(ctx, date, &candidate.symbol))
|
||||
.then_some("ma_filter".to_string())
|
||||
} else {
|
||||
self.buy_rejection_reason(ctx, date, &candidate.symbol)?
|
||||
};
|
||||
let rejection = (!self.stock_passes_ma_filter(ctx, date, &candidate.symbol))
|
||||
.then_some("ma_filter".to_string());
|
||||
if let Some(reason) = rejection {
|
||||
if diagnostics.len() < 12 {
|
||||
diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason));
|
||||
@@ -2584,7 +2611,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||
let signal_date = ctx.decision_date;
|
||||
let execution_date = ctx.execution_date;
|
||||
let defer_selection_risk = ctx.is_lagged_execution();
|
||||
let lagged_execution = ctx.is_lagged_execution();
|
||||
if self.config.in_skip_window(signal_date) {
|
||||
return Ok(StrategyDecision {
|
||||
rebalance: false,
|
||||
@@ -2595,9 +2622,12 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
.positions()
|
||||
.keys()
|
||||
.cloned()
|
||||
.map(|symbol| OrderIntent::TargetValue {
|
||||
.map(|symbol| OrderIntent::TimedTargetValue {
|
||||
symbol,
|
||||
target_value: 0.0,
|
||||
style: AlgoOrderStyle::Twap,
|
||||
start_time: Some(self.intraday_execution_start_time()),
|
||||
end_time: Some(self.intraday_execution_start_time()),
|
||||
reason: "seasonal_stop_window".to_string(),
|
||||
})
|
||||
.collect(),
|
||||
@@ -2630,8 +2660,8 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
// 使用前一交易日的指数价格计算市值区间(模拟实盘场景)
|
||||
let (band_low, band_high) = self.market_cap_band(prev_index_level);
|
||||
let (stock_list, selection_notes) =
|
||||
self.select_symbols(ctx, signal_date, band_low, band_high, defer_selection_risk)?;
|
||||
let risk_decisions = self.selection_risk_decisions(ctx, signal_date, defer_selection_risk);
|
||||
self.select_symbols(ctx, signal_date, band_low, band_high)?;
|
||||
let risk_decisions = self.selection_risk_decisions(ctx, signal_date);
|
||||
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
|
||||
let projection_date = signal_date;
|
||||
let mut projected = ctx.portfolio.clone();
|
||||
@@ -2656,8 +2686,8 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
<= position.average_cost * self.config.stop_loss_ratio
|
||||
+ self.stop_loss_tolerance(market);
|
||||
let profit_hit = current_price / position.average_cost > self.config.take_profit_ratio;
|
||||
let can_sell = defer_selection_risk
|
||||
|| self.can_sell_position(ctx, execution_date, &position.symbol);
|
||||
let can_sell =
|
||||
lagged_execution || self.can_sell_position(ctx, execution_date, &position.symbol);
|
||||
let at_upper_limit = market.is_at_upper_limit_price(current_price);
|
||||
if stop_hit || (profit_hit && !at_upper_limit) {
|
||||
let sell_reason = if stop_hit {
|
||||
@@ -2693,7 +2723,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !defer_selection_risk
|
||||
if !lagged_execution
|
||||
&& self
|
||||
.buy_rejection_reason(ctx, execution_date, symbol)?
|
||||
.is_some()
|
||||
@@ -2731,7 +2761,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
if stock_list.iter().any(|candidate| candidate == symbol) {
|
||||
continue;
|
||||
}
|
||||
if !defer_selection_risk && !self.can_sell_position(ctx, execution_date, symbol) {
|
||||
if !lagged_execution && !self.can_sell_position(ctx, execution_date, symbol) {
|
||||
continue;
|
||||
}
|
||||
order_intents.push(OrderIntent::TargetValue {
|
||||
@@ -2759,7 +2789,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !defer_selection_risk
|
||||
if !lagged_execution
|
||||
&& self
|
||||
.buy_rejection_reason(ctx, execution_date, symbol)?
|
||||
.is_some()
|
||||
@@ -2990,33 +3020,27 @@ mod tests {
|
||||
default_cfg.stock_long_ma_days = 3;
|
||||
let default_strategy = OmniMicroCapStrategy::new(default_cfg.clone());
|
||||
let (default_selected, _) = default_strategy
|
||||
.select_symbols(&ctx, dates[2], 0.0, 100.0, false)
|
||||
.select_symbols(&ctx, dates[2], 0.0, 100.0)
|
||||
.expect("default selection");
|
||||
assert!(default_selected.is_empty());
|
||||
let default_risk_decisions =
|
||||
default_strategy.selection_risk_decisions(&ctx, dates[2], false);
|
||||
assert_eq!(default_risk_decisions.len(), 1);
|
||||
assert_eq!(default_risk_decisions[0].symbol, symbol);
|
||||
assert_eq!(default_risk_decisions[0].rule_code, "kcb");
|
||||
assert_eq!(default_selected, vec![symbol.to_string()]);
|
||||
let default_risk_decisions = default_strategy.selection_risk_decisions(&ctx, dates[2]);
|
||||
assert!(default_risk_decisions.is_empty());
|
||||
|
||||
let mut cfg = default_cfg;
|
||||
cfg.risk_config.static_rules.reject_kcb_selection = true;
|
||||
let strict_strategy = OmniMicroCapStrategy::new(cfg);
|
||||
let (selected, _) = strict_strategy
|
||||
.select_symbols(&ctx, dates[2], 0.0, 100.0)
|
||||
.expect("strict selection");
|
||||
assert!(selected.is_empty());
|
||||
let strict_risk_decisions = strict_strategy.selection_risk_decisions(&ctx, dates[2]);
|
||||
assert_eq!(strict_risk_decisions.len(), 1);
|
||||
assert_eq!(strict_risk_decisions[0].symbol, symbol);
|
||||
assert_eq!(strict_risk_decisions[0].rule_code, "kcb");
|
||||
assert!(
|
||||
default_risk_decisions[0]
|
||||
strict_risk_decisions[0]
|
||||
.diagnostic_line()
|
||||
.starts_with("risk_decision=")
|
||||
);
|
||||
|
||||
let mut cfg = default_cfg;
|
||||
cfg.risk_config.static_rules.reject_kcb_selection = false;
|
||||
cfg.risk_config.static_rules.reject_kcb_buy = false;
|
||||
let configured_strategy = OmniMicroCapStrategy::new(cfg);
|
||||
let (selected, _) = configured_strategy
|
||||
.select_symbols(&ctx, dates[2], 0.0, 100.0, false)
|
||||
.expect("configured selection");
|
||||
assert!(
|
||||
configured_strategy
|
||||
.selection_risk_decisions(&ctx, dates[2], false)
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
assert_eq!(selected, vec![symbol.to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,29 @@ pub struct StrategyAiCatalog {
|
||||
pub data_lake_fields: Vec<ManualFactorSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StrategyAiHoldingCountContract {
|
||||
#[serde(
|
||||
default,
|
||||
alias = "holdingCount",
|
||||
alias = "holding_count",
|
||||
alias = "targetHoldingCount",
|
||||
alias = "target_holding_count"
|
||||
)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub count: Option<i64>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "kind",
|
||||
alias = "holdingCountMode",
|
||||
alias = "holding_count_mode",
|
||||
alias = "targetHoldingCountMode",
|
||||
alias = "target_holding_count_mode"
|
||||
)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StrategyAiGenerateRequest {
|
||||
pub user_goal: String,
|
||||
@@ -79,6 +102,9 @@ pub struct StrategyAiGenerateRequest {
|
||||
pub market: String,
|
||||
pub benchmark_symbol: String,
|
||||
pub signal_symbol: String,
|
||||
#[serde(default, alias = "holdingCountContract")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -87,9 +113,14 @@ pub struct StrategyAiOptimizeRequest {
|
||||
pub objective: String,
|
||||
pub result_summary: serde_json::Value,
|
||||
pub diagnostics: Vec<String>,
|
||||
#[serde(default, alias = "holdingCountContract")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
|
||||
}
|
||||
|
||||
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益 >= 150% 即视为满足收益目标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同。";
|
||||
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益严格 > 120% 才视为满足收益目标,120.00% 本身不算达标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同。";
|
||||
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "reject_st_selection=false、reject_st_buy=true、reject_star_st_selection=false、reject_star_st_buy=true、reject_paused_selection=false、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=false、reject_inactive_buy=true、reject_inactive_sell=true、reject_new_listing_selection=false、reject_new_listing_buy=true、reject_kcb_selection=false、reject_kcb_buy=true、reject_bjse_selection=false、reject_bjse_buy=true、reject_one_yuan_selection=false、reject_one_yuan_buy=true、respect_allow_buy_sell=true、reject_upper_limit_selection=false、reject_lower_limit_selection=false、reject_upper_limit_buy=true、reject_lower_limit_sell=true、forbid_same_day_rebuy_after_sell=true、blacklist_enabled=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\"";
|
||||
const DEFAULT_RISK_POLICY_DSL_CODE: &str = "reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=true, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\"";
|
||||
|
||||
pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
StrategyAiManual {
|
||||
@@ -104,7 +135,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
"AI 生成策略时只能输出完整 engine-script 代码,不输出 Markdown、解释、推理过程、JSON 包装或手册复述。".to_string(),
|
||||
"表达式字段以运行时字段为准:市值使用 market_cap,流通市值使用 free_float_cap;不要在策略表达式中使用数据库原始字段 float_market_cap。".to_string(),
|
||||
"任意窗口价格均线使用 rolling_mean(\"close\", n) 或 ma(\"close\", n),任意窗口均量使用 rolling_mean(\"volume\", n) 或 vma(n);不要使用未列出的 ma60、stock_ma60、signal_ma60 或 benchmark_ma60 变量。".to_string(),
|
||||
"next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息。".to_string(),
|
||||
"next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(),
|
||||
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
|
||||
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
|
||||
],
|
||||
@@ -219,7 +250,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
},
|
||||
ManualSection {
|
||||
title: "risk.policy / risk.blacklist".to_string(),
|
||||
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_star_st_selection、reject_star_st_buy、reject_paused_selection、reject_paused_buy、reject_paused_sell、reject_inactive_selection、reject_inactive_buy、reject_inactive_sell、reject_new_listing_selection、reject_new_listing_buy、reject_kcb_selection、reject_kcb_buy、reject_bjse_selection、reject_bjse_buy、reject_one_yuan_selection、reject_one_yuan_buy、respect_allow_buy_sell、reject_upper_limit_selection、reject_lower_limit_selection、reject_upper_limit_buy、reject_lower_limit_sell、forbid_same_day_rebuy_after_sell、blacklist_enabled、blacklisted_symbols、volume_limit_enabled、liquidity_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。ST、*ST、停牌、退市、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不要写进 universe.exclude 或 filter.stock_expr。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
|
||||
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_star_st_selection、reject_star_st_buy、reject_paused_selection、reject_paused_buy、reject_paused_sell、reject_inactive_selection、reject_inactive_buy、reject_inactive_sell、reject_new_listing_selection、reject_new_listing_buy、reject_kcb_selection、reject_kcb_buy、reject_bjse_selection、reject_bjse_buy、reject_one_yuan_selection、reject_one_yuan_buy、respect_allow_buy_sell、reject_upper_limit_selection、reject_lower_limit_selection、reject_upper_limit_buy、reject_lower_limit_sell、forbid_same_day_rebuy_after_sell、blacklist_enabled、allow_market_orders、live_trading_enabled、blacklisted_symbols、volume_limit_enabled、liquidity_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。ST、*ST、停牌、退市、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不要写进 universe.exclude 或 filter.stock_expr。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||
@@ -227,7 +258,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
},
|
||||
ManualSection {
|
||||
title: "execution.matching_type / execution.slippage".to_string(),
|
||||
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\");current_bar_close 使用决策日当日 close,next_bar_open 使用决策日信号并在下一可交易日 open 撮合,禁止把执行日 open/high/low/close 解释为下单前已知数据。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
|
||||
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\");current_bar_close 使用决策日当日 close,next_bar_open 使用决策日信号并在下一可交易日 open 撮合,禁止把执行日 open/high/low/close 解释为下单前已知数据;next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。日线调仓现金口径由 execution.rebalance_cash_mode(\"sell_then_buy\" | \"same_point_net\" | \"pre_open_cash\") 或页面/API 参数控制,默认 sell_then_buy;sell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "期货提交校验".to_string(),
|
||||
@@ -335,13 +366,13 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason;可用便捷函数读取状态、成交均价和费用,对齐 平台内核 Order 的核心属性。".to_string() },
|
||||
ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等;DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() },
|
||||
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金;receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力。".to_string() },
|
||||
ManualFunction { name: "rolling_mean / sma / ma".to_string(), signature: "rolling_mean(\"field\", lookback) / ma(\"close\", 20)".to_string(), detail: "任意字段滚动均值,支持 close、volume、amount、turnover_ratio、effective_turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 和所有数值型 extra_factors。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".to_string() },
|
||||
ManualFunction { name: "rolling_mean / sma / ma".to_string(), signature: "rolling_mean(\"field\", lookback) / ma(\"close\", 20)".to_string(), detail: "任意字段滚动均值,支持 close、volume、amount、turnover_ratio、effective_turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 和所有数值型 extra_factors。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".to_string() },
|
||||
ManualFunction { name: "vma".to_string(), signature: "vma(60)".to_string(), detail: "rolling_mean(\"volume\", lookback) 的便捷别名,用于任意窗口成交量均线,例如 vma(5) < vma(60)。".to_string() },
|
||||
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
|
||||
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
|
||||
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
|
||||
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;需要收益率波动时先使用已注册收益率字段或发布因子,不要写 rolling_stddev(pct_change(\"close\", 1), 20)。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
|
||||
ManualFunction { name: "Source Lake 指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "Strategy Factory Source Lake 中已完成 PIT/as-of 审计的 source rows 字段、已发布指标或因子 artifact 会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() },
|
||||
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },
|
||||
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },
|
||||
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs) / safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法,两参数形式默认 fallback=0。".to_string() },
|
||||
ManualFunction { name: "contains/starts_with/ends_with/lower/upper/trim/strlen".to_string(), signature: "starts_with(symbol, \"60\")".to_string(), detail: "字符串辅助函数。".to_string() },
|
||||
],
|
||||
factor_sources: vec![
|
||||
@@ -439,11 +470,12 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
|
||||
out.push_str("## AI 代码生成硬约束\n");
|
||||
out.push_str("- 只输出完整 `engine-script` 代码;第一行必须是 `strategy(\"...\")`、`let`、`fn`、`const` 或 `//`。\n");
|
||||
out.push_str("- 禁止输出 Markdown、解释、推理过程、JSON 包装、手册复述或结果报告。\n");
|
||||
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`risk.policy`、`risk.blacklist`、`execution.matching_type`、`execution.slippage`、`universe.exclude`。\n");
|
||||
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`risk.policy`、`risk.blacklist`、`execution.matching_type`、`execution.rebalance_cash_mode`、`execution.slippage`、`universe.exclude`。\n");
|
||||
out.push_str("- `universe.exclude` 只用于用户明确要求的业务排除项;ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量、手续费和印花税等基础风控必须写 `risk.policy(...)` 或由运行态 RiskLimits 注入。\n");
|
||||
out.push_str("- 禁止伪 DSL:`filter(...)`、`rank(...)`、`select.top(...)`、`weight.equal(...)`、`sell_rule(...)`、`backtest(...)`、`risk.max_position(...)`。\n");
|
||||
out.push_str("- 市值表达式字段只能用 `market_cap` 或 `free_float_cap`;不要使用数据库原始字段 `float_market_cap`。\n");
|
||||
out.push_str("- 任意窗口价格均线使用 `rolling_mean(\"close\", n)` 或 `ma(\"close\", n)`;任意窗口均量使用 `rolling_mean(\"volume\", n)` 或 `vma(n)`;不要使用未列出的 `ma60`、`stock_ma60`、`signal_ma60` 或 `benchmark_ma60` 变量。\n");
|
||||
out.push_str("- `rolling_mean`、`rolling_sum/min/max/stddev/zscore`、`pct_change`、`factor_value` 等 helper 的第一个参数必须是字段名或字符串字段名;不要输出 `rolling_stddev(pct_change(\"close\", 1), 20)` 这类嵌套表达式。\n");
|
||||
out.push_str("- 自定义 `fn` 必须通过参数传入运行时字段;不要用 `fn score()` 这类零参数函数直接引用 `market_cap`、`close`、`ma5` 等股票字段。\n");
|
||||
out.push_str("- `selection.market_cap_band` 必须写命名参数:`field=\"market_cap\"` 或 `field=\"free_float_cap\"`,并包含 `lower=...` 与 `upper=...`。\n");
|
||||
out.push_str(
|
||||
@@ -452,7 +484,7 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
|
||||
out.push_str("- `filter.stock_expr(...)` 只写 alpha 或业务过滤条件;不要把 `!is_st`、`!paused`、`!at_upper_limit`、`!at_lower_limit` 这类基础风控散落在过滤表达式里。\n");
|
||||
out.push_str("- 完整三元表达式 `cond ? a : b` 可在表达式参数中使用;若当前运行环境报 `Unknown operator: '?'`,先重编译并重启回测服务,不要改写策略语义掩盖运行时漂移。\n");
|
||||
out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\n");
|
||||
out.push_str("- `next_bar_open` 必须区分信号日、订单创建日和实际成交日:T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断,禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
|
||||
out.push_str("- `next_bar_open` 必须区分信号日、订单创建日和实际成交日:T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须比较实际 next-open 成交价与涨跌停价,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
|
||||
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
|
||||
out.push_str("## 语句块\n");
|
||||
for item in &manual.statement_blocks {
|
||||
@@ -532,9 +564,11 @@ pub fn build_generation_prompt(
|
||||
prompt.push('\n');
|
||||
prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\n");
|
||||
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、risk.policy、risk.blacklist、execution.matching_type、execution.slippage、universe.exclude。universe.exclude 只用于用户明确要求的业务排除项,不能表达 FIDC 基础风控。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
|
||||
prompt.push_str("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0;filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;risk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,例如 reject_st_selection=true、reject_st_buy=true、reject_star_st_selection=true、reject_star_st_buy=true、reject_paused_selection=true、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=true、reject_new_listing_selection=true、reject_kcb_selection=true、reject_bjse_selection=true、reject_one_yuan_selection=true、forbid_same_day_rebuy_after_sell=true、reject_upper_limit_selection=true、reject_lower_limit_selection=true、reject_upper_limit_buy=true、reject_lower_limit_sell=true、blacklist_enabled=true、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_after_change=0.0005,不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断,禁止用 T 日执行状态拦截 T+1 可交易订单;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n");
|
||||
prompt.push_str(&format!("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);rolling_mean、rolling_sum/min/max/stddev/zscore、pct_change、factor_value 等 helper 的第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0;filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;risk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,必须覆盖完整默认配置面,例如 {DEFAULT_RISK_POLICY_DSL_PROMPT},不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n"));
|
||||
prompt.push_str("回测成功但 tradeCount=0 或 holdingCount=0 是无效策略;第一版必须保持稳定买入覆盖率,复杂因子只能在后续优化中逐步加严。\n");
|
||||
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && close > 2)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.policy(reject_st_selection=true, reject_st_buy=true, reject_star_st_selection=true, reject_star_st_buy=true, reject_paused_selection=true, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=true, reject_new_listing_selection=true, reject_kcb_selection=true, reject_bjse_selection=true, reject_one_yuan_selection=true, reject_upper_limit_selection=true, reject_lower_limit_selection=true, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5)\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
|
||||
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && close > 2)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.policy(");
|
||||
prompt.push_str(DEFAULT_RISK_POLICY_DSL_CODE);
|
||||
prompt.push_str(")\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
|
||||
prompt.push_str("用户目标:\n");
|
||||
prompt.push_str(&format!("- {}\n", request.user_goal));
|
||||
if !request.constraints.is_empty() {
|
||||
@@ -564,7 +598,7 @@ pub fn build_optimization_prompt(
|
||||
prompt.push_str("持仓数量属于策略合同,不是优化自由参数。原策略或用户目标明确 stocknum、selection.limit、目标持仓N只或不少于N只时,优化后必须保留该目标槽位或满足最低槽位,不能为了收益或交易次数擅自改小。\n");
|
||||
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
|
||||
prompt.push('\n');
|
||||
prompt.push_str("可以使用 Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计的日频 source rows 字段、已发布指标/因子 artifact 和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
|
||||
prompt.push_str("可以使用 Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计的日频 source rows 字段、已发布指标/因子 artifact 和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;这些滚动/因子 helper 的字段参数只能是字段名或字符串字段名,不要嵌套表达式;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
|
||||
prompt.push_str("优化目标:\n");
|
||||
prompt.push_str(&format!("- {}\n\n", request.objective));
|
||||
prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n");
|
||||
@@ -602,10 +636,11 @@ mod tests {
|
||||
market: "CN_A".to_string(),
|
||||
benchmark_symbol: "000852.SH".to_string(),
|
||||
signal_symbol: "000001.SH".to_string(),
|
||||
holding_count_contract: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||
assert!(prompt.contains("三年回测区间策略总收益严格 > 120% 才视为满足收益目标"));
|
||||
assert!(prompt.contains("不得把已达标策略判为失败"));
|
||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
|
||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||
@@ -613,6 +648,16 @@ mod tests {
|
||||
assert!(prompt.contains("T 日只生成订单意图"));
|
||||
assert!(prompt.contains("按实际成交日判断"));
|
||||
assert!(prompt.contains("禁止用 T 日执行状态拦截 T+1 可交易订单"));
|
||||
assert!(prompt.contains("必须覆盖完整默认配置面"));
|
||||
assert!(prompt.contains("reject_inactive_buy=true"));
|
||||
assert!(prompt.contains("reject_inactive_sell=true"));
|
||||
assert!(prompt.contains("reject_new_listing_buy=true"));
|
||||
assert!(prompt.contains("reject_kcb_buy=true"));
|
||||
assert!(prompt.contains("reject_bjse_buy=true"));
|
||||
assert!(prompt.contains("reject_one_yuan_buy=true"));
|
||||
assert!(prompt.contains("respect_allow_buy_sell=true"));
|
||||
assert!(prompt.contains("stamp_tax_rate_before_change=0.001"));
|
||||
assert!(prompt.contains("stamp_tax_change_date=\"2023-08-28\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -624,10 +669,11 @@ mod tests {
|
||||
objective: "优化收益".to_string(),
|
||||
result_summary: json!({ "total_return": 1.49 }),
|
||||
diagnostics: Vec::new(),
|
||||
holding_count_contract: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||
assert!(prompt.contains("三年回测区间策略总收益严格 > 120% 才视为满足收益目标"));
|
||||
assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性"));
|
||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
|
||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||
|
||||
@@ -55,15 +55,11 @@ pub struct SelectionContext<'a> {
|
||||
|
||||
impl SelectionContext<'_> {
|
||||
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
|
||||
let eligible = if self.defer_selection_risk {
|
||||
self.data.fundamental_universe_on(self.decision_date)
|
||||
} else {
|
||||
match self.risk_config {
|
||||
Some(risk_config) => self
|
||||
.data
|
||||
.eligible_universe_on_with_risk_config(self.decision_date, risk_config),
|
||||
None => self.data.eligible_universe_on(self.decision_date).to_vec(),
|
||||
}
|
||||
let eligible = match (self.risk_config, self.defer_selection_risk) {
|
||||
(Some(risk_config), false) => self
|
||||
.data
|
||||
.eligible_universe_on_with_risk_config(self.decision_date, risk_config),
|
||||
_ => self.data.eligible_universe_on(self.decision_date).to_vec(),
|
||||
};
|
||||
match self.dynamic_universe {
|
||||
Some(symbols) if !symbols.is_empty() => eligible
|
||||
@@ -75,9 +71,6 @@ impl SelectionContext<'_> {
|
||||
}
|
||||
|
||||
fn selection_risk_decisions(&self) -> Vec<FidcRiskDecisionAudit> {
|
||||
if self.defer_selection_risk {
|
||||
return Vec::new();
|
||||
}
|
||||
let default_risk_config;
|
||||
let risk_config = match self.risk_config {
|
||||
Some(value) => value,
|
||||
@@ -404,13 +397,16 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
let (_selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&FidcRiskControlConfig::default()),
|
||||
risk_config: Some(&risk_config),
|
||||
defer_selection_risk: false,
|
||||
});
|
||||
|
||||
@@ -433,7 +429,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_defers_static_execution_risk_for_lagged_execution() {
|
||||
fn selector_applies_configured_selection_risk_on_decision_date() {
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
instrument("000001.SZ"),
|
||||
@@ -459,13 +455,74 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&FidcRiskControlConfig::default()),
|
||||
risk_config: Some(&risk_config),
|
||||
defer_selection_risk: false,
|
||||
});
|
||||
|
||||
let selected_symbols = selected
|
||||
.iter()
|
||||
.map(|candidate| candidate.symbol.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(!selected_symbols.contains("000001.SZ"));
|
||||
assert!(!selected_symbols.contains("688001.SH"));
|
||||
assert!(selected_symbols.contains("000002.SZ"));
|
||||
assert_eq!(diagnostics.not_eligible_count, 2);
|
||||
let rules = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.map(|decision| decision.rule_code.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
|
||||
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_can_defer_configured_selection_risk_without_losing_diagnostics() {
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
instrument("000001.SZ"),
|
||||
instrument("688001.SH"),
|
||||
instrument("000002.SZ"),
|
||||
],
|
||||
vec![
|
||||
market("000001.SZ", 10.0),
|
||||
market("688001.SH", 10.0),
|
||||
market("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
factor("000001.SZ", 8.0),
|
||||
factor("688001.SH", 9.0),
|
||||
factor("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
candidate("000001.SZ", true, false),
|
||||
candidate("688001.SH", false, true),
|
||||
candidate("000002.SZ", false, false),
|
||||
],
|
||||
vec![benchmark()],
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
|
||||
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&risk_config),
|
||||
defer_selection_risk: true,
|
||||
});
|
||||
|
||||
@@ -476,7 +533,12 @@ mod tests {
|
||||
assert!(selected_symbols.contains("000001.SZ"));
|
||||
assert!(selected_symbols.contains("688001.SH"));
|
||||
assert!(selected_symbols.contains("000002.SZ"));
|
||||
assert_eq!(diagnostics.not_eligible_count, 0);
|
||||
assert!(diagnostics.risk_decisions.is_empty());
|
||||
let rules = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.map(|decision| decision.rule_code.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
|
||||
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ fn broker_executes_explicit_order_value_buy() {
|
||||
ask1: 10.01,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -551,7 +551,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
||||
ask1: 10.01,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -985,7 +985,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
||||
bid1: 9.99,
|
||||
ask1_volume: 1,
|
||||
bid1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -998,7 +998,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
||||
bid1: 10.19,
|
||||
ask1_volume: 1,
|
||||
bid1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -1784,7 +1784,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
||||
ask1: 10.01,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -1937,7 +1937,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_executes_intraday_last_on_start_quote_without_trade_delta() {
|
||||
fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
vec![Instrument {
|
||||
@@ -2013,7 +2013,7 @@ fn broker_executes_intraday_last_on_start_quote_without_trade_delta() {
|
||||
ask1: 15.21,
|
||||
bid1_volume: 8,
|
||||
ask1_volume: 8,
|
||||
volume_delta: 0,
|
||||
volume_delta: 800,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -2131,7 +2131,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
||||
ask1: 10.03,
|
||||
bid1_volume: 2,
|
||||
ask1_volume: 2,
|
||||
volume_delta: 1,
|
||||
volume_delta: 800,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -2368,7 +2368,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
||||
ask1: 10.02,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2381,7 +2381,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
||||
ask1: 10.04,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2531,7 +2531,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
||||
ask1: 10.02,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2544,7 +2544,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
||||
ask1: 10.04,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2678,7 +2678,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
ask1: 9.99,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2691,7 +2691,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
ask1: 10.02,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2704,7 +2704,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
ask1: 10.04,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2717,7 +2717,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
ask1: 10.11,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2838,7 +2838,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
ask1: 12.01,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2851,7 +2851,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
ask1: 12.04,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2864,7 +2864,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
ask1: 12.07,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2999,7 +2999,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
||||
ask1: 10.02,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -3116,7 +3116,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
||||
ask1: 10.02,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 1,
|
||||
volume_delta: 400,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -3349,7 +3349,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebalance_uses_prev_close_for_open_auction_valuation() {
|
||||
fn rebalance_uses_day_open_for_open_auction_valuation() {
|
||||
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
|
||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let data = DataSet::from_components(
|
||||
@@ -3515,7 +3515,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
|
||||
let held = portfolio.position("000001.SZ").expect("held position");
|
||||
let target = portfolio.position("000002.SZ").expect("target position");
|
||||
assert_eq!(held.quantity, 500);
|
||||
assert_eq!(target.quantity, 400);
|
||||
assert_eq!(target.quantity, 900);
|
||||
assert_eq!(report.fill_events.len(), 2);
|
||||
assert!(
|
||||
report
|
||||
@@ -3531,7 +3531,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
|
||||
.iter()
|
||||
.any(|fill| fill.symbol == "000002.SZ"
|
||||
&& fill.side == fidc_core::OrderSide::Buy
|
||||
&& fill.quantity == 400)
|
||||
&& fill.quantity == 900)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3725,6 +3725,179 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "TargetA".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
},
|
||||
Instrument {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
name: "TargetB".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2024-01-10 10:18:00".to_string()),
|
||||
day_open: 82.0,
|
||||
open: 82.0,
|
||||
high: 83.0,
|
||||
low: 81.0,
|
||||
close: 82.0,
|
||||
last_price: 82.0,
|
||||
bid1: 81.99,
|
||||
ask1: 82.01,
|
||||
prev_close: 82.0,
|
||||
volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 80_000,
|
||||
ask1_volume: 80_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 90.2,
|
||||
lower_limit: 73.8,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: Some("2024-01-10 10:18:00".to_string()),
|
||||
day_open: 82.0,
|
||||
open: 82.0,
|
||||
high: 83.0,
|
||||
low: 81.0,
|
||||
close: 82.0,
|
||||
last_price: 82.0,
|
||||
bid1: 81.99,
|
||||
ask1: 82.01,
|
||||
prev_close: 82.0,
|
||||
volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 80_000,
|
||||
ask1_volume: 80_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 90.2,
|
||||
lower_limit: 73.8,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 50.0,
|
||||
free_float_cap_bn: 45.0,
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
market_cap_bn: 60.0,
|
||||
free_float_cap_bn: 50.0,
|
||||
pe_ttm: 18.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
let mut portfolio = PortfolioState::new(100_000.0);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
|
||||
broker
|
||||
.execute(
|
||||
date,
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([
|
||||
("000001.SZ".to_string(), 0.48),
|
||||
("000002.SZ".to_string(), 0.48),
|
||||
]),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("broker execution");
|
||||
|
||||
assert_eq!(
|
||||
portfolio
|
||||
.position("000001.SZ")
|
||||
.map(|position| position.quantity),
|
||||
Some(500)
|
||||
);
|
||||
assert_eq!(
|
||||
portfolio
|
||||
.position("000002.SZ")
|
||||
.map(|position| position.quantity),
|
||||
Some(500)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
|
||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
|
||||
@@ -21,16 +21,17 @@ runtime_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'fidatacenter|FIDATACENTER|/v1/backtest/data|/v1/xuntou|ClickHouse|clickhouse|CLICKHOUSE' \
|
||||
'fidatacenter|FIDATACENTER|/v1/backtest/data|/v1/xuntou|ClickHouse|clickhouse|CLICKHOUSE|FIDC_BT_INSTRUMENT_METADATA_CSV|FIDC_BT_WRITE_SNAPSHOTS|FIDC_BT_WRITE_CSV_SNAPSHOTS|FIDC_BT_WRITE_COMBINED_SOURCE_ROW_CACHE|write_csv_snapshot_files|write_csv\(|FIDC_RISK_RUNTIME_FILE|FIDC_RISK_RUNTIME_URL|FIDC_FIRISK_RUNTIME_FILE|FiRisk runtime snapshot' \
|
||||
crates Cargo.toml \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
|
||||
if [[ -n "$runtime_hits" ]]; then
|
||||
fail "legacy fidatacenter/ClickHouse runtime data source references are not allowed in fidc-backtest-engine" "$runtime_hits"
|
||||
fail "legacy fidatacenter/ClickHouse/CSV snapshot/FiRisk runtime data source references are not allowed in fidc-backtest-engine" "$runtime_hits"
|
||||
fi
|
||||
|
||||
manifest_hits="$(
|
||||
@@ -48,10 +49,11 @@ local_only_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'FICLAW_DATA_AGENT_URL|ficlaw_data\.source_rows_v1|strategy-factory-source-lake://local' \
|
||||
'FICLAW_DATA_AGENT_URL|ficlaw_data\.source_rows_v1|strategy-factory-source-lake://local|FIDC_BT_WRITE_COMBINED_SOURCE_ROW_CACHE' \
|
||||
crates Cargo.toml \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
@@ -67,7 +69,8 @@ json_query_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'/v1/query/(source-rows|daily-execution-prices|minute-execution-prices|instruments|corporate-actions)\.json' \
|
||||
@@ -86,7 +89,8 @@ fused_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'exported_fused|fidc_fused|fusion|fused|wide_table|wide table|source_rows_export|exported_daily|merged_daily|daily_merged|merged_source|materialized[_ -]source|materialized_source_rows|source_rows_materialized|融合表|融合宽表' \
|
||||
@@ -105,7 +109,8 @@ feature_store_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'research_feature_store|feature_store|FEATURE_STORE|daily_minute_current|FIDC_STRATEGY_FACTORY_ENABLE_FEATURE_STORE_CACHE|ALPHA_FACTORY_ENABLE_FEATURE_STORE_CACHE|ENABLE_FEATURE_STORE_CACHE' \
|
||||
@@ -124,7 +129,8 @@ truth_csv_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'FIDC_BT_TRUTH_STOCK_LIST_CSV|OMNI_BT_TRUTH_STOCK_LIST_CSV|OMNI_BACKTEST_TRUTH_STOCK_LIST_CSV|selection_source=truth_csv|truth_stock_list|truth_csv' \
|
||||
@@ -143,7 +149,8 @@ csv_snapshot_loader_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'from_csv_dir|from_partitioned_dir|instruments\.csv|candidate_flags\.csv|market\.csv|benchmark\.csv' \
|
||||
crates Cargo.toml \
|
||||
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
|
||||
fail() {
|
||||
local message="$1"
|
||||
local details="${2:-}"
|
||||
printf '[FAIL] %s\n' "$message" >&2
|
||||
if [[ -n "$details" ]]; then
|
||||
printf '%s\n' "$details" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_core_test() {
|
||||
local filter="$1"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
printf '[INFO] cargo test -p fidc-core %s\n' "$filter"
|
||||
if cargo test -p fidc-core "$filter" -- --nocapture 2>&1 | tee "$tmp"; then
|
||||
local passed_count
|
||||
passed_count="$(
|
||||
"$PYTHON_BIN" - "$tmp" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
count = 0
|
||||
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
|
||||
match = re.search(r"test result: ok\. (\d+) passed;", line)
|
||||
if match:
|
||||
count += int(match.group(1))
|
||||
print(count)
|
||||
PY
|
||||
)"
|
||||
rm -f "$tmp"
|
||||
if [[ "$passed_count" -le 0 ]]; then
|
||||
fail "cargo test filter matched 0 tests: package=fidc-core filter=${filter}"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
local output
|
||||
output="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
fail "cargo test failed: package=fidc-core filter=${filter}" "$output"
|
||||
}
|
||||
|
||||
run_core_test eligible_universe_does_not_require_candidate_risk_state_when_selection_risk_is_disabled
|
||||
run_core_test next_bar_open_eligible_universe_helper_does_not_block_on_decision_day_risk
|
||||
run_core_test platform_next_open_selection_records_risk_diagnostics_without_filtering
|
||||
run_core_test next_open_buy_risk_uses_execution_date_not_signal_date
|
||||
run_core_test next_open_buy_limit_risk_uses_open_not_close
|
||||
run_core_test next_open_sell_risk_uses_execution_date_not_signal_date
|
||||
run_core_test next_open_sell_limit_risk_uses_open_not_close
|
||||
run_core_test next_bar_open_sell_respects_allow_sell_policy_on_execution_day
|
||||
run_core_test volume_limit_uses_floor_for_odd_lot_sell
|
||||
run_core_test configurable_upper_limit_buy_filter_can_be_disabled
|
||||
|
||||
printf '[OK] fidc-backtest-engine runtime risk contracts passed\n'
|
||||
Reference in New Issue
Block a user