Compare commits

..

1 Commits

Author SHA1 Message Date
boris 7f60bfac1d 修正FIDC退市风控原因识别 2026-07-04 16:52:11 +08:00
17 changed files with 909 additions and 11181 deletions
File diff suppressed because it is too large Load Diff
+106 -140
View File
@@ -448,12 +448,49 @@ pub struct EligibleUniverseSnapshot {
pub free_float_cap_bn: f64,
}
pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
factor.market_cap_bn
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_free_float_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
factor.free_float_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)
}
#[derive(Debug, Clone)]
@@ -2456,7 +2493,14 @@ 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.market_by_date))
.get_or_init(|| {
build_eligible_universe(
&self.factor_by_date,
&self.candidate_by_date,
&self.market_by_date,
&self.instruments,
)
})
.get(&date)
.map(Vec::as_slice)
.unwrap_or(&[])
@@ -2989,12 +3033,22 @@ 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 in factor_by_date.keys() {
let rows = build_fundamental_universe_for_date(*date, factor_by_date, market_by_date);
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,
);
per_date.insert(*date, rows);
}
@@ -3011,21 +3065,20 @@ fn build_fundamental_universe_for_date(
return rows;
};
for factor in factors {
if market_by_date
let Some(market) = market_by_date
.get(&date)
.and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()))
.is_none()
{
else {
continue;
}
let market_cap_bn = decision_market_cap_bn(factor);
};
let market_cap_bn = decision_market_cap_bn(factor, market);
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),
free_float_cap_bn: decision_free_float_cap_bn(factor, market),
});
}
rows.sort_by(|left, right| {
@@ -3073,15 +3126,11 @@ fn build_eligible_universe_for_date_from_factors(
if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() {
continue;
}
let synthetic_candidate;
let candidate = if let Some(candidate) = candidate_by_date
let Some(candidate) = candidate_by_date
.get(&date)
.and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()))
{
candidate
} else {
synthetic_candidate = missing_candidate_risk_state(date, &factor.symbol);
&synthetic_candidate
else {
continue;
};
let Some(market) = market_by_date
.get(&date)
@@ -3100,11 +3149,11 @@ fn build_eligible_universe_for_date_from_factors(
{
continue;
}
let market_cap_bn = decision_market_cap_bn(factor);
let market_cap_bn = decision_market_cap_bn(factor, market);
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
continue;
}
let free_float_cap_bn = decision_free_float_cap_bn(factor);
let free_float_cap_bn = decision_free_float_cap_bn(factor, market);
rows.push(EligibleUniverseSnapshot {
symbol: factor.symbol.clone(),
market_cap_bn,
@@ -3120,25 +3169,6 @@ 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()
@@ -3408,95 +3438,11 @@ mod tests {
let rows = data.eligible_universe_on(date);
assert_eq!(rows.len(), 2);
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"
);
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);
}
#[test]
@@ -3570,13 +3516,8 @@ mod tests {
)
.expect("dataset");
assert_eq!(data.eligible_universe_on(date).len(), 1);
assert!(data.eligible_universe_on(date).is_empty());
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);
@@ -3585,8 +3526,33 @@ mod tests {
}
#[test]
fn decision_market_cap_uses_factor_date_snapshot_without_price_reconstruction() {
fn decision_market_cap_keeps_pre_adjusted_factor() {
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(),
@@ -3595,11 +3561,11 @@ mod tests {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
extra_factors,
};
assert!((decision_market_cap_bn(&factor) - 12.0).abs() < 1e-9);
assert!((decision_free_float_cap_bn(&factor) - 4.0).abs() < 1e-9);
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);
}
#[test]
+24 -369
View File
@@ -1192,9 +1192,6 @@ 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
@@ -1232,9 +1229,6 @@ 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,
@@ -1652,7 +1646,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 calendar_dates = self
let execution_dates = self
.data
.calendar()
.iter()
@@ -1663,41 +1657,11 @@ 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
@@ -1740,54 +1704,31 @@ where
&mut portfolio,
&mut corporate_action_notes,
);
self.extend_result(
&mut result,
pending_cash_flow_report,
execution_date,
execution_date,
);
self.extend_result(&mut result, pending_cash_flow_report);
let corporate_action_report = self.apply_corporate_actions(
execution_date,
&mut portfolio,
&mut corporate_action_notes,
)?;
self.extend_result(
&mut result,
corporate_action_report,
execution_date,
execution_date,
);
self.extend_result(&mut result, corporate_action_report);
let receivable_report = self.settle_cash_receivables(
execution_date,
&mut portfolio,
&mut corporate_action_notes,
)?;
self.extend_result(
&mut result,
receivable_report,
execution_date,
execution_date,
);
self.extend_result(&mut result, receivable_report);
let delisting_report = self.settle_delisted_positions(
execution_date,
&mut portfolio,
&mut corporate_action_notes,
)?;
self.extend_result(
&mut result,
delisting_report,
execution_date,
execution_date,
);
self.extend_result(&mut result, delisting_report);
let futures_open_order_report = self.process_futures_open_orders(execution_date);
self.extend_result(
&mut result,
futures_open_order_report,
execution_date,
execution_date,
);
self.extend_result(&mut result, futures_open_order_report);
let decision_slot = decision_slots.get(execution_idx).copied().flatten();
let decision_slot = execution_idx
.checked_sub(self.config.decision_lag_trading_days)
.map(|decision_idx| (decision_idx, execution_dates[decision_idx]));
let Some((decision_index, decision_date)) = decision_slot else {
let mut process_events = Vec::new();
let mut report = BrokerExecutionReport::default();
@@ -1809,7 +1750,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, execution_date, execution_date);
self.extend_result(&mut result, report);
result.risk_decisions.extend(execution_risk_decisions);
let benchmark =
@@ -2073,10 +2014,8 @@ where
None,
None,
)?;
let mut report = self.broker.execute_with_event_dates(
let mut report = self.broker.execute(
execution_date,
decision_date,
decision_date,
&mut portfolio,
&self.data,
&auction_decision,
@@ -2321,14 +2260,9 @@ where
None,
None,
)?;
let mut intraday_report = self.broker.execute_with_event_dates(
execution_date,
decision_date,
decision_date,
&mut portfolio,
&self.data,
&decision,
)?;
let mut intraday_report =
self.broker
.execute(execution_date, &mut portfolio, &self.data, &decision)?;
let post_intraday_open_orders = self.open_order_views();
publish_process_events(
&mut self.strategy,
@@ -2492,10 +2426,8 @@ where
Some(minute_time),
Some(minute_time),
)?;
let mut minute_report = self.broker.execute_between_with_event_dates(
let mut minute_report = self.broker.execute_between(
execution_date,
decision_date,
decision_date,
&mut portfolio,
&self.data,
&minute_decision,
@@ -2818,7 +2750,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, decision_date, execution_date);
self.extend_result(&mut result, report);
result.risk_decisions.extend(decision.risk_decisions);
result.risk_decisions.extend(execution_risk_decisions);
@@ -2906,11 +2838,8 @@ where
fn extend_result(
&self,
result: &mut BacktestResult,
mut report: BrokerExecutionReport,
decision_date: NaiveDate,
execution_date: NaiveDate,
report: BrokerExecutionReport,
) -> 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
@@ -3134,9 +3063,6 @@ 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,
@@ -3440,9 +3366,6 @@ 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,
@@ -3453,9 +3376,6 @@ 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,
@@ -3907,24 +3827,6 @@ 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()
@@ -4119,9 +4021,6 @@ 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,
@@ -4251,78 +4150,6 @@ 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,
@@ -4952,103 +4779,9 @@ 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);
@@ -5084,22 +4817,9 @@ 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_with_risk_config(first, &selection_risk_config)
.is_empty(),
"explicit raw DataSet selection risk can still filter the universe"
dataset.eligible_universe_on(first).is_empty(),
"raw DataSet helper remains a risk-filtered universe"
);
assert_eq!(
manual_ctx
@@ -5112,7 +4832,7 @@ mod tests {
);
assert_eq!(
manual_ctx
.eligible_universe_on_with_risk_config(first, &selection_risk_config)
.eligible_universe_on_with_risk_config(first, &FidcRiskControlConfig::default())
.into_iter()
.map(|row| row.symbol)
.collect::<Vec<_>>(),
@@ -5305,30 +5025,6 @@ 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);
@@ -5463,47 +5159,6 @@ 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);
-39
View File
@@ -23,33 +23,6 @@ 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,
@@ -90,12 +63,6 @@ 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,
@@ -110,12 +77,6 @@ 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,
-12
View File
@@ -746,9 +746,6 @@ impl FuturesAccountState {
);
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id,
symbol: intent.symbol,
side,
@@ -826,9 +823,6 @@ 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,
@@ -895,9 +889,6 @@ impl FuturesAccountState {
});
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id,
symbol: intent.symbol,
side,
@@ -924,9 +915,6 @@ impl FuturesAccountState {
);
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id,
symbol: intent.symbol,
side,
+4 -6
View File
@@ -20,8 +20,7 @@ pub mod strategy_ai;
pub mod universe;
pub use broker::{
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, RebalanceCashMode,
SlippageModel,
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, SlippageModel,
};
pub use calendar::TradingCalendar;
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
@@ -85,10 +84,9 @@ pub use strategy::{
};
pub use strategy_ai::{
ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction,
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiHoldingCountContract,
StrategyAiManual, StrategyAiOptimizeRequest, build_generation_prompt,
build_optimization_prompt, built_in_strategy_manual, merge_catalog_into_manual,
render_manual_markdown,
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, 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,7 +140,6 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
"ask1_volume",
"turnover_ratio",
"effective_turnover_ratio",
"up_days_stock",
"open",
"high",
"low",
@@ -157,7 +156,6 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
"is_st",
"is_star_st",
"is_kcb",
"is_bjse",
"is_one_yuan",
"is_new_listing",
"allow_buy",
@@ -330,7 +328,6 @@ mod tests {
"avg_cost",
"current_price",
"stock_ma_short",
"up_days_stock",
] {
assert!(
names.contains(required),
+25 -493
View File
@@ -8,7 +8,7 @@ use crate::{
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel,
PlatformUniverseActionKind, ScheduleTimeRule, SlippageModel,
};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -105,10 +105,6 @@ 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)]
@@ -118,8 +114,6 @@ 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")]
@@ -179,22 +173,10 @@ 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)]
@@ -254,10 +236,6 @@ 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",
@@ -329,8 +307,6 @@ 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"]),
];
@@ -388,55 +364,6 @@ 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>,
@@ -457,25 +384,17 @@ 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 parsed_values = Vec::with_capacity(values.len());
let mut merged = false;
for value in values {
parsed_values.push(parse_policy_bool(canonical, value)?);
merged |= parse_policy_bool(canonical, value)?;
}
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));
object.insert((*canonical).to_string(), Value::Bool(merged));
}
}
for (canonical, aliases) in RISK_POLICY_VALUE_ALIAS_GROUPS {
let values = remove_policy_alias_values(object, canonical, aliases);
if !values.is_empty() {
reject_conflicting_policy_values(canonical, &values)?;
let value = values.into_iter().next().expect("non-empty values");
if let Some(value) = values.into_iter().next() {
object.insert((*canonical).to_string(), value);
}
}
@@ -691,23 +610,9 @@ 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)]
@@ -884,13 +789,10 @@ fn apply_flat_risk_overrides(
let value = normalize_percent_ratio(raw_value, "volume_percent")?;
cfg.risk_config.trading_constraints.volume_percent = value;
}
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.quote_quantity_limit = cfg.risk_config.trading_constraints.volume_limit_enabled
|| cfg.risk_config.trading_constraints.liquidity_limit_enabled
|| cfg.matching_type != MatchingType::MinuteLast;
Ok(())
}
fn apply_risk_policy_overrides(
@@ -1007,22 +909,6 @@ 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);
@@ -1037,26 +923,6 @@ 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>,
@@ -1069,7 +935,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_slippage_model_name)
.map(normalize_model_name)
.filter(|item| !item.is_empty())
.unwrap_or_else(|| {
if value.is_some_and(|item| item > 0.0) {
@@ -1084,11 +950,13 @@ 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" => 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" | "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),
)))
}
_ => None,
}
}
@@ -1096,24 +964,16 @@ 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()
@@ -1133,26 +993,6 @@ 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(())
}
@@ -1239,17 +1079,6 @@ 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,
@@ -1261,7 +1090,6 @@ 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);
@@ -1342,9 +1170,6 @@ 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()
@@ -1430,13 +1255,11 @@ 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,
)?;
}
@@ -1571,16 +1394,7 @@ pub fn platform_expr_config_from_spec(
.as_ref()
.filter(|value| !value.trim().is_empty())
{
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()
};
cfg.exposure_expr = expr.clone();
}
if let Some(expr) = risk
.stop_loss_expr
@@ -1634,18 +1448,6 @@ 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;
}
@@ -1655,9 +1457,6 @@ 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;
}
@@ -1768,7 +1567,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!(
"signal_ma_short < signal_ma_long * {} ? {} : {}",
"benchmark_ma_short < benchmark_ma_long * {} ? {} : {}",
ratio, defensive, full
);
}
@@ -1802,16 +1601,14 @@ 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 = 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())
});
let aiquant_profile = is_aiquant_profile(
spec.engine_config
.as_ref()
.and_then(|engine| engine.profile_name.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()
@@ -1868,16 +1665,13 @@ 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
@@ -1923,15 +1717,7 @@ fn signal_rebalance_dates(rebalance: &StrategyRebalanceSpec) -> Option<BTreeSet<
.unwrap_or("")
.trim()
.to_ascii_lowercase();
if !matches!(
frequency.as_str(),
"signal_dates"
| "signal-dates"
| "signal dates"
| "daily_model_score_rank"
| "dynamic_model_score_rank"
| "model_rank_rotation"
) {
if frequency != "signal_dates" && frequency != "signal-dates" && frequency != "signal dates" {
return None;
}
let dates = rebalance
@@ -2372,10 +2158,7 @@ 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", "bjse"]
);
assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
assert!(!cfg.rotation_enabled);
assert!(cfg.daily_top_up_enabled);
assert!(cfg.retry_empty_rebalance);
@@ -2402,24 +2185,6 @@ 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!({
@@ -2442,26 +2207,6 @@ 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!({
@@ -2564,24 +2309,6 @@ 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!({
@@ -2696,63 +2423,6 @@ 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!({
@@ -2778,78 +2448,6 @@ 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 [
@@ -2963,9 +2561,6 @@ mod tests {
"runtimeExpressions": {
"trading": {
"dailyTopUp": false,
"dailyPositionTargetAdjust": false,
"rebalanceExistingPositions": true,
"selectionBufferMultiple": 1.5,
"retryEmptyRebalance": false
}
}
@@ -2974,9 +2569,6 @@ 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);
}
@@ -3064,40 +2656,6 @@ 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!({
@@ -3141,32 +2699,6 @@ 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!({
+5 -98
View File
@@ -82,8 +82,6 @@ impl Position {
return;
}
let previous_quantity = self.quantity;
let previous_average_cost = self.average_cost;
self.lots.push(PositionLot {
acquired_date: date,
quantity,
@@ -95,18 +93,7 @@ impl Position {
self.day_trade_quantity_delta += quantity as i32;
self.day_buy_quantity += quantity;
self.day_buy_value += execution_price * quantity as f64;
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.recalculate_average_cost();
self.refresh_day_pnl();
}
@@ -130,7 +117,6 @@ 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 {
@@ -155,13 +141,7 @@ impl Position {
self.day_trade_quantity_delta -= quantity as i32;
self.day_sell_quantity += quantity;
self.day_sell_value += execution_price * quantity as f64;
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.recalculate_average_cost();
self.refresh_day_pnl();
Ok(realized)
}
@@ -272,11 +252,7 @@ impl Position {
}
if let Some(lot) = self.lots.last_mut() {
lot.price += cost / quantity as f64;
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.recalculate_average_cost();
}
self.day_trade_cost += cost;
self.refresh_day_pnl();
@@ -394,11 +370,7 @@ impl Position {
self.lots = scaled_lots;
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
self.last_price /= ratio;
if self.average_cost.is_finite() && self.average_cost > 0.0 {
self.average_cost /= ratio;
} else {
self.recalculate_average_cost();
}
self.recalculate_average_cost();
self.day_split_ratio *= ratio;
self.refresh_day_pnl();
self.quantity as i32 - old_quantity as i32
@@ -865,7 +837,6 @@ 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
@@ -901,8 +872,6 @@ 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;
@@ -910,30 +879,7 @@ impl PortfolioState {
if converted_last_price > 0.0 {
successor.last_price = converted_last_price;
}
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.recalculate_average_cost();
successor.refresh_day_pnl();
Some(SuccessorConversionOutcome {
@@ -1009,45 +955,6 @@ 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();
+48 -110
View File
@@ -45,27 +45,27 @@ pub struct StaticRiskRuleConfig {
impl Default for StaticRiskRuleConfig {
fn default() -> Self {
Self {
reject_st_selection: false,
reject_st_selection: true,
reject_st_buy: true,
reject_star_st_selection: false,
reject_star_st_selection: true,
reject_star_st_buy: true,
reject_paused_selection: false,
reject_paused_selection: true,
reject_paused_buy: true,
reject_paused_sell: true,
reject_inactive_selection: false,
reject_inactive_selection: true,
reject_inactive_buy: true,
reject_inactive_sell: true,
reject_new_listing_selection: false,
reject_new_listing_selection: true,
reject_new_listing_buy: true,
reject_kcb_selection: false,
reject_kcb_selection: true,
reject_kcb_buy: true,
reject_bjse_selection: false,
reject_bjse_selection: true,
reject_bjse_buy: true,
reject_one_yuan_selection: false,
reject_one_yuan_selection: true,
reject_one_yuan_buy: true,
respect_allow_buy_sell: true,
reject_upper_limit_selection: false,
reject_lower_limit_selection: false,
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,
@@ -223,21 +223,6 @@ 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,
@@ -270,6 +255,11 @@ 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)
@@ -516,9 +506,10 @@ impl ChinaAShareRiskControl {
if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) {
return Some("paused");
}
if config.static_rules.respect_allow_buy_sell && !candidate.allow_sell {
return Some("sell_disabled");
}
// `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.reject_lower_limit_sell
&& market.is_at_lower_limit_price(check_price)
{
@@ -599,7 +590,8 @@ 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.reject_lower_limit_selection
|| config.static_rules.respect_allow_buy_sell;
}
missing_field_rejected(&fields, config, RiskCheckScope::Selection)
}
@@ -695,13 +687,15 @@ fn missing_single_field_rejected(
RiskCheckScope::Sell => false,
},
"allow_buy" => match scope {
RiskCheckScope::Selection => false,
RiskCheckScope::Buy => config.static_rules.respect_allow_buy_sell,
RiskCheckScope::Selection | RiskCheckScope::Buy => {
config.static_rules.respect_allow_buy_sell
}
RiskCheckScope::Sell => false,
},
"allow_sell" => match scope {
RiskCheckScope::Selection => false,
RiskCheckScope::Sell => config.static_rules.respect_allow_buy_sell,
RiskCheckScope::Selection | RiskCheckScope::Sell => {
config.static_rules.respect_allow_buy_sell
}
RiskCheckScope::Buy => false,
},
"upper_limit" | "upper_limit_price" | "high_limit" | "high_limit_price" => match scope {
@@ -726,6 +720,7 @@ 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
@@ -814,7 +809,7 @@ mod tests {
}
#[test]
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
fn sell_rejection_uses_execution_price_not_stale_allow_sell() {
let prev_date = d(2024, 4, 16);
let date = d(2024, 4, 17);
let candidate = candidate(date);
@@ -830,29 +825,14 @@ mod tests {
6.27,
);
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);
assert_eq!(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 mut candidate = candidate(date);
candidate.allow_sell = true;
let candidate = candidate(date);
let market = market(date, 5.63, 5.63);
let position = position(prev_date);
@@ -899,19 +879,13 @@ 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, None);
assert_eq!(enabled_selection_reason, Some("kcb"));
assert_eq!(default_reason, Some("kcb"));
assert_eq!(configured_reason, None);
}
@@ -1020,10 +994,6 @@ 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(
@@ -1033,9 +1003,8 @@ mod tests {
date, &candidate, &market, None, 6.27, &config,
);
assert_eq!(default_selection, None);
assert_eq!(default_selection, Some("bjse"));
assert_eq!(default_buy, Some("bjse"));
assert_eq!(enabled_selection, Some("bjse"));
assert_eq!(configured_selection, None);
assert_eq!(configured_buy, None);
}
@@ -1047,11 +1016,13 @@ 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, &config,
date,
&candidate,
&market,
None,
&FidcRiskControlConfig::default(),
)
.expect("kcb selection rejection");
@@ -1066,13 +1037,9 @@ 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_with_config(
date, &candidate, &market, None, &config
),
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None),
Some("inactive_or_delisted")
);
assert_eq!(
@@ -1098,10 +1065,9 @@ mod tests {
}
#[test]
fn missing_risk_state_default_selection_ignores_allow_flags_but_buy_rejects() {
fn missing_risk_state_rejects_selection_and_buy_when_static_filters_enabled() {
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);
@@ -1118,28 +1084,11 @@ mod tests {
6.27,
);
assert_eq!(selection_reason, None);
assert_eq!(selection_reason, Some("missing_risk_state"));
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);
@@ -1148,11 +1097,12 @@ 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, &config,
date,
&candidate,
&market,
None,
&FidcRiskControlConfig::default(),
)
.expect("missing risk state rejection");
@@ -1224,8 +1174,6 @@ 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(
@@ -1319,17 +1267,12 @@ 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, None);
assert_eq!(enabled_reason, Some("upper_limit"));
assert_eq!(default_reason, Some("upper_limit"));
assert_eq!(configured_reason, None);
}
@@ -1342,17 +1285,12 @@ 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, None);
assert_eq!(enabled_reason, Some("lower_limit"));
assert_eq!(default_reason, Some("lower_limit"));
assert_eq!(configured_reason, None);
}
}
+54 -78
View File
@@ -2035,6 +2035,7 @@ impl OmniMicroCapStrategy {
Some(fill.quantity)
}
#[allow(dead_code)]
fn projected_market_fillable_quantity(
&self,
ctx: &StrategyContext<'_>,
@@ -2046,19 +2047,18 @@ 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)?;
let constraints = self.config.risk_config.trading_constraints;
if constraints.volume_limit_enabled && snapshot.minute_volume == 0 {
if 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,14 +2080,9 @@ impl OmniMicroCapStrategy {
max_fill = max_fill.min(liquidity_limited);
}
let consumed_turnover = execution_state
.intraday_turnover
.get(symbol)
.copied()
.unwrap_or(0)
.saturating_add(current_fill_quantity);
let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0);
if constraints.volume_limit_enabled {
let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).floor()
let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).round()
as i64
- consumed_turnover as i64;
if raw_limit <= 0 {
@@ -2135,23 +2130,6 @@ 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 {
@@ -2245,25 +2223,7 @@ impl OmniMicroCapStrategy {
if remaining_qty == 0 {
break;
}
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);
let mut take_qty = remaining_qty.min(available_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);
@@ -2514,7 +2474,11 @@ 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) {
@@ -2574,8 +2538,13 @@ impl OmniMicroCapStrategy {
date: NaiveDate,
band_low: f64,
band_high: f64,
defer_selection_risk: bool,
) -> Result<(Vec<String>, Vec<String>), BacktestError> {
let universe = ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config);
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 mut diagnostics = Vec::new();
let mut selected = Vec::new();
let start = lower_bound_eligible(&universe, band_low);
@@ -2584,8 +2553,12 @@ impl OmniMicroCapStrategy {
if candidate.market_cap_bn > band_high {
break;
}
let rejection = (!self.stock_passes_ma_filter(ctx, date, &candidate.symbol))
.then_some("ma_filter".to_string());
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)?
};
if let Some(reason) = rejection {
if diagnostics.len() < 12 {
diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason));
@@ -2611,7 +2584,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 lagged_execution = ctx.is_lagged_execution();
let defer_selection_risk = ctx.is_lagged_execution();
if self.config.in_skip_window(signal_date) {
return Ok(StrategyDecision {
rebalance: false,
@@ -2622,12 +2595,9 @@ impl Strategy for OmniMicroCapStrategy {
.positions()
.keys()
.cloned()
.map(|symbol| OrderIntent::TimedTargetValue {
.map(|symbol| OrderIntent::TargetValue {
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(),
@@ -2660,8 +2630,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)?;
let risk_decisions = self.selection_risk_decisions(ctx, signal_date);
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);
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
let projection_date = signal_date;
let mut projected = ctx.portfolio.clone();
@@ -2686,8 +2656,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 =
lagged_execution || self.can_sell_position(ctx, execution_date, &position.symbol);
let can_sell = defer_selection_risk
|| 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 {
@@ -2723,7 +2693,7 @@ impl Strategy for OmniMicroCapStrategy {
{
continue;
}
if !lagged_execution
if !defer_selection_risk
&& self
.buy_rejection_reason(ctx, execution_date, symbol)?
.is_some()
@@ -2761,7 +2731,7 @@ impl Strategy for OmniMicroCapStrategy {
if stock_list.iter().any(|candidate| candidate == symbol) {
continue;
}
if !lagged_execution && !self.can_sell_position(ctx, execution_date, symbol) {
if !defer_selection_risk && !self.can_sell_position(ctx, execution_date, symbol) {
continue;
}
order_intents.push(OrderIntent::TargetValue {
@@ -2789,7 +2759,7 @@ impl Strategy for OmniMicroCapStrategy {
{
continue;
}
if !lagged_execution
if !defer_selection_risk
&& self
.buy_rejection_reason(ctx, execution_date, symbol)?
.is_some()
@@ -3020,27 +2990,33 @@ 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)
.select_symbols(&ctx, dates[2], 0.0, 100.0, false)
.expect("default selection");
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_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!(
strict_risk_decisions[0]
default_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()]);
}
}
+15 -61
View File
@@ -72,29 +72,6 @@ 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,
@@ -102,9 +79,6 @@ 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)]
@@ -113,14 +87,9 @@ 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 = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益严格 > 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\"";
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益 >= 150% 视为满足收益目标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同。";
pub fn built_in_strategy_manual() -> StrategyAiManual {
StrategyAiManual {
@@ -135,7 +104,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 当成下单前已知信息;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close".to_string(),
"next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息。".to_string(),
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
],
@@ -250,7 +219,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、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(),
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(),
},
ManualSection {
title: "corporate_actions.dividend_reinvestment".to_string(),
@@ -258,7 +227,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 使用决策日当日 closenext_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_buysell_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(),
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\")current_bar_close 使用决策日当日 closenext_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(),
},
ManualSection {
title: "期货提交校验".to_string(),
@@ -366,13 +335,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。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。个股 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。个股 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: "任意数值字段滚动求和、最小值、最大值。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 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: "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: "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) / safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法,两参数形式默认 fallback=0".to_string() },
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".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![
@@ -470,12 +439,11 @@ 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.rebalance_cash_mode`、`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.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(
@@ -484,7 +452,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、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须比较实际 next-open 成交价与涨跌停价,不能用执行日 close/last 或 next-close禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
out.push_str("- `next_bar_open` 必须区分信号日、订单创建日和实际成交日:T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
out.push_str("## 语句块\n");
for item in &manual.statement_blocks {
@@ -564,11 +532,9 @@ 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(&format!("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma6060日价格均线写 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 == 0filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposurerisk.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_typenext_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("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma6060日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposurerisk.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_typenext_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("回测成功但 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(");
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("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\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("用户目标:\n");
prompt.push_str(&format!("- {}\n", request.user_goal));
if !request.constraints.is_empty() {
@@ -598,7 +564,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这些滚动/因子 helper 的字段参数只能是字段名或字符串字段名,不要嵌套表达式;不要回退 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;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
prompt.push_str("优化目标:\n");
prompt.push_str(&format!("- {}\n\n", request.objective));
prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n");
@@ -636,11 +602,10 @@ 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("三年回测区间策略总收益严格 > 120% 视为满足收益目标"));
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 视为满足收益目标"));
assert!(prompt.contains("不得把已达标策略判为失败"));
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
assert!(prompt.contains("不要回退 ficlaw-data"));
@@ -648,16 +613,6 @@ 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]
@@ -669,11 +624,10 @@ mod tests {
objective: "优化收益".to_string(),
result_summary: json!({ "total_return": 1.49 }),
diagnostics: Vec::new(),
holding_count_contract: None,
},
);
assert!(prompt.contains("三年回测区间策略总收益严格 > 120% 视为满足收益目标"));
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 视为满足收益目标"));
assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性"));
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
assert!(prompt.contains("不要回退 ficlaw-data"));
+17 -79
View File
@@ -55,11 +55,15 @@ pub struct SelectionContext<'a> {
impl SelectionContext<'_> {
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
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(),
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(),
}
};
match self.dynamic_universe {
Some(symbols) if !symbols.is_empty() => eligible
@@ -71,6 +75,9 @@ 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,
@@ -397,16 +404,13 @@ 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(&risk_config),
risk_config: Some(&FidcRiskControlConfig::default()),
defer_selection_risk: false,
});
@@ -429,7 +433,7 @@ mod tests {
}
#[test]
fn selector_applies_configured_selection_risk_on_decision_date() {
fn selector_defers_static_execution_risk_for_lagged_execution() {
let data = DataSet::from_components(
vec![
instrument("000001.SZ"),
@@ -455,74 +459,13 @@ 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(&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),
risk_config: Some(&FidcRiskControlConfig::default()),
defer_selection_risk: true,
});
@@ -533,12 +476,7 @@ mod tests {
assert!(selected_symbols.contains("000001.SZ"));
assert!(selected_symbols.contains("688001.SH"));
assert!(selected_symbols.contains("000002.SZ"));
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);
assert_eq!(diagnostics.not_eligible_count, 0);
assert!(diagnostics.risk_decisions.is_empty());
}
}
+24 -197
View File
@@ -265,7 +265,7 @@ fn broker_executes_explicit_order_value_buy() {
ask1: 10.01,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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_with_trade_delta() {
fn broker_executes_intraday_last_on_start_quote_without_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_with_trade_delta() {
ask1: 15.21,
bid1_volume: 8,
ask1_volume: 8,
volume_delta: 800,
volume_delta: 0,
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: 800,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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: 400,
volume_delta: 1,
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_day_open_for_open_auction_valuation() {
fn rebalance_uses_prev_close_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_day_open_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, 900);
assert_eq!(target.quantity, 400);
assert_eq!(report.fill_events.len(), 2);
assert!(
report
@@ -3531,7 +3531,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
.iter()
.any(|fill| fill.symbol == "000002.SZ"
&& fill.side == fidc_core::OrderSide::Buy
&& fill.quantity == 900)
&& fill.quantity == 400)
);
}
@@ -3725,179 +3725,6 @@ 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();
+10 -17
View File
@@ -21,17 +21,16 @@ 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|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' \
'fidatacenter|FIDATACENTER|/v1/backtest/data|/v1/xuntou|ClickHouse|clickhouse|CLICKHOUSE' \
crates Cargo.toml \
2>/dev/null || true
)"
if [[ -n "$runtime_hits" ]]; then
fail "legacy fidatacenter/ClickHouse/CSV snapshot/FiRisk runtime data source references are not allowed in fidc-backtest-engine" "$runtime_hits"
fail "legacy fidatacenter/ClickHouse runtime data source references are not allowed in fidc-backtest-engine" "$runtime_hits"
fi
manifest_hits="$(
@@ -49,11 +48,10 @@ 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|FIDC_BT_WRITE_COMBINED_SOURCE_ROW_CACHE' \
'FICLAW_DATA_AGENT_URL|ficlaw_data\.source_rows_v1|strategy-factory-source-lake://local' \
crates Cargo.toml \
2>/dev/null || true
)"
@@ -69,8 +67,7 @@ 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' \
@@ -89,8 +86,7 @@ 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|融合表|融合宽表' \
@@ -109,8 +105,7 @@ 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' \
@@ -129,8 +124,7 @@ 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' \
@@ -149,8 +143,7 @@ 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 \
-61
View File
@@ -1,61 +0,0 @@
#!/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'