diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 2e8c0c7..5db7e9b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -80,12 +80,68 @@ pub enum MatchingType { Twap, } +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DynamicSlippageConfig { + pub impact_coefficient: f64, + pub volatility_coefficient: f64, + pub max_ratio: f64, +} + +impl DynamicSlippageConfig { + pub fn new(impact_coefficient: f64, volatility_coefficient: f64, max_ratio: f64) -> Self { + Self { + impact_coefficient: impact_coefficient.max(0.0), + volatility_coefficient: volatility_coefficient.max(0.0), + max_ratio: max_ratio.max(0.0), + } + } + + fn ratio( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + raw_price: f64, + order_value: Option, + ) -> f64 { + let daily_amount = (snapshot.volume as f64 * raw_price).max(0.0); + let impact_ratio = match order_value { + Some(value) if value.is_finite() && value > 0.0 && daily_amount > 0.0 => { + value / daily_amount + } + _ => 0.0, + }; + let volatility_base = if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 { + snapshot.prev_close + } else { + raw_price + }; + let volatility = if snapshot.high.is_finite() + && snapshot.low.is_finite() + && volatility_base.is_finite() + && volatility_base > 0.0 + { + ((snapshot.high - snapshot.low).abs() / volatility_base).max(0.0) + } else { + 0.0 + }; + let ratio = + impact_ratio * self.impact_coefficient + volatility * self.volatility_coefficient; + ratio.clamp(0.0, self.max_ratio) + } +} + +impl Default for DynamicSlippageConfig { + fn default() -> Self { + Self::new(0.5, 0.3, 0.01) + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum SlippageModel { None, PriceRatio(f64), TickSize(f64), LimitPrice, + Dynamic(DynamicSlippageConfig), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -306,6 +362,7 @@ where &self, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, + quantity: Option, ) -> f64 { let raw_price = if self.execution_price_field == PriceField::Last && self.intraday_execution_start_time.is_some() @@ -319,7 +376,7 @@ where } }; - self.apply_slippage(snapshot, side, raw_price) + self.apply_slippage(snapshot, side, raw_price, quantity) } fn is_open_auction_matching(&self) -> bool { @@ -331,6 +388,7 @@ where snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, raw_price: f64, + quantity: Option, ) -> f64 { if !raw_price.is_finite() || raw_price <= 0.0 { return raw_price; @@ -340,6 +398,7 @@ where return self.clamp_execution_price(snapshot, side, raw_price); } + let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64)); let adjusted = match self.slippage_model { SlippageModel::None => raw_price, SlippageModel::PriceRatio(ratio) => { @@ -358,6 +417,13 @@ where } } SlippageModel::LimitPrice => raw_price, + SlippageModel::Dynamic(config) => { + let ratio = config.ratio(snapshot, raw_price, order_value); + match side { + OrderSide::Buy => raw_price * (1.0 + ratio), + OrderSide::Sell => raw_price * (1.0 - ratio), + } + } }; self.clamp_execution_price(snapshot, side, adjusted) @@ -394,8 +460,9 @@ where snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, raw_price: f64, + quantity: Option, ) -> f64 { - self.apply_slippage(snapshot, side, raw_price) + self.apply_slippage(snapshot, side, raw_price, quantity) } fn matching_type_for_algo_request( @@ -411,7 +478,7 @@ where fn select_quote_reference_price( &self, - snapshot: &crate::data::DailyMarketSnapshot, + _snapshot: &crate::data::DailyMarketSnapshot, quote: &IntradayExecutionQuote, side: OrderSide, matching_type: MatchingType, @@ -462,9 +529,8 @@ where OrderSide::Sell => quote.sell_price(), }, }?; - let execution_price = self.quote_execution_price(snapshot, side, raw_price); - if execution_price.is_finite() && execution_price > 0.0 { - Some(execution_price) + if raw_price.is_finite() && raw_price > 0.0 { + Some(raw_price) } else { None } @@ -2345,7 +2411,8 @@ where merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); (fill.quantity, fill.legs) } else { - let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell); + let mut execution_price = + self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(fillable_qty)); if let Some(reason) = self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price) { @@ -2363,7 +2430,7 @@ where ); (0, Vec::new()) } else { - let execution_price = + execution_price = self.execution_price_with_limit_slippage(execution_price, limit_price); ( fillable_qty, @@ -3714,7 +3781,8 @@ where merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); (fill.quantity, fill.legs) } else { - let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy); + let mut execution_price = + self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(constrained_qty)); if let Some(reason) = self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price) { @@ -3732,7 +3800,7 @@ where ); (0, Vec::new()) } else { - let execution_price = + execution_price = self.execution_price_with_limit_slippage(execution_price, limit_price); let filled_qty = self.affordable_buy_quantity( date, @@ -3743,6 +3811,12 @@ where self.minimum_order_quantity(data, symbol), self.order_step_size(data, symbol), ); + if filled_qty > 0 { + execution_price = + self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(filled_qty)); + execution_price = + self.execution_price_with_limit_slippage(execution_price, limit_price); + } if filled_qty < constrained_qty { partial_fill_reason = merge_partial_fill_reason( partial_fill_reason, @@ -4537,28 +4611,11 @@ where // Approximate platform-native market-order fills with the evolving L1 book after // the decision time instead of trade VWAP. This keeps quantities/prices // closer to the observed 10:18 execution logs. - let Some(quote_price) = + let Some(raw_quote_price) = self.select_quote_reference_price(snapshot, quote, side, matching_type) else { continue; }; - if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) - { - execution_block_reason.get_or_insert(reason); - execution_block_timestamp = Some(quote.timestamp); - continue; - } - saw_non_blocked_execution_price = true; - if !self.price_satisfies_limit( - side, - quote_price, - limit_price, - snapshot.effective_price_tick(), - ) { - continue; - } - let quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); - let remaining_qty = requested_qty.saturating_sub(filled_qty); if remaining_qty == 0 { break; @@ -4594,8 +4651,35 @@ where continue; } + let mut quote_price = + self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); + if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) + { + execution_block_reason.get_or_insert(reason); + execution_block_timestamp = Some(quote.timestamp); + continue; + } + saw_non_blocked_execution_price = true; + if !self.price_satisfies_limit( + side, + quote_price, + limit_price, + snapshot.effective_price_tick(), + ) { + continue; + } + if let Some(cash) = cash_limit { while take_qty > 0 { + quote_price = + self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); + if !quote_price.is_finite() || quote_price <= 0.0 { + budget_block_reason = Some("invalid execution price"); + take_qty = 0; + break; + } + quote_price = + self.execution_price_with_limit_slippage(quote_price, limit_price); let candidate_gross = gross_amount + quote_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { budget_block_reason = Some("value budget limit"); @@ -4621,6 +4705,10 @@ where } } + quote_price = + self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); + quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); + gross_amount += quote_price * take_qty as f64; filled_qty += take_qty; last_timestamp = Some(quote.timestamp); diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index cff9700..423a72c 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::path::Path; @@ -1179,6 +1179,33 @@ impl DataSet { .unwrap_or(&[]) } + pub fn execution_quote_key_set(&self) -> HashSet<(NaiveDate, String)> { + self.execution_quotes_index.keys().cloned().collect() + } + + pub fn add_execution_quotes(&mut self, quotes: Vec) -> usize { + let mut added = 0usize; + let mut touched = HashSet::<(NaiveDate, String)>::new(); + for quote in quotes { + let key = (quote.date, quote.symbol.clone()); + let rows = self.execution_quotes_index.entry(key.clone()).or_default(); + if rows.iter().any(|existing| { + existing.timestamp == quote.timestamp && existing.symbol == quote.symbol + }) { + continue; + } + rows.push(quote); + touched.insert(key); + added += 1; + } + for key in touched { + if let Some(rows) = self.execution_quotes_index.get_mut(&key) { + rows.sort_by_key(|quote| quote.timestamp); + } + } + added + } + pub fn order_book_depth_on( &self, date: NaiveDate, diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index e689b5c..185e6a2 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -314,6 +314,7 @@ pub struct BacktestEngine { config: BacktestConfig, dividend_reinvestment: bool, cash_dividends_enabled: bool, + cash_dividend_adjusts_cost_basis: bool, process_event_bus: ProcessEventBus, dynamic_universe: Option>, subscriptions: BTreeSet, @@ -340,6 +341,7 @@ impl BacktestEngine { config, dividend_reinvestment: false, cash_dividends_enabled: true, + cash_dividend_adjusts_cost_basis: true, process_event_bus: ProcessEventBus::new(), dynamic_universe: None, subscriptions: BTreeSet::new(), @@ -363,6 +365,11 @@ impl BacktestEngine { self } + pub fn with_cash_dividend_cost_basis_adjustment(mut self, enabled: bool) -> Self { + self.cash_dividend_adjusts_cost_basis = enabled; + self + } + pub fn with_futures_account(mut self, account: FuturesAccountState) -> Self { self.futures_account = Some(account); self @@ -2534,7 +2541,11 @@ where let position = portfolio .position_mut_if_exists(&action.symbol) .expect("position exists for dividend action"); - let cash_delta = position.apply_cash_dividend(action.share_cash); + let cash_delta = if self.cash_dividend_adjusts_cost_basis { + position.apply_cash_dividend(action.share_cash) + } else { + position.apply_cash_dividend_preserve_cost_basis(action.share_cash) + }; (cash_delta, position.quantity, position.average_cost) }; if cash_delta.abs() > f64::EPSILON { diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index ec43fa6..2e313b1 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -19,7 +19,9 @@ pub mod strategy; pub mod strategy_ai; pub mod universe; -pub use broker::{BrokerExecutionReport, BrokerSimulator, MatchingType, SlippageModel}; +pub use broker::{ + BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, SlippageModel, +}; pub use calendar::TradingCalendar; pub use cost::{ChinaAShareCostModel, CostModel, TradingCost}; pub use data::{ @@ -49,8 +51,8 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics}; pub use platform_expr_strategy::{ PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, - PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, - PlatformUniverseActionKind, + PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformSelectionPrefetchPlan, + PlatformTradeAction, PlatformUniverseActionKind, }; pub use platform_runtime_schema::{ PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index d0986bb..df075ed 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -472,6 +472,22 @@ pub struct PlatformExprStrategy { cache_misses: RefCell, } +#[derive(Debug, Clone, PartialEq)] +pub struct PlatformSelectionPrefetchPlan { + pub execution_date: NaiveDate, + pub decision_date: NaiveDate, + pub selection_date: NaiveDate, + pub factor_date: NaiveDate, + pub requires_intraday_selection_quotes: bool, + pub band_low: f64, + pub band_high: f64, + pub selection_limit: usize, + pub processed_scope: usize, + pub candidate_symbols: Vec, + pub order_symbols: Vec, + pub diagnostics: Vec, +} + impl PlatformExprStrategy { pub fn new(config: PlatformExprStrategyConfig) -> Self { let mut engine = Engine::new(); @@ -1222,6 +1238,27 @@ impl PlatformExprStrategy { .count() } + fn projected_position_value_at_execution_price( + &self, + ctx: &StrategyContext<'_>, + projected: &PortfolioState, + date: NaiveDate, + symbol: &str, + ) -> f64 { + let Some(position) = projected.position(symbol) else { + return 0.0; + }; + let Some(market) = ctx.data.market(date, symbol) else { + return position.market_value(); + }; + let execution_price = self.projected_execution_price(market, OrderSide::Buy); + if execution_price.is_finite() && execution_price > 0.0 { + position.quantity as f64 * execution_price + } else { + position.market_value() + } + } + fn project_order_value( &self, ctx: &StrategyContext<'_>, @@ -1611,7 +1648,11 @@ impl PlatformExprStrategy { low: feature_market.low, close: feature_market.close, last: decision_quote - .and_then(|quote| quote.buy_price()) + .and_then(|quote| { + (quote.last_price.is_finite() && quote.last_price > 0.0) + .then_some(quote.last_price) + }) + .or_else(|| decision_quote.and_then(|quote| quote.buy_price())) .unwrap_or(market.last_price), prev_close: market.prev_close, amount, @@ -3733,6 +3774,22 @@ impl PlatformExprStrategy { Ok(value.round().max(1.0) as usize) } + fn selection_dates(&self, ctx: &StrategyContext<'_>) -> (NaiveDate, NaiveDate) { + let decision_date = ctx.decision_date; + let factor_date = ctx + .data + .previous_trading_date(decision_date, 1) + .unwrap_or(decision_date); + let selection_date = if self.config.aiquant_transaction_cost + && self.config.intraday_execution_time.is_some() + { + ctx.execution_date + } else { + decision_date + }; + (selection_date, factor_date) + } + fn buy_scale( &self, ctx: &StrategyContext<'_>, @@ -4964,6 +5021,175 @@ impl PlatformExprStrategy { Ok((selected, diagnostics)) } + fn stock_filter_uses_intraday_quote_fields(&self) -> bool { + let expr = Self::normalize_runtime_field_aliases(&self.config.stock_filter_expr); + Self::extract_identifier_candidates(&expr) + .into_iter() + .any(|name| { + matches!( + name.as_str(), + "last" + | "last_price" + | "bid1" + | "ask1" + | "bid1_volume" + | "ask1_volume" + | "tick_volume" + | "touched_upper_limit" + | "touched_lower_limit" + | "hit_upper_limit" + | "hit_lower_limit" + ) + }) + } + + fn select_prefetch_symbols( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + factor_date: NaiveDate, + day: &DayExpressionState, + band_low: f64, + band_high: f64, + selection_limit: usize, + ) -> Result<(Vec, Vec, Vec), BacktestError> { + let universe = self.selectable_universe_on(ctx, date, factor_date); + let mut diagnostics = Vec::new(); + let mut candidates = Vec::new(); + let apply_stock_filter = !self.stock_filter_uses_intraday_quote_fields(); + for candidate in universe { + let stock = + self.stock_state_with_factor_date(ctx, date, factor_date, &candidate.symbol)?; + let field_value = self.selection_field_value(&candidate, &stock); + if !field_value.is_finite() { + if diagnostics.len() < 12 { + diagnostics.push(format!( + "{} prefetch rejected by missing selection field", + candidate.symbol + )); + } + continue; + } + if field_value < band_low || field_value > band_high { + continue; + } + let rank_value = self.rank_value(ctx, day, &candidate, &stock)?; + if !rank_value.is_finite() { + if diagnostics.len() < 12 { + diagnostics.push(format!( + "{} prefetch rejected by missing rank field", + candidate.symbol + )); + } + continue; + } + candidates.push((candidate.symbol.clone(), rank_value)); + } + candidates.sort_by(|lhs, rhs| { + let ordering = if self.config.rank_desc { + rhs.1 + .partial_cmp(&lhs.1) + .unwrap_or(std::cmp::Ordering::Equal) + } else { + lhs.1 + .partial_cmp(&rhs.1) + .unwrap_or(std::cmp::Ordering::Equal) + }; + if ordering == std::cmp::Ordering::Equal { + lhs.0.cmp(&rhs.0) + } else { + ordering + } + }); + + let mut processed_symbols = Vec::new(); + let mut selected_symbols = Vec::new(); + let mut batch_size = selection_limit.saturating_add(80).max(120); + let mut cursor = 0usize; + while cursor < candidates.len() && selected_symbols.len() < selection_limit { + let end = (cursor + batch_size).min(candidates.len()); + for (symbol, _) in &candidates[cursor..end] { + processed_symbols.push(symbol.clone()); + } + for (symbol, _) in &candidates[cursor..end] { + let stock = self.stock_state_with_factor_date(ctx, date, factor_date, symbol)?; + if let Some(reason) = self.buy_rejection_reason(ctx, date, symbol, &stock)? { + if diagnostics.len() < 12 { + diagnostics.push(format!("{symbol} prefetch rejected by {reason}")); + } + continue; + } + if apply_stock_filter && !self.stock_passes_expr(ctx, day, &stock)? { + if diagnostics.len() < 12 { + diagnostics.push(format!("{symbol} prefetch rejected by stock_expr")); + } + continue; + } + selected_symbols.push(symbol.clone()); + if selected_symbols.len() >= selection_limit { + break; + } + } + cursor = end; + batch_size = batch_size.saturating_mul(2).max(120); + } + Ok((processed_symbols, selected_symbols, diagnostics)) + } + + pub fn selection_prefetch_plan( + &self, + ctx: &StrategyContext<'_>, + _scope_limit: usize, + ) -> Result { + if !self.config.rotation_enabled || self.config.in_skip_window(ctx.execution_date) { + return Ok(PlatformSelectionPrefetchPlan { + execution_date: ctx.execution_date, + decision_date: ctx.decision_date, + selection_date: ctx.execution_date, + factor_date: ctx.decision_date, + requires_intraday_selection_quotes: false, + band_low: 0.0, + band_high: 0.0, + selection_limit: 0, + processed_scope: 0, + candidate_symbols: Vec::new(), + order_symbols: Vec::new(), + diagnostics: Vec::new(), + }); + } + + let day = self.day_state(ctx, ctx.decision_date)?; + let (selection_date, factor_date) = self.selection_dates(ctx); + let requires_intraday_selection_quotes = self.stock_filter_uses_intraday_quote_fields(); + let (band_low, band_high) = self.market_cap_band(ctx, &day)?; + let selection_limit = self + .selection_limit(ctx, &day)? + .min(self.config.max_positions.max(1)); + let (candidate_symbols, order_symbols, diagnostics) = self.select_prefetch_symbols( + ctx, + selection_date, + factor_date, + &day, + band_low, + band_high, + selection_limit, + )?; + Ok(PlatformSelectionPrefetchPlan { + execution_date: ctx.execution_date, + decision_date: ctx.decision_date, + selection_date, + factor_date, + requires_intraday_selection_quotes, + band_low, + band_high, + selection_limit, + processed_scope: candidate_symbols.len(), + candidate_symbols, + order_symbols, + diagnostics, + }) + } + fn stop_take_action( &self, ctx: &StrategyContext<'_>, @@ -4980,10 +5206,17 @@ impl PlatformExprStrategy { { return Ok((false, false)); } - let avg_price = position - .average_entry_price() - .filter(|value| value.is_finite() && *value > 0.0) - .unwrap_or(position.average_cost); + let avg_price = if self.config.aiquant_transaction_cost + && position.average_cost.is_finite() + && position.average_cost > 0.0 + { + position.average_cost + } else { + position + .average_entry_price() + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or(position.average_cost) + }; if position.quantity == 0 || avg_price <= 0.0 { return Ok((false, false)); } @@ -5123,17 +5356,7 @@ impl Strategy for PlatformExprStrategy { } let day = self.day_state(ctx, decision_date)?; - let selection_factor_date = ctx - .data - .previous_trading_date(decision_date, 1) - .unwrap_or(decision_date); - let selection_market_date = if self.config.aiquant_transaction_cost - && self.config.intraday_execution_time.is_some() - { - execution_date - } else { - decision_date - }; + let (selection_market_date, selection_factor_date) = self.selection_dates(ctx); let (explicit_action_intents, explicit_action_diagnostics) = if self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay && self.explicit_actions_active(ctx.data.calendar(), execution_date) @@ -5214,14 +5437,6 @@ impl Strategy for PlatformExprStrategy { let mut intraday_attempted_buys = BTreeSet::::new(); let mut delayed_sold_symbols = BTreeSet::::new(); let mut unresolved_stop_loss_symbols = BTreeSet::::new(); - let initial_position_symbols = ctx - .portfolio - .positions() - .values() - .filter(|position| position.quantity > 0) - .map(|position| position.symbol.clone()) - .collect::>(); - let take_profit_multiplier = self .config .take_profit_expr @@ -5240,10 +5455,17 @@ impl Strategy for PlatformExprStrategy { }; if let Some(multiplier) = take_profit_multiplier { for position in ctx.portfolio.positions().values() { - let avg_price = position - .average_entry_price() - .filter(|value| value.is_finite() && *value > 0.0) - .unwrap_or(position.average_cost); + let avg_price = if self.config.aiquant_transaction_cost + && position.average_cost.is_finite() + && position.average_cost > 0.0 + { + position.average_cost + } else { + position + .average_entry_price() + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or(position.average_cost) + }; if position.quantity == 0 || avg_price <= 0.0 || pending_symbols.contains(&position.symbol) @@ -5338,10 +5560,17 @@ impl Strategy for PlatformExprStrategy { if delayed_sold_symbols.contains(&position.symbol) { continue; } - let avg_price = position - .average_entry_price() - .filter(|value| value.is_finite() && *value > 0.0) - .unwrap_or(position.average_cost); + let avg_price = if self.config.aiquant_transaction_cost + && position.average_cost.is_finite() + && position.average_cost > 0.0 + { + position.average_cost + } else { + position + .average_entry_price() + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or(position.average_cost) + }; if position.quantity == 0 || avg_price <= 0.0 { continue; } @@ -5484,14 +5713,69 @@ impl Strategy for PlatformExprStrategy { } } - aiquant_available_cash = projected.cash(); + if !self.config.aiquant_transaction_cost { + aiquant_available_cash = projected.cash(); + } let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64; - let max_periodic_buys = - selection_limit.saturating_sub(initial_position_symbols.len().min(selection_limit)); - let mut periodic_buy_count = 0_usize; for symbol in stock_list.iter().take(selection_limit) { - if periodic_buy_count >= max_periodic_buys { - break; + let decision_stock = self.stock_state_with_factor_date( + ctx, + decision_date, + selection_factor_date, + symbol, + )?; + let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; + let target_cash = fixed_buy_cash * stock_scale; + if projected.positions().contains_key(symbol) { + if self.config.aiquant_transaction_cost { + continue; + } + if same_day_sold_symbols.contains(symbol) + || unresolved_stop_loss_symbols.contains(symbol) + || intraday_attempted_buys.contains(symbol) + { + continue; + } + let current_value = self.projected_position_value_at_execution_price( + ctx, + &projected, + execution_date, + symbol, + ); + let buy_cash = (target_cash - current_value).min(aiquant_available_cash); + if buy_cash <= 0.0 { + continue; + } + let execution_stock = self.stock_state(ctx, execution_date, symbol)?; + if self + .buy_rejection_reason(ctx, execution_date, symbol, &execution_stock)? + .is_some() + { + continue; + } + if !self.stock_passes_expr(ctx, &day, &decision_stock)? { + continue; + } + order_intents.push(OrderIntent::Value { + symbol: symbol.clone(), + value: buy_cash, + reason: "periodic_rebalance_buy".to_string(), + }); + let cash_before_buy = projected.cash(); + let filled_qty = self.project_order_value( + ctx, + &mut projected, + execution_date, + symbol, + buy_cash, + &mut projected_execution_state, + ); + if filled_qty > 0 { + let spent = (cash_before_buy - projected.cash()).max(0.0); + aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); + intraday_attempted_buys.insert(symbol.clone()); + } + continue; } if Self::projected_position_count_excluding( &projected, @@ -5512,22 +5796,18 @@ impl Strategy for PlatformExprStrategy { &unresolved_stop_loss_symbols, )) .max(1); - let decision_stock = self.stock_state_with_factor_date( - ctx, - decision_date, - selection_factor_date, - symbol, - )?; - let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; let cash_cap = if self.config.aiquant_transaction_cost { aiquant_available_cash } else { aiquant_available_cash / slots_remaining as f64 }; - let buy_cash = (fixed_buy_cash * stock_scale).min(cash_cap); + let buy_cash = target_cash.min(cash_cap); if buy_cash <= 0.0 { break; } + if self.config.aiquant_transaction_cost && buy_cash < target_cash * 0.5 { + break; + } let execution_stock = self.stock_state(ctx, execution_date, symbol)?; if self .buy_rejection_reason(ctx, execution_date, symbol, &execution_stock)? @@ -5555,7 +5835,6 @@ impl Strategy for PlatformExprStrategy { if filled_qty > 0 { let spent = (cash_before_buy - projected.cash()).max(0.0); aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); - periodic_buy_count += 1; } } } @@ -5611,7 +5890,8 @@ impl Strategy for PlatformExprStrategy { day.total_value ), format!("selected_symbols={}", stock_list.join(",")), - "platform strategy script executed through expression runtime + bid1/ask1 snapshot execution".to_string(), + "platform strategy script executed through expression runtime + batched tick execution" + .to_string(), ]; diagnostics.extend(selection_notes); diagnostics.extend(explicit_action_diagnostics); @@ -6565,9 +6845,9 @@ mod tests { .stock_state(&ctx, date, symbol) .expect("stock state"); - assert_eq!(stock.last, 1.36); + assert_eq!(stock.last, 1.35); assert!( - strategy + !strategy .stock_passes_expr(&ctx, &day, &stock) .expect("stock expr") ); @@ -8790,7 +9070,7 @@ mod tests { } #[test] - fn platform_aiquant_profile_uses_calendar_day_rebalance_interval() { + fn platform_calendar_day_rebalance_interval_can_be_enabled_explicitly() { let dates = [d(2023, 1, 12), d(2023, 1, 13)]; let symbols = ["000001.SZ", "000002.SZ"]; let data = DataSet::from_components( @@ -9595,6 +9875,144 @@ mod tests { ); } + #[test] + fn platform_periodic_rebalance_tops_up_underweight_selected_holding() { + let prev_date = d(2025, 5, 13); + let date = d(2025, 5, 14); + let symbol = "600778.SH"; + let data = DataSet::from_components_with_actions_and_quotes( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-05-14 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.9, + volume: 1_000_000, + tick_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_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: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + Vec::new(), + vec![IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"), + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 10_000, + amount_delta: 100_000.0, + trading_phase: Some("continuous".to_string()), + }], + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(499_000.0); + portfolio.position_mut(symbol).buy(prev_date, 100, 10.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 20, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = symbol.to_string(); + cfg.refresh_rate = 20; + cfg.max_positions = 1; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "1".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.take_profit_expr.clear(); + cfg.stop_loss_expr.clear(); + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.rebalance_day_counter = 20; + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert_eq!( + decision.order_intents.len(), + 1, + "{:?}", + decision.order_intents + ); + assert!(matches!( + &decision.order_intents[0], + OrderIntent::Value { + symbol: intent_symbol, + value, + reason, + } if intent_symbol == symbol + && reason == "periodic_rebalance_buy" + && (*value - 499_000.0).abs() < 1e-6 + )); + } + #[test] fn platform_aiquant_projection_reserves_sell_cost_without_same_callback_proceeds() { let prev_date = d(2025, 5, 13); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index a5a489e..c4823ff 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -61,6 +61,12 @@ pub struct StrategyExecutionSpec { #[serde(default)] pub slippage_value: Option, #[serde(default)] + pub slippage_impact_coefficient: Option, + #[serde(default)] + pub slippage_volatility_coefficient: Option, + #[serde(default)] + pub slippage_max_value: Option, + #[serde(default)] pub strict_value_budget: Option, } @@ -96,6 +102,12 @@ pub struct StrategyEngineConfig { #[serde(default)] pub slippage_value: Option, #[serde(default)] + pub slippage_impact_coefficient: Option, + #[serde(default)] + pub slippage_volatility_coefficient: Option, + #[serde(default)] + pub slippage_max_value: Option, + #[serde(default)] pub strict_value_budget: Option, #[serde(default)] pub dividend_reinvestment: Option, @@ -718,7 +730,6 @@ pub fn platform_expr_config_from_spec( .map(|value| value.trim().to_ascii_lowercase()) .is_some_and(|value| value == "aiquant_rqalpha" || value == "aiquant") { - cfg.calendar_rebalance_interval = true; cfg.aiquant_transaction_cost = true; } @@ -1138,7 +1149,7 @@ mod tests { assert!(!cfg.rotation_enabled); assert!(cfg.daily_top_up_enabled); assert!(cfg.retry_empty_rebalance); - assert!(cfg.calendar_rebalance_interval); + assert!(!cfg.calendar_rebalance_interval); assert!(cfg.aiquant_transaction_cost); assert_eq!(cfg.explicit_actions.len(), 1); assert_eq!( @@ -1163,7 +1174,7 @@ mod tests { cfg.intraday_execution_time, Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap()) ); - assert!(cfg.calendar_rebalance_interval); + assert!(!cfg.calendar_rebalance_interval); assert!(cfg.aiquant_transaction_cost); } } diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 5cc430f..b7f86a3 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -270,15 +270,31 @@ impl Position { } pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 { + self.apply_cash_dividend_internal(dividend_per_share, true) + } + + pub fn apply_cash_dividend_preserve_cost_basis(&mut self, dividend_per_share: f64) -> f64 { + self.apply_cash_dividend_internal(dividend_per_share, false) + } + + fn apply_cash_dividend_internal( + &mut self, + dividend_per_share: f64, + adjust_cost_basis: bool, + ) -> f64 { if self.quantity == 0 || !dividend_per_share.is_finite() || dividend_per_share == 0.0 { return 0.0; } for lot in &mut self.lots { lot.entry_price -= dividend_per_share; - lot.price -= dividend_per_share; + if adjust_cost_basis { + lot.price -= dividend_per_share; + } + } + if adjust_cost_basis { + self.average_cost -= dividend_per_share; } - self.average_cost -= dividend_per_share; self.last_price -= dividend_per_share; let cash_delta = self.quantity as f64 * dividend_per_share; self.day_dividend_cash += cash_delta; @@ -887,6 +903,23 @@ mod tests { assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12); } + #[test] + fn cash_dividend_can_preserve_avg_cost_for_aiquant_compatibility() { + let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let mut position = Position::new("603102.SH"); + position.buy(date, 1000, 46.45); + position.record_buy_trade_cost(1000, 37.16); + + let cost_before = position.average_cost; + let entry_before = position.average_entry_price().unwrap(); + let cash = position.apply_cash_dividend_preserve_cost_basis(0.6); + + assert!((cash - 600.0).abs() < 1e-12); + assert!((position.average_cost - cost_before).abs() < 1e-12); + assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12); + assert!((position.last_price - 45.85).abs() < 1e-12); + } + #[test] fn portfolio_tracks_dividend_receivable_and_day_pnl() { let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index c94ce11..866a62a 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -1,9 +1,9 @@ use chrono::{NaiveDate, NaiveTime}; use fidc_core::{ AlgoOrderStyle, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, - ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, - IntradayExecutionQuote, MatchingType, OrderIntent, OrderStatus, PortfolioState, PriceField, - ProcessEventKind, SlippageModel, StrategyDecision, TargetPortfolioOrderPricing, + ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, DynamicSlippageConfig, + Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, OrderStatus, PortfolioState, + PriceField, ProcessEventKind, SlippageModel, StrategyDecision, TargetPortfolioOrderPricing, }; use std::collections::{BTreeMap, BTreeSet}; @@ -1485,6 +1485,110 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() { assert!((report.fill_events[0].price - 10.1).abs() < 1e-9); } +#[test] +fn broker_applies_dynamic_slippage_on_snapshot_fills() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000002.SZ".to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000002.SZ".to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_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: "000002.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(), + }], + vec![CandidateEligibility { + date, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + 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(1_000_000.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_slippage_model(SlippageModel::Dynamic(DynamicSlippageConfig::new( + 0.5, 0.3, 0.1, + ))); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "000002.SZ".to_string(), + value: 100_000.0, + reason: "dynamic_slippage".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 1); + let expected_ratio = ((10.0 * report.fill_events[0].quantity as f64) / (100_000.0 * 10.0)) + * 0.5 + + ((10.1 - 9.9) / 10.0) * 0.3; + assert!((report.fill_events[0].price - 10.0 * (1.0 + expected_ratio)).abs() < 1e-9); +} + #[test] fn broker_applies_tick_size_slippage_on_intraday_last_fills() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();