diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 39c1edf..ad6ceff 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -64,6 +64,7 @@ struct TargetConstraint { desired_qty: u32, provisional_target_qty: u32, price: f64, + buy_execution_price: f64, minimum_order_quantity: u32, order_step_size: u32, } @@ -193,6 +194,7 @@ pub struct BrokerSimulator { runtime_intraday_end_time: Cell>, runtime_decision_date: Cell>, runtime_order_created_date: Cell>, + runtime_decision_total_equity: Cell>, next_order_id: Cell, open_orders: RefCell>, } @@ -210,7 +212,7 @@ impl BrokerSimulator { volume_limit: true, inactive_limit: true, liquidity_limit: true, - strict_value_budget: false, + strict_value_budget: true, rebalance_cash_mode: RebalanceCashMode::default(), sell_then_buy_delay_slippage_rate: 0.0, aiquant_execution_rules: false, @@ -222,6 +224,7 @@ impl BrokerSimulator { runtime_intraday_end_time: Cell::new(None), runtime_decision_date: Cell::new(None), runtime_order_created_date: Cell::new(None), + runtime_decision_total_equity: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), } @@ -243,7 +246,7 @@ impl BrokerSimulator { volume_limit: true, inactive_limit: true, liquidity_limit: true, - strict_value_budget: false, + strict_value_budget: true, rebalance_cash_mode: RebalanceCashMode::default(), sell_then_buy_delay_slippage_rate: 0.0, aiquant_execution_rules: false, @@ -255,6 +258,7 @@ impl BrokerSimulator { runtime_intraday_end_time: Cell::new(None), runtime_decision_date: Cell::new(None), runtime_order_created_date: Cell::new(None), + runtime_decision_total_equity: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), } @@ -276,7 +280,11 @@ impl BrokerSimulator { } pub fn with_strict_value_budget(mut self, enabled: bool) -> Self { - self.strict_value_budget = enabled; + assert!( + enabled, + "strict value budget is mandatory for FIDC order sizing" + ); + self.strict_value_budget = true; self } @@ -427,11 +435,11 @@ where symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, ) -> f64 { - if self.matching_type == MatchingType::NextBarOpen - && snapshot.prev_close.is_finite() - && snapshot.prev_close > 0.0 - { - return snapshot.prev_close; + if self.matching_type == MatchingType::NextBarOpen { + let execution_price = snapshot.price(PriceField::Open); + if execution_price.is_finite() && execution_price > 0.0 { + return execution_price; + } } if self.aiquant_execution_rules && self.execution_price_field == PriceField::Last { let start_cursor = self @@ -716,16 +724,42 @@ where portfolio: &mut PortfolioState, data: &DataSet, decision: &StrategyDecision, + ) -> Result { + self.execute_with_event_dates_and_decision_equity( + date, + decision_date, + order_created_date, + None, + portfolio, + data, + decision, + ) + } + + pub fn execute_with_event_dates_and_decision_equity( + &self, + date: NaiveDate, + decision_date: NaiveDate, + order_created_date: NaiveDate, + decision_total_equity: Option, + portfolio: &mut PortfolioState, + data: &DataSet, + decision: &StrategyDecision, ) -> Result { let previous_decision_date = self.runtime_decision_date.get(); let previous_order_created_date = self.runtime_order_created_date.get(); + let previous_decision_total_equity = self.runtime_decision_total_equity.get(); self.runtime_decision_date.set(Some(decision_date)); self.runtime_order_created_date .set(Some(order_created_date)); + self.runtime_decision_total_equity + .set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0)); let result = self.execute_with_runtime_dates(date, portfolio, data, decision); self.runtime_decision_date.set(previous_decision_date); self.runtime_order_created_date .set(previous_order_created_date); + self.runtime_decision_total_equity + .set(previous_decision_total_equity); result } @@ -880,15 +914,42 @@ where decision: &StrategyDecision, start_time: Option, end_time: Option, + ) -> Result { + self.execute_between_with_event_dates_and_decision_equity( + date, + decision_date, + order_created_date, + None, + portfolio, + data, + decision, + start_time, + end_time, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn execute_between_with_event_dates_and_decision_equity( + &self, + date: NaiveDate, + decision_date: NaiveDate, + order_created_date: NaiveDate, + decision_total_equity: Option, + portfolio: &mut PortfolioState, + data: &DataSet, + decision: &StrategyDecision, + start_time: Option, + end_time: Option, ) -> Result { let previous_start_time = self.runtime_intraday_start_time.get(); let previous_end_time = self.runtime_intraday_end_time.get(); self.runtime_intraday_start_time.set(start_time); self.runtime_intraday_end_time.set(end_time); - let result = self.execute_with_event_dates( + let result = self.execute_with_event_dates_and_decision_equity( date, decision_date, order_created_date, + decision_total_equity, portfolio, data, decision, @@ -2017,8 +2078,22 @@ where target_weights: &BTreeMap, valuation_prices: Option<&BTreeMap>, ) -> Result<(BTreeMap, Vec), BacktestError> { - let equity = - self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?; + let equity = if valuation_prices.is_none() { + self.target_total_equity_at(date, portfolio, data)? + } else { + self.runtime_decision_total_equity + .get() + .filter(|equity| equity.is_finite() && *equity >= 0.0) + .map(Ok) + .unwrap_or_else(|| { + self.rebalance_total_equity_at_with_overrides( + date, + portfolio, + data, + valuation_prices, + ) + })? + }; let target_weight_sum = target_weights .values() .copied() @@ -2036,15 +2111,33 @@ where data, valuation_prices, )?; - let raw_qty = ((equity * weight) / price).floor() as u32; - desired_targets.insert( - symbol.clone(), + let current_qty = portfolio + .position(symbol) + .map(|position| position.quantity) + .unwrap_or(0); + let target_value = (equity * weight).max(0.0); + let current_value = price * current_qty as f64; + let minimum_order_quantity = self.minimum_order_quantity(data, symbol); + let order_step_size = self.order_step_size(data, symbol); + let desired_qty = if target_value > current_value + f64::EPSILON { + let buy_budget = target_value - current_value; + current_qty.saturating_add(self.target_buy_quantity_for_budget( + date, + data, + symbol, + buy_budget, + price, + minimum_order_quantity, + order_step_size, + )) + } else { self.round_buy_quantity( - raw_qty, - self.minimum_order_quantity(data, symbol), - self.order_step_size(data, symbol), - ), - ); + (target_value / price).floor() as u32, + minimum_order_quantity, + order_step_size, + ) + }; + desired_targets.insert(symbol.clone(), desired_qty); } let mut symbols = BTreeSet::new(); @@ -2087,6 +2180,22 @@ where order_step_size, ); let provisional_target_qty = desired_qty.clamp(min_target_qty, max_target_qty); + let buy_quantity = provisional_target_qty.saturating_sub(current_qty); + let sell_quantity = current_qty.saturating_sub(provisional_target_qty); + let buy_execution_price = data + .market(date, &symbol) + .map(|snapshot| { + self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(buy_quantity)) + }) + .filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0) + .unwrap_or(price); + let sell_execution_price = data + .market(date, &symbol) + .map(|snapshot| { + self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(sell_quantity)) + }) + .filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0) + .unwrap_or(price); if desired_qty < current_qty && min_target_qty >= current_qty && diagnostics.len() < 16 @@ -2132,7 +2241,7 @@ where if current_qty > provisional_target_qty && cash_mode != RebalanceCashMode::PreOpenCash { projected_cash += self.estimated_sell_net_cash( date, - price, + sell_execution_price, current_qty.saturating_sub(provisional_target_qty), ); } @@ -2142,6 +2251,7 @@ where desired_qty, provisional_target_qty, price, + buy_execution_price, minimum_order_quantity, order_step_size, }); @@ -2186,7 +2296,7 @@ where if target_qty > constraint.current_qty { buy_cash_out += self.estimated_buy_cash_out( date, - constraint.price, + constraint.buy_execution_price, target_qty - constraint.current_qty, ); } @@ -2445,8 +2555,22 @@ where reason: &str, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let equity = - self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?; + let equity = if valuation_prices.is_none() { + self.target_total_equity_at(date, portfolio, data)? + } else { + self.runtime_decision_total_equity + .get() + .filter(|equity| equity.is_finite() && *equity >= 0.0) + .map(Ok) + .unwrap_or_else(|| { + self.rebalance_total_equity_at_with_overrides( + date, + portfolio, + data, + valuation_prices, + ) + })? + }; for (symbol, weight) in target_weights { if weight.abs() <= f64::EPSILON { continue; @@ -4054,7 +4178,7 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?; + let total_equity = self.target_total_equity_at(date, portfolio, data)?; self.process_target_value( date, portfolio, @@ -4085,7 +4209,7 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?; + let total_equity = self.target_total_equity_at(date, portfolio, data)?; self.process_limit_target_value( date, portfolio, @@ -4324,7 +4448,7 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?; + let total_equity = self.target_total_equity_at(date, portfolio, data)?; self.process_value( date, portfolio, @@ -4355,7 +4479,7 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?; + let total_equity = self.target_total_equity_at(date, portfolio, data)?; self.process_limit_value( date, portfolio, @@ -4495,7 +4619,7 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?; + let total_equity = self.target_total_equity_at(date, portfolio, data)?; self.process_algo_value( date, portfolio, @@ -5343,6 +5467,22 @@ where self.rebalance_total_equity_at_with_overrides(date, portfolio, data, None) } + fn target_total_equity_at( + &self, + date: NaiveDate, + portfolio: &PortfolioState, + data: &DataSet, + ) -> Result { + if let Some(equity) = self + .runtime_decision_total_equity + .get() + .filter(|equity| equity.is_finite() && *equity >= 0.0) + { + return Ok(equity); + } + self.rebalance_total_equity_at(date, portfolio, data) + } + fn rebalance_total_equity_at_with_overrides( &self, date: NaiveDate, @@ -5443,6 +5583,60 @@ where 0 } + #[allow(clippy::too_many_arguments)] + fn target_buy_quantity_for_budget( + &self, + date: NaiveDate, + data: &DataSet, + symbol: &str, + value_budget: f64, + fallback_price: f64, + minimum_order_quantity: u32, + order_step_size: u32, + ) -> u32 { + let snapshot = data.market(date, symbol); + let mut quantity = self.value_buy_quantity( + date, + value_budget, + fallback_price, + minimum_order_quantity, + order_step_size, + ); + for _ in 0..8 { + let execution_price = snapshot + .map(|snapshot| { + self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity)) + }) + .filter(|price| price.is_finite() && *price > 0.0) + .unwrap_or(fallback_price); + let resolved = self.value_buy_quantity( + date, + value_budget, + execution_price, + minimum_order_quantity, + order_step_size, + ); + if resolved == quantity { + return quantity; + } + quantity = resolved; + } + while quantity >= minimum_order_quantity.max(1) { + let execution_price = snapshot + .map(|snapshot| { + self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity)) + }) + .filter(|price| price.is_finite() && *price > 0.0) + .unwrap_or(fallback_price); + if self.estimated_buy_cash_out(date, execution_price, quantity) <= value_budget + 1e-6 { + return quantity; + } + quantity = + self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); + } + 0 + } + fn decrement_order_quantity( &self, quantity: u32, @@ -7154,7 +7348,7 @@ mod tests { } #[test] - fn next_open_target_value_valuation_uses_previous_close() { + fn next_open_target_value_valuation_uses_execution_open() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), @@ -7181,10 +7375,68 @@ mod tests { assert_eq!( broker.target_value_valuation_price(date, &data, "000001.SZ", snapshot), - 10.0 + 11.0 ); } + #[test] + fn next_open_target_value_recomputes_quantity_from_execution_open() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_matching_type(MatchingType::NextBarOpen) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut snapshot = limit_test_snapshot(); + snapshot.date = date; + snapshot.prev_close = 10.0; + snapshot.open = 11.0; + snapshot.close = 20.0; + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![snapshot], + Vec::new(), + vec![limit_test_candidate(true, true)], + vec![limit_test_benchmark()], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let mut portfolio = PortfolioState::new(20_000.0); + portfolio.position_mut("000001.SZ").buy( + date.pred_opt().expect("previous date"), + 1_000, + 10.0, + ); + portfolio.apply_cash_delta(-10_000.0); + let mut report = BrokerExecutionReport::default(); + + broker + .process_target_value( + date, + &mut portfolio, + &data, + "000001.SZ", + 5_500.0, + "next_open_target_value", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("target value execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].price, 11.0); + assert_eq!(report.fill_events[0].quantity, 500); + assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 500); + } + #[test] fn target_portfolio_smart_ignores_zero_weight_symbols_without_market_snapshot() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); @@ -7238,6 +7490,51 @@ mod tests { ); } + #[test] + fn target_weight_buy_quantity_respects_per_symbol_budget_after_slippage_and_fees() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_matching_type(MatchingType::NextBarOpen) + .with_slippage_model(SlippageModel::PriceRatio(0.002)) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut snapshot = limit_test_snapshot(); + snapshot.date = date; + snapshot.open = 10.0; + snapshot.close = 10.0; + snapshot.last_price = 10.0; + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![snapshot], + Vec::new(), + vec![limit_test_candidate(true, true)], + vec![limit_test_benchmark()], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let portfolio = PortfolioState::new(100_000.0); + let target_weights = BTreeMap::from([("000001.SZ".to_string(), 0.5)]); + + let (targets, _) = broker + .target_quantities(date, &portfolio, &data, &target_weights) + .expect("target quantities"); + let quantity = targets["000001.SZ"]; + let execution_price = 10.0 * 1.002; + let allocated_amount = 50_000.0; + + assert_eq!(quantity, 4_900); + assert!(broker.estimated_buy_cash_out(date, execution_price, quantity) <= allocated_amount); + assert!( + broker.estimated_buy_cash_out(date, execution_price, quantity + 100) > allocated_amount + ); + } + #[test] fn target_portfolio_smart_records_buy_rejection_when_target_is_blacklisted() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); @@ -7344,7 +7641,7 @@ mod tests { let (aiquant_targets, _) = aiquant_broker .target_quantities(date, &portfolio, &data, &target_weights) .expect("aiquant target quantities"); - assert_eq!(aiquant_targets.get("000001.SZ").copied(), Some(50_000)); + assert_eq!(aiquant_targets.get("000001.SZ").copied(), Some(49_900)); } #[test] @@ -7558,7 +7855,7 @@ mod tests { } #[test] - fn target_portfolio_smart_scales_buys_when_full_targets_exceed_cash_by_fees() { + fn target_portfolio_smart_budgets_each_buy_before_cash_optimization() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let symbols = ["000001.SZ", "000002.SZ"]; let instruments = symbols @@ -7618,20 +7915,7 @@ mod tests { assert_eq!(target_quantities.get("000001.SZ").copied(), Some(400)); assert_eq!(target_quantities.get("000002.SZ").copied(), Some(400)); - assert!( - diagnostics - .iter() - .any(|line| line.contains("rebalance_safety_scaled")), - "{diagnostics:?}" - ); - assert!( - diagnostics - .iter() - .any(|line| line.contains("rebalance_buy_reduced") - && line.contains("provisional=500") - && line.contains("final=400")), - "{diagnostics:?}" - ); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); } #[test] diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 88ac7cd..3bdc06f 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1728,6 +1728,7 @@ where daily_holdings: Vec::new(), metrics: BacktestMetrics::default(), }; + let mut stock_equity_by_date = BTreeMap::::new(); for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { let mut corporate_action_notes = Vec::new(); @@ -1875,8 +1876,12 @@ where process_events: day_process_events, }); result.process_events.append(&mut process_events); + stock_equity_by_date.insert(execution_date, portfolio.total_equity()); continue; }; + let decision_total_equity = (decision_date < execution_date) + .then(|| stock_equity_by_date.get(&decision_date).copied()) + .flatten(); let mut process_events = Vec::new(); let mut directive_report = BrokerExecutionReport::default(); let pre_open_orders = self.open_order_views(); @@ -2073,10 +2078,11 @@ where None, None, )?; - let mut report = self.broker.execute_with_event_dates( + let mut report = self.broker.execute_with_event_dates_and_decision_equity( execution_date, decision_date, decision_date, + decision_total_equity, &mut portfolio, &self.data, &auction_decision, @@ -2321,10 +2327,11 @@ where None, None, )?; - let mut intraday_report = self.broker.execute_with_event_dates( + let mut intraday_report = self.broker.execute_with_event_dates_and_decision_equity( execution_date, decision_date, decision_date, + decision_total_equity, &mut portfolio, &self.data, &decision, @@ -2492,16 +2499,19 @@ where Some(minute_time), Some(minute_time), )?; - let mut minute_report = self.broker.execute_between_with_event_dates( - execution_date, - decision_date, - decision_date, - &mut portfolio, - &self.data, - &minute_decision, - Some(minute_time), - Some(minute_time), - )?; + let mut minute_report = self + .broker + .execute_between_with_event_dates_and_decision_equity( + execution_date, + decision_date, + decision_date, + decision_total_equity, + &mut portfolio, + &self.data, + &minute_decision, + Some(minute_time), + Some(minute_time), + )?; let post_minute_open_orders = self.open_order_views(); publish_process_events( &mut self.strategy, @@ -2888,6 +2898,7 @@ where process_events: day_process_events, }); result.process_events.extend(process_events); + stock_equity_by_date.insert(execution_date, portfolio.total_equity()); } if let Some(last_date) = execution_dates.last().copied() { @@ -4323,6 +4334,43 @@ mod tests { } } + #[derive(Debug)] + struct ScheduledTargetPercentStrategy { + first_decision_date: NaiveDate, + second_decision_date: NaiveDate, + } + + impl Strategy for ScheduledTargetPercentStrategy { + fn name(&self) -> &str { + "scheduled_target_percent" + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + let order_intents = if ctx.decision_date == self.first_decision_date { + vec![OrderIntent::Shares { + symbol: SYMBOL.to_string(), + quantity: 1_000, + reason: "initial_position".to_string(), + }] + } else if ctx.decision_date == self.second_decision_date { + vec![OrderIntent::TargetPercent { + symbol: SYMBOL.to_string(), + target_percent: 0.5, + reason: "frozen_target_percent".to_string(), + }] + } else { + Vec::new() + }; + Ok(StrategyDecision { + order_intents, + ..StrategyDecision::default() + }) + } + } + #[derive(Debug)] struct ScheduledEligibleUniverseBuyStrategy { rule: ScheduleRule, @@ -5003,6 +5051,49 @@ mod tests { assert_eq!(result.fills[0].quantity, 8_300); } + #[test] + fn next_bar_open_target_percent_freezes_decision_day_equity() { + let first = d(2025, 1, 2); + let second = d(2025, 1, 3); + let third = d(2025, 1, 6); + let dataset = dataset_from_market_and_candidates( + vec![ + market(first, 10.0, 10.0), + market(second, 10.0, 10.0), + market(third, 20.0, 20.0), + ], + vec![candidate(first), candidate(second), candidate(third)], + ); + 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, + ScheduledTargetPercentStrategy { + first_decision_date: first, + second_decision_date: second, + }, + scheduled_next_open_broker(FidcRiskControlConfig::default()), + config, + ) + .run() + .expect("backtest run"); + + assert_eq!(result.fills.len(), 2, "fills={:?}", result.fills); + assert_eq!(result.fills[0].date, second); + assert_eq!(result.fills[0].quantity, 1_000); + assert_eq!(result.fills[1].date, third); + assert_eq!(result.fills[1].price, 20.0); + assert_eq!(result.fills[1].quantity, 1_400); + assert_eq!(result.fills[1].decision_date, Some(second)); + } + #[test] fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() { let first = d(2025, 1, 2); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index ee5cc63..58a7152 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -315,7 +315,7 @@ fn band_low(index_close) { stamp_tax_rate_before_change: None, stamp_tax_rate_after_change: None, stamp_tax_change_date: None, - strict_value_budget: false, + strict_value_budget: true, rebalance_cash_mode: RebalanceCashMode::default(), sell_then_buy_delay_slippage_rate: 0.0, risk_config: FidcRiskControlConfig::default(), diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 9e945a9..ed6782b 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -1130,9 +1130,10 @@ fn apply_execution_behavior_overrides( cfg.slippage_model = parsed; } } - if let Some(enabled) = strict_value_budget { - cfg.strict_value_budget = enabled; + if strict_value_budget == Some(false) { + return Err("strictValueBudget=false is not supported".to_string()); } + cfg.strict_value_budget = true; if let Some(rate) = sell_then_buy_delay_slippage_rate { if !rate.is_finite() || !(0.0..1.0).contains(&rate) { return Err( @@ -1886,9 +1887,7 @@ pub fn platform_expr_config_from_spec( { cfg.minimum_commission = None; } - if aiquant_profile { - cfg.strict_value_budget = true; - } + cfg.strict_value_budget = true; Ok(cfg) } @@ -2767,7 +2766,7 @@ mod tests { "matchingType": "current_bar_close", "slippageModel": "none", "slippageValue": 0.0, - "strictValueBudget": false + "strictValueBudget": true } }); diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 9a91320..62de9ea 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -135,7 +135,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { "AI 生成策略时只能输出完整 engine-script 代码,不输出 Markdown、解释、推理过程、JSON 包装或手册复述。".to_string(), "表达式字段以运行时字段为准:市值使用 market_cap,流通市值使用 free_float_cap;不要在策略表达式中使用数据库原始字段 float_market_cap。".to_string(), "任意窗口价格均线使用 rolling_mean(\"close\", n) 或 ma(\"close\", n),任意窗口均量使用 rolling_mean(\"volume\", n) 或 vma(n);不要使用未列出的 ma60、stock_ma60、signal_ma60 或 benchmark_ma60 变量。".to_string(), - "next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(), + "next_bar_open 会在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数;不得把执行日 open/high/low/close 当成下单前已知信息,也不得用 T+1 prev_close 或 T 日估算股数直接成交;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(), "自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(), "禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(), ], @@ -258,7 +258,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "execution.matching_type / execution.slippage".to_string(), - detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\");current_bar_close 使用决策日当日 close,next_bar_open 使用决策日信号并在下一可交易日 open 撮合,禁止把执行日 open/high/low/close 解释为下单前已知数据;next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。日线调仓现金口径由 execution.rebalance_cash_mode(\"sell_then_buy\" | \"same_point_net\" | \"pre_open_cash\") 或页面/API 参数控制,默认 sell_then_buy;sell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(), + detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\");current_bar_close 使用决策日当日 close,next_bar_open 在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数,保证执行金额加手续费不超过分配金额;禁止把执行日 open/high/low/close 解释为下单前已知数据,也禁止用 T+1 prev_close 或 T 日估算股数直接成交;next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。金额预算始终严格,execution.strict_value_budget(false) 会被拒绝。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。日线调仓现金口径由 execution.rebalance_cash_mode(\"sell_then_buy\" | \"same_point_net\" | \"pre_open_cash\") 或页面/API 参数控制,默认 sell_then_buy;sell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(), }, ManualSection { title: "期货提交校验".to_string(), @@ -485,6 +485,7 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String { 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 日收盘目标,T+1 按实际 open、滑点、卖后买延迟滑点、手续费和证券数量步长重算股数;禁止用 T+1 prev_close、T 日估算股数或 T+1 开盘后权益替代。金额预算始终严格,不能生成 `execution.strict_value_budget(false)`。\n"); out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n"); out.push_str("## 语句块\n"); for item in &manual.statement_blocks { @@ -564,7 +565,7 @@ 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_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);rolling_mean、rolling_sum/min/max/stddev/zscore、pct_change、factor_value 等 helper 的第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0;filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;risk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,必须覆盖完整默认配置面,例如 {DEFAULT_RISK_POLICY_DSL_PROMPT},不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n")); + prompt.push_str(&format!("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);rolling_mean、rolling_sum/min/max/stddev/zscore、pct_change、factor_value 等 helper 的第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0;filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;risk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,必须覆盖完整默认配置面,例如 {DEFAULT_RISK_POLICY_DSL_PROMPT},不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图并在收盘冻结目标金额或目标权益,T+1 按实际 open、滑点、手续费和证券数量步长重算股数,不能用 T+1 prev_close 或 T 日估算股数直接成交;涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单;金额预算始终严格,禁止 execution.strict_value_budget(false);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); diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 57b1a18..38f911e 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -3714,15 +3714,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() { .iter() .any(|event| event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy) ); - assert!( - report - .diagnostics - .iter() - .any(|line| line.contains("rebalance_safety_scaled") - || line.contains("rebalance_buy_reduced")), - "expected rebalance diagnostics when cash is tight, got {:?}", - report.diagnostics - ); + assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics); } #[test]