use std::collections::{BTreeMap, BTreeSet}; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use crate::cost::ChinaAShareCostModel; use crate::data::{ DailyMarketSnapshot, DataSet, DividendRecord, FactorTextValue, FactorValue, IntradayExecutionQuote, PriceBar, PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint, }; use crate::engine::BacktestError; use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent}; use crate::futures::{FuturesAccountState, FuturesOrderIntent}; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit}; use crate::scheduler::ScheduleRule; use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector}; pub trait Strategy { fn name(&self) -> &str; fn management_fee( &mut self, _ctx: &StrategyContext<'_>, _rate: f64, ) -> Result, BacktestError> { Ok(None) } fn on_process_event( &mut self, _ctx: &StrategyContext<'_>, _event: &ProcessEvent, ) -> Result<(), BacktestError> { Ok(()) } fn schedule_rules(&self) -> Vec { Vec::new() } fn decision_quote_times(&self) -> Vec { Vec::new() } fn decision_quote_symbols( &mut self, _ctx: &StrategyContext<'_>, ) -> Result, BacktestError> { Ok(BTreeSet::new()) } fn on_scheduled( &mut self, _ctx: &StrategyContext<'_>, _rule: &ScheduleRule, ) -> Result { Ok(StrategyDecision::default()) } fn before_trading(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { Ok(()) } fn open_auction( &mut self, _ctx: &StrategyContext<'_>, ) -> Result { Ok(StrategyDecision::default()) } fn on_bar(&mut self, _ctx: &StrategyContext<'_>) -> Result { Ok(StrategyDecision::default()) } fn on_minute( &mut self, _ctx: &StrategyContext<'_>, _quote: &IntradayExecutionQuote, ) -> Result { Ok(StrategyDecision::default()) } fn on_day(&mut self, _ctx: &StrategyContext<'_>) -> Result { Ok(StrategyDecision::default()) } fn after_trading(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { Ok(()) } fn on_settlement(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { Ok(()) } } #[derive(Debug, Clone)] pub struct OpenOrderView { pub order_id: u64, pub symbol: String, pub side: OrderSide, pub requested_quantity: u32, pub filled_quantity: u32, pub remaining_quantity: u32, pub unfilled_quantity: u32, pub status: OrderStatus, pub avg_price: f64, pub transaction_cost: f64, pub limit_price: f64, pub reason: String, } #[derive(Debug, Clone)] pub struct OrderRuntimeView { pub order_id: u64, pub symbol: String, pub side: OrderSide, pub requested_quantity: u32, pub filled_quantity: u32, pub unfilled_quantity: u32, pub status: OrderStatus, pub avg_price: f64, pub transaction_cost: f64, pub limit_price: f64, pub reason: String, } #[derive(Debug, Clone)] pub struct PortfolioRuntimeView { pub account_type: &'static str, pub starting_cash: f64, pub units: f64, pub cash: f64, pub available_cash: f64, pub frozen_cash: f64, pub market_value: f64, pub total_value: f64, pub portfolio_value: f64, pub total_equity: f64, pub unit_net_value: f64, pub static_unit_net_value: f64, pub daily_pnl: f64, pub daily_returns: f64, pub total_returns: f64, pub transaction_cost: f64, pub trading_pnl: f64, pub position_pnl: f64, pub cash_liabilities: f64, pub management_fee_rate: f64, pub management_fees: f64, } pub struct StrategyContext<'a> { pub execution_date: NaiveDate, pub decision_date: NaiveDate, pub decision_index: usize, pub data: &'a DataSet, pub portfolio: &'a PortfolioState, pub futures_account: Option<&'a FuturesAccountState>, pub open_orders: &'a [OpenOrderView], pub dynamic_universe: Option<&'a BTreeSet>, pub subscriptions: &'a BTreeSet, pub process_events: &'a [ProcessEvent], pub active_process_event: Option<&'a ProcessEvent>, pub active_datetime: Option, pub order_events: &'a [OrderEvent], pub fills: &'a [FillEvent], } impl StrategyContext<'_> { pub fn signal_date(&self) -> NaiveDate { self.decision_date } pub fn execution_trade_date(&self) -> NaiveDate { self.execution_date } fn current_data_date(&self) -> NaiveDate { let active_date = self .active_datetime .map(|datetime| datetime.date()) .unwrap_or(self.decision_date); if self.is_lagged_execution() && active_date > self.decision_date { self.decision_date } else { active_date } } fn current_data_datetime(&self) -> Option { self.active_datetime .map(|datetime| self.current_data_date().and_time(datetime.time())) } pub fn is_lagged_execution(&self) -> bool { self.execution_date != self.decision_date } pub fn current_datetime(&self) -> Option { self.active_datetime } pub fn current_time(&self) -> Option { self.active_datetime.map(|value| value.time()) } pub fn has_open_orders(&self) -> bool { !self.open_orders.is_empty() } pub fn open_order_count(&self) -> usize { self.open_orders.len() } pub fn open_buy_order_count(&self) -> usize { self.open_orders .iter() .filter(|order| order.side == OrderSide::Buy) .count() } pub fn open_sell_order_count(&self) -> usize { self.open_orders .iter() .filter(|order| order.side == OrderSide::Sell) .count() } pub fn open_buy_quantity(&self) -> u32 { self.open_orders .iter() .filter(|order| order.side == OrderSide::Buy) .map(|order| order.remaining_quantity) .sum() } pub fn open_sell_quantity(&self) -> u32 { self.open_orders .iter() .filter(|order| order.side == OrderSide::Sell) .map(|order| order.remaining_quantity) .sum() } pub fn symbol_open_order_count(&self, symbol: &str) -> usize { self.open_orders .iter() .filter(|order| order.symbol == symbol) .count() } pub fn symbol_open_buy_quantity(&self, symbol: &str) -> u32 { self.open_orders .iter() .filter(|order| order.symbol == symbol && order.side == OrderSide::Buy) .map(|order| order.remaining_quantity) .sum() } pub fn symbol_open_sell_quantity(&self, symbol: &str) -> u32 { self.open_orders .iter() .filter(|order| order.symbol == symbol && order.side == OrderSide::Sell) .map(|order| order.remaining_quantity) .sum() } pub fn latest_open_order_id(&self) -> u64 { self.open_orders .iter() .map(|order| order.order_id) .max() .unwrap_or(0) } pub fn latest_open_order_status(&self) -> &'static str { self.open_orders .iter() .max_by_key(|order| order.order_id) .map(|order| order.status.as_str()) .unwrap_or("") } pub fn latest_open_order_unfilled_quantity(&self) -> u32 { self.open_orders .iter() .max_by_key(|order| order.order_id) .map(|order| order.unfilled_quantity) .unwrap_or(0) } pub fn latest_symbol_open_order_id(&self, symbol: &str) -> u64 { self.open_orders .iter() .filter(|order| order.symbol == symbol) .map(|order| order.order_id) .max() .unwrap_or(0) } pub fn latest_symbol_open_order_status(&self, symbol: &str) -> &'static str { self.open_orders .iter() .filter(|order| order.symbol == symbol) .max_by_key(|order| order.order_id) .map(|order| order.status.as_str()) .unwrap_or("") } pub fn latest_symbol_open_order_unfilled_quantity(&self, symbol: &str) -> u32 { self.open_orders .iter() .filter(|order| order.symbol == symbol) .max_by_key(|order| order.order_id) .map(|order| order.unfilled_quantity) .unwrap_or(0) } pub fn order(&self, order_id: u64) -> Option { let fills = self .fills .iter() .filter(|fill| fill.order_id == Some(order_id)) .collect::>(); let filled_quantity = fills.iter().map(|fill| fill.quantity).sum::(); let gross_amount = fills.iter().map(|fill| fill.gross_amount).sum::(); let transaction_cost = fills .iter() .map(|fill| fill.commission + fill.stamp_tax) .sum::(); let avg_price = if filled_quantity == 0 { 0.0 } else { gross_amount / filled_quantity as f64 }; if let Some(order) = self .open_orders .iter() .find(|order| order.order_id == order_id) { let filled_quantity = order.filled_quantity.max(filled_quantity); return Some(OrderRuntimeView { order_id, symbol: order.symbol.clone(), side: order.side, requested_quantity: order.requested_quantity, filled_quantity, unfilled_quantity: order .unfilled_quantity .min(order.requested_quantity.saturating_sub(filled_quantity)), status: order.status, avg_price: if avg_price > 0.0 { avg_price } else { order.avg_price }, transaction_cost: if transaction_cost > 0.0 { transaction_cost } else { order.transaction_cost }, limit_price: order.limit_price, reason: order.reason.clone(), }); } let latest_event = self .order_events .iter() .rev() .filter(|event| event.order_id == Some(order_id)) .next()?; let filled_quantity = latest_event.filled_quantity.max(filled_quantity); Some(OrderRuntimeView { order_id, symbol: latest_event.symbol.clone(), side: latest_event.side, requested_quantity: latest_event.requested_quantity, filled_quantity, unfilled_quantity: latest_event .requested_quantity .saturating_sub(filled_quantity), status: latest_event.status, avg_price, transaction_cost, limit_price: 0.0, reason: latest_event.reason.clone(), }) } pub fn order_status(&self, order_id: u64) -> &'static str { self.order(order_id) .map(|order| order.status.as_str()) .unwrap_or("") } pub fn order_avg_price(&self, order_id: u64) -> f64 { self.order(order_id) .map(|order| order.avg_price) .unwrap_or(0.0) } pub fn order_transaction_cost(&self, order_id: u64) -> f64 { self.order(order_id) .map(|order| order.transaction_cost) .unwrap_or(0.0) } pub fn portfolio_view(&self) -> PortfolioRuntimeView { let frozen_cash = self.frozen_cash(); let cash = self.portfolio.cash(); let total_equity = self.portfolio.total_equity(); PortfolioRuntimeView { account_type: "STOCK", starting_cash: self.portfolio.starting_cash(), units: self.portfolio.units(), cash, available_cash: (cash - frozen_cash).max(0.0), frozen_cash, market_value: self.portfolio.market_value(), total_value: self.portfolio.total_value(), portfolio_value: self.portfolio.portfolio_value(), total_equity, unit_net_value: self.portfolio.unit_net_value(), static_unit_net_value: self.portfolio.static_unit_net_value(), daily_pnl: self.portfolio.daily_pnl(), daily_returns: self.portfolio.daily_returns(), total_returns: self.portfolio.total_returns(), transaction_cost: self.portfolio.transaction_cost(), trading_pnl: self.portfolio.trading_pnl(), position_pnl: self.portfolio.position_pnl(), cash_liabilities: self.portfolio.cash_liabilities(), management_fee_rate: self.portfolio.management_fee_rate(), management_fees: self.portfolio.management_fees(), } } pub fn account(&self) -> PortfolioRuntimeView { self.portfolio_view() } pub fn stock_account(&self) -> PortfolioRuntimeView { self.portfolio_view() } pub fn future_account(&self) -> Option { self.futures_account.map(|account| { let starting_cash = account.starting_cash(); let total_value = account.total_value(); let daily_pnl = account.daily_pnl(); let static_base = total_value - daily_pnl; let unit_net_value = safe_ratio(total_value, starting_cash); let static_unit_net_value = safe_ratio(static_base, starting_cash); PortfolioRuntimeView { account_type: "FUTURE", starting_cash, units: 1.0, cash: account.cash(), available_cash: account.cash(), frozen_cash: account.frozen_cash(), market_value: account.market_value(), total_value, portfolio_value: total_value, total_equity: total_value, unit_net_value, static_unit_net_value, daily_pnl, daily_returns: safe_ratio(daily_pnl, static_base), total_returns: safe_ratio(total_value - starting_cash, starting_cash), transaction_cost: account.transaction_cost(), trading_pnl: account.trading_pnl(), position_pnl: account.position_pnl(), cash_liabilities: 0.0, management_fee_rate: 0.0, management_fees: 0.0, } }) } pub fn account_by_type(&self, account_type: &str) -> Option { if account_type.eq_ignore_ascii_case("STOCK") { Some(self.stock_account()) } else if account_type.eq_ignore_ascii_case("FUTURE") { self.future_account() } else { None } } pub fn accounts(&self) -> BTreeMap { let mut accounts = BTreeMap::from([("STOCK".to_string(), self.stock_account())]); if let Some(future_account) = self.future_account() { accounts.insert("FUTURE".to_string(), future_account); } accounts } pub fn frozen_cash(&self) -> f64 { self.open_orders .iter() .filter(|order| order.side == OrderSide::Buy) .map(|order| { let price = if order.limit_price.is_finite() { order.limit_price.max(0.0) } else { 0.0 }; order.remaining_quantity as f64 * price }) .sum() } pub fn available_cash(&self) -> f64 { (self.portfolio.cash() - self.frozen_cash()).max(0.0) } pub fn available_sellable_qty(&self, symbol: &str, raw_sellable_qty: u32) -> u32 { raw_sellable_qty.saturating_sub(self.symbol_open_sell_quantity(symbol)) } pub fn has_dynamic_universe(&self) -> bool { self.dynamic_universe .is_some_and(|symbols| !symbols.is_empty()) } pub fn dynamic_universe_count(&self) -> usize { self.dynamic_universe.map_or(0, BTreeSet::len) } pub fn dynamic_universe_contains(&self, symbol: &str) -> bool { self.dynamic_universe .is_some_and(|symbols| symbols.contains(symbol)) } pub fn eligible_universe_on( &self, date: NaiveDate, ) -> Vec { let eligible = if self.is_lagged_execution() && date == self.decision_date { self.data.fundamental_universe_on(date) } else { self.data.eligible_universe_on(date).to_vec() }; match self.dynamic_universe { Some(symbols) if !symbols.is_empty() => eligible .into_iter() .filter(|row| symbols.contains(&row.symbol)) .collect(), _ => eligible, } } pub fn eligible_universe_on_with_risk_config( &self, date: NaiveDate, risk_config: &crate::risk_control::FidcRiskControlConfig, ) -> Vec { let eligible = if self.is_lagged_execution() && date == self.decision_date { self.data.fundamental_universe_on(date) } else { self.data .eligible_universe_on_with_risk_config(date, risk_config) }; match self.dynamic_universe { Some(symbols) if !symbols.is_empty() => eligible .into_iter() .filter(|row| symbols.contains(&row.symbol)) .collect(), _ => eligible, } } pub fn fundamental_universe_on( &self, date: NaiveDate, ) -> Vec { let eligible = self.data.fundamental_universe_on(date); match self.dynamic_universe { Some(symbols) if !symbols.is_empty() => eligible .into_iter() .filter(|row| symbols.contains(&row.symbol)) .collect(), _ => eligible, } } pub fn current_snapshot(&self, symbol: &str) -> Option<&DailyMarketSnapshot> { self.data.market(self.current_data_date(), symbol) } pub fn history_bars( &self, symbol: &str, bar_count: usize, frequency: &str, field: &str, include_now: bool, ) -> Vec { self.data.history_bars_at( self.current_data_date(), self.current_data_datetime(), symbol, bar_count, frequency, field, include_now, ) } pub fn history_daily_snapshots( &self, symbol: &str, bar_count: usize, include_now: bool, ) -> Vec { self.data .history_daily_snapshots(self.current_data_date(), symbol, bar_count, include_now) } pub fn history_intraday_quotes( &self, symbol: &str, bar_count: usize, include_now: bool, ) -> Vec { self.data.history_intraday_quotes_at( self.current_data_date(), self.current_data_datetime(), symbol, bar_count, include_now, ) } pub fn instrument(&self, symbol: &str) -> Option<&Instrument> { self.data.instrument(symbol) } pub fn instruments(&self, symbols: &[&str]) -> Vec<&Instrument> { symbols .iter() .filter_map(|symbol| self.data.instrument(symbol)) .collect() } pub fn instruments_history(&self, symbols: &[&str]) -> Vec<&Instrument> { self.data.instruments_history(symbols) } pub fn active_instruments(&self, symbols: &[&str]) -> Vec<&Instrument> { self.data .active_instruments(self.current_data_date(), symbols) } pub fn all_instruments(&self) -> Vec<&Instrument> { self.data.all_instruments() } pub fn get_trading_dates(&self, start: NaiveDate, end: NaiveDate) -> Vec { self.data.trading_dates(start, end) } pub fn get_previous_trading_date(&self, date: NaiveDate, n: usize) -> Option { self.data.previous_trading_date(date, n) } pub fn get_next_trading_date(&self, date: NaiveDate, n: usize) -> Option { self.data.next_trading_date(date, n) } pub fn is_suspended(&self, symbol: &str, count: usize) -> Vec { self.data .is_suspended_flags(self.current_data_date(), symbol, count) } pub fn is_st_stock(&self, symbol: &str, count: usize) -> Vec { self.data .is_st_stock_flags(self.current_data_date(), symbol, count) } pub fn get_price( &self, symbol: &str, start: NaiveDate, end: NaiveDate, frequency: &str, ) -> Vec { self.data.get_price(symbol, start, end, frequency) } pub fn get_dividend(&self, symbol: &str, start: NaiveDate) -> Vec { let current_data_date = self.current_data_date(); let end = self .data .previous_trading_date(current_data_date, 1) .unwrap_or(current_data_date); self.data.get_dividend(symbol, start, end) } pub fn get_split(&self, symbol: &str, start: NaiveDate) -> Vec { let current_data_date = self.current_data_date(); let end = self .data .previous_trading_date(current_data_date, 1) .unwrap_or(current_data_date); self.data.get_split(symbol, start, end) } pub fn get_factor( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { self.data.get_factor(symbol, start, end, field) } pub fn get_factor_text( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { self.data.get_factor_text(symbol, start, end, field) } pub fn get_yield_curve( &self, start: NaiveDate, end: NaiveDate, tenor: Option<&str>, ) -> Vec { self.data.get_yield_curve(start, end, tenor) } pub fn get_margin_stocks(&self, margin_type: &str) -> Vec { self.data .get_margin_stocks(self.current_data_date(), margin_type) } pub fn get_securities_margin( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { self.data.get_securities_margin(symbol, start, end, field) } pub fn get_shares( &self, symbol: &str, start: NaiveDate, end: NaiveDate, share_type: &str, ) -> Vec { self.data.get_shares(symbol, start, end, share_type) } pub fn get_turnover_rate( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { self.data.get_turnover_rate(symbol, start, end, field) } pub fn get_price_change_rate( &self, symbol: &str, start: NaiveDate, end: NaiveDate, ) -> Vec { self.data.get_price_change_rate(symbol, start, end) } pub fn get_stock_connect( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { self.data.get_stock_connect(symbol, start, end, field) } pub fn current_performance( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { self.data.current_performance(symbol, start, end, field) } pub fn get_fundamentals( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { self.data.get_fundamentals(symbol, start, end, field) } pub fn get_financials( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { self.data.get_financials(symbol, start, end, field) } pub fn get_pit_financials( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { self.data.get_pit_financials(symbol, start, end, field) } pub fn get_industry(&self, symbol: &str, source: &str, level: usize) -> Option { self.data .get_industry(symbol, self.current_data_date(), source, level) } pub fn get_industry_name( &self, symbol: &str, source: &str, level: usize, ) -> Option { self.data .get_industry_name(symbol, self.current_data_date(), source, level) } pub fn get_dominant_future(&self, underlying_symbol: &str) -> Option { self.data .get_dominant_future(underlying_symbol, self.current_data_date()) } pub fn get_dominant_future_price( &self, underlying_symbol: &str, start: NaiveDate, end: NaiveDate, frequency: &str, ) -> Vec { self.data .get_dominant_future_price(underlying_symbol, start, end, frequency) } pub fn has_subscriptions(&self) -> bool { !self.subscriptions.is_empty() } pub fn subscription_count(&self) -> usize { self.subscriptions.len() } pub fn is_subscribed(&self, symbol: &str) -> bool { self.subscriptions.contains(symbol) } pub fn has_process_events(&self) -> bool { !self.process_events.is_empty() || self.active_process_event.is_some() } pub fn process_event_count(&self) -> usize { self.process_events.len() + usize::from(self.active_process_event.is_some()) } pub fn process_event_count_by_kind(&self, kind: crate::events::ProcessEventKind) -> usize { self.process_events .iter() .filter(|event| event.kind == kind) .count() + usize::from( self.active_process_event .is_some_and(|event| event.kind == kind), ) } pub fn latest_process_event(&self) -> Option<&ProcessEvent> { self.active_process_event .or_else(|| self.process_events.last()) } pub fn latest_process_event_kind(&self) -> &'static str { self.latest_process_event() .map(|event| event.kind.as_str()) .unwrap_or("") } pub fn latest_process_event_order_id(&self) -> u64 { self.latest_process_event() .and_then(|event| event.order_id) .unwrap_or(0) } pub fn latest_process_event_symbol(&self) -> &str { self.latest_process_event() .and_then(|event| event.symbol.as_deref()) .unwrap_or("") } pub fn latest_process_event_side(&self) -> &'static str { self.latest_process_event() .and_then(|event| event.side.as_ref()) .map(OrderSide::as_str) .unwrap_or("") } pub fn latest_process_event_detail(&self) -> &str { self.latest_process_event() .map(|event| event.detail.as_str()) .unwrap_or("") } pub fn current_process_event_kind(&self) -> &'static str { self.active_process_event .map(|event| event.kind.as_str()) .unwrap_or("") } pub fn current_process_event_order_id(&self) -> u64 { self.active_process_event .and_then(|event| event.order_id) .unwrap_or(0) } pub fn current_process_event_symbol(&self) -> &str { self.active_process_event .and_then(|event| event.symbol.as_deref()) .unwrap_or("") } pub fn current_process_event_side(&self) -> &'static str { self.active_process_event .and_then(|event| event.side.as_ref()) .map(OrderSide::as_str) .unwrap_or("") } pub fn current_process_event_detail(&self) -> &str { self.active_process_event .map(|event| event.detail.as_str()) .unwrap_or("") } pub fn process_event_counts(&self) -> BTreeMap { let mut counts = BTreeMap::::new(); for event in self.process_events { *counts.entry(event.kind.as_str().to_string()).or_insert(0) += 1; } if let Some(event) = self.active_process_event { *counts.entry(event.kind.as_str().to_string()).or_insert(0) += 1; } counts } } fn safe_ratio(numerator: f64, denominator: f64) -> f64 { if denominator.abs() <= f64::EPSILON { 0.0 } else { numerator / denominator } } #[derive(Debug, Clone, Default)] pub struct StrategyDecision { pub rebalance: bool, pub target_weights: BTreeMap, pub exit_symbols: BTreeSet, pub order_intents: Vec, pub notes: Vec, pub diagnostics: Vec, pub risk_decisions: Vec, } impl StrategyDecision { pub fn merge_from(&mut self, mut other: StrategyDecision) { self.rebalance |= other.rebalance; self.target_weights.append(&mut other.target_weights); self.exit_symbols.append(&mut other.exit_symbols); self.order_intents.append(&mut other.order_intents); self.notes.append(&mut other.notes); self.diagnostics.append(&mut other.diagnostics); self.risk_decisions.append(&mut other.risk_decisions); } pub fn is_empty(&self) -> bool { !self.rebalance && self.target_weights.is_empty() && self.exit_symbols.is_empty() && self.order_intents.is_empty() && self.notes.is_empty() && self.diagnostics.is_empty() && self.risk_decisions.is_empty() } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AlgoOrderStyle { Vwap, Twap, } #[derive(Debug, Clone)] pub enum TargetPortfolioOrderPricing { LimitPrices(BTreeMap), AlgoOrder { style: AlgoOrderStyle, start_time: Option, end_time: Option, }, } #[derive(Debug, Clone)] pub enum OrderIntent { Shares { symbol: String, quantity: i32, reason: String, }, LimitShares { symbol: String, quantity: i32, limit_price: f64, reason: String, }, Lots { symbol: String, lots: i32, reason: String, }, LimitLots { symbol: String, lots: i32, limit_price: f64, reason: String, }, TargetShares { symbol: String, target_quantity: i32, reason: String, }, LimitTargetShares { symbol: String, target_quantity: i32, limit_price: f64, reason: String, }, TargetValue { symbol: String, target_value: f64, reason: String, }, TimedTargetValue { symbol: String, target_value: f64, style: AlgoOrderStyle, start_time: Option, end_time: Option, reason: String, }, LimitTargetValue { symbol: String, target_value: f64, limit_price: f64, reason: String, }, Value { symbol: String, value: f64, reason: String, }, LimitValue { symbol: String, value: f64, limit_price: f64, reason: String, }, Percent { symbol: String, percent: f64, reason: String, }, LimitPercent { symbol: String, percent: f64, limit_price: f64, reason: String, }, TargetPercent { symbol: String, target_percent: f64, reason: String, }, LimitTargetPercent { symbol: String, target_percent: f64, limit_price: f64, reason: String, }, AlgoValue { symbol: String, value: f64, style: AlgoOrderStyle, start_time: Option, end_time: Option, reason: String, }, AlgoPercent { symbol: String, percent: f64, style: AlgoOrderStyle, start_time: Option, end_time: Option, reason: String, }, TargetPortfolioSmart { target_weights: BTreeMap, order_prices: Option, valuation_prices: Option>, reason: String, }, CancelOrder { order_id: u64, reason: String, }, CancelSymbol { symbol: String, reason: String, }, CancelAll { reason: String, }, UpdateUniverse { symbols: BTreeSet, reason: String, }, Subscribe { symbols: BTreeSet, reason: String, }, Unsubscribe { symbols: BTreeSet, reason: String, }, DepositWithdraw { amount: f64, receiving_days: usize, reason: String, }, FinanceRepay { amount: f64, reason: String, }, SetManagementFeeRate { rate: f64, reason: String, }, Futures { intent: FuturesOrderIntent, }, } #[derive(Debug, Clone)] pub struct CnSmallCapRotationConfig { pub strategy_name: String, pub refresh_rate: usize, pub stocknum: usize, pub xs: f64, pub base_index_level: f64, pub base_cap_floor: f64, pub cap_span: f64, pub padding_ratio: f64, pub min_padding: f64, pub max_padding: f64, pub short_ma_days: usize, pub long_ma_days: usize, pub stock_short_ma_days: usize, pub stock_mid_ma_days: usize, pub stock_long_ma_days: usize, pub rsi_rate: f64, pub trade_rate: f64, pub stop_loss_pct: f64, pub take_profit_pct: f64, pub signal_symbol: Option, pub skip_months: Vec, pub skip_month_day_ranges: Vec<(Option, u32, u32, u32)>, pub risk_config: FidcRiskControlConfig, } impl CnSmallCapRotationConfig { pub fn demo() -> Self { Self { strategy_name: "cn-smallcap-rotation".to_string(), refresh_rate: 3, stocknum: 2, xs: 4.0 / 500.0, base_index_level: 2000.0, base_cap_floor: 7.0, cap_span: 10.0, padding_ratio: 0.5, min_padding: 8.0, max_padding: 20.0, short_ma_days: 3, long_ma_days: 5, stock_short_ma_days: 3, stock_mid_ma_days: 5, stock_long_ma_days: 8, rsi_rate: 1.0001, trade_rate: 0.5, stop_loss_pct: 0.08, take_profit_pct: 0.10, signal_symbol: None, skip_months: Vec::new(), skip_month_day_ranges: Vec::new(), risk_config: FidcRiskControlConfig::default(), } } pub fn cn_dyn_smallcap_band() -> Self { Self { strategy_name: "cn-dyn-smallcap-band".to_string(), refresh_rate: 15, stocknum: 40, xs: 4.0 / 500.0, base_index_level: 2000.0, base_cap_floor: 7.0, cap_span: 10.0, padding_ratio: 0.5, min_padding: 8.0, max_padding: 20.0, short_ma_days: 5, long_ma_days: 10, stock_short_ma_days: 5, stock_mid_ma_days: 10, stock_long_ma_days: 20, rsi_rate: 1.0001, trade_rate: 0.5, stop_loss_pct: 0.07, take_profit_pct: 0.07, signal_symbol: Some("000852.SH".to_string()), skip_months: vec![], skip_month_day_ranges: vec![ (None, 1, 15, 30), (None, 4, 15, 29), (None, 8, 15, 31), (None, 10, 20, 30), (None, 12, 20, 30), ], risk_config: FidcRiskControlConfig::default(), } } fn in_skip_window(&self, date: NaiveDate) -> bool { let year = date.year() as u32; let month = date.month(); let day = date.day(); self.skip_months.contains(&month) || self .skip_month_day_ranges .iter() .any(|(window_year, m, start_day, end_day)| { window_year.map(|value| value == year).unwrap_or(true) && month == *m && day >= *start_day && day <= *end_day }) } } pub struct CnSmallCapRotationStrategy { config: CnSmallCapRotationConfig, selector: DynamicMarketCapBandSelector, last_gross_exposure: Option, } impl CnSmallCapRotationStrategy { pub fn new(config: CnSmallCapRotationConfig) -> Self { Self { selector: DynamicMarketCapBandSelector::new( config.base_index_level, config.base_cap_floor, config.cap_span, config.xs, config.stocknum, config.padding_ratio, config.min_padding, config.max_padding, ), config, last_gross_exposure: None, } } fn moving_average(values: &[f64], lookback: usize) -> f64 { let len = values.len(); let window = values.iter().skip(len.saturating_sub(lookback)); let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| { (sum + value, count + 1) }); if count == 0 { 0.0 } else { sum / count as f64 } } fn gross_exposure(&self, closes: &[f64]) -> f64 { if closes.is_empty() { return 0.0; } let current = *closes.last().unwrap_or(&0.0); let short_ma = Self::moving_average(closes, self.config.short_ma_days); let long_ma = Self::moving_average(closes, self.config.long_ma_days); if short_ma < long_ma * self.config.rsi_rate { self.config.trade_rate } else if current >= long_ma { 1.0 } else { self.config.trade_rate } } fn resolve_signal_series( &self, ctx: &StrategyContext<'_>, ) -> Result<(String, Vec, f64), BacktestError> { if let Some(symbol) = self.config.signal_symbol.as_deref() { let closes = ctx.data .market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days); if closes.len() >= self.config.long_ma_days { let close = ctx .data .price(ctx.decision_date, symbol, PriceField::Close) .ok_or_else(|| BacktestError::MissingPrice { date: ctx.decision_date, symbol: symbol.to_string(), field: "close", })?; return Ok((symbol.to_string(), closes, close)); } } let closes = ctx .data .benchmark_closes_up_to(ctx.decision_date, self.config.long_ma_days); if closes.len() < self.config.long_ma_days { return Err(BacktestError::Execution(format!( "signal series insufficient on/before {} for long_ma_days={}", ctx.decision_date, self.config.long_ma_days ))); } let close = ctx .data .benchmark(ctx.decision_date) .ok_or(BacktestError::MissingBenchmark { date: ctx.decision_date, })? .close; Ok((ctx.data.benchmark_code().to_string(), closes, close)) } fn stock_passes_ma_filter(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool { let closes = ctx.data .market_closes_up_to(ctx.decision_date, symbol, self.config.stock_long_ma_days); if closes.len() < self.config.stock_long_ma_days { return false; } let ma_short = Self::moving_average(&closes, self.config.stock_short_ma_days); let ma_mid = Self::moving_average(&closes, self.config.stock_mid_ma_days); let ma_long = Self::moving_average(&closes, self.config.stock_long_ma_days); ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long } fn stop_exit_symbols( &self, ctx: &StrategyContext<'_>, ) -> Result, BacktestError> { let mut exits = BTreeSet::new(); for position in ctx.portfolio.positions().values() { if position.quantity == 0 { continue; } let close_price = ctx .data .price(ctx.decision_date, &position.symbol, PriceField::Close) .ok_or_else(|| BacktestError::MissingPrice { date: ctx.decision_date, symbol: position.symbol.clone(), field: "close", })?; let Some(holding_return) = position.holding_return(close_price) else { continue; }; if holding_return <= -self.config.stop_loss_pct || holding_return >= self.config.take_profit_pct { exits.insert(position.symbol.clone()); } } Ok(exits) } } impl Strategy for CnSmallCapRotationStrategy { fn name(&self) -> &str { self.config.strategy_name.as_str() } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { let benchmark = ctx.data .benchmark(ctx.decision_date) .ok_or(BacktestError::MissingBenchmark { date: ctx.decision_date, })?; if self.config.in_skip_window(ctx.decision_date) { self.last_gross_exposure = Some(0.0); return Ok(StrategyDecision { rebalance: true, target_weights: BTreeMap::new(), exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), order_intents: Vec::new(), notes: vec![format!("skip-window active on {}", ctx.decision_date)], diagnostics: vec![ "seasonal stop window approximated at daily granularity".to_string(), "run_daily(10:17/10:18) mapped to T-1 decision and T open execution" .to_string(), ], risk_decisions: Vec::new(), }); } let (resolved_signal_symbol, signal_closes, signal_level) = match self.resolve_signal_series(ctx) { Ok(value) => value, Err(BacktestError::Execution(message)) if message.contains("signal series insufficient") => { return Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: Vec::new(), notes: vec![format!("warmup: {}", message)], diagnostics: vec![ "insufficient history; skip trading on warmup dates".to_string(), ], risk_decisions: Vec::new(), }); } Err(err) => return Err(err), }; let gross_exposure = self.gross_exposure(&signal_closes); let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; let exposure_changed = self .last_gross_exposure .map(|previous| (previous - gross_exposure).abs() > f64::EPSILON) .unwrap_or(true); let exit_symbols = self.stop_exit_symbols(ctx)?; let rebalance = periodic_rebalance || exposure_changed; let mut target_weights = BTreeMap::new(); let mut notes = vec![format!( "decision={} exec={} exposure={:.2}", ctx.decision_date, ctx.execution_date, gross_exposure )]; let mut diagnostics = vec![format!( "benchmark_close={:.2} signal_level={:.2} signal_symbol={} refresh_rate={} stocknum={} short_ma_days={} long_ma_days={} stock_ma={}/{}/{} stop={:.4} take={:.4}", benchmark.close, signal_level, resolved_signal_symbol.as_str(), self.config.refresh_rate, self.config.stocknum, self.config.short_ma_days, self.config.long_ma_days, self.config.stock_short_ma_days, self.config.stock_mid_ma_days, self.config.stock_long_ma_days, 1.0 - self.config.stop_loss_pct, 1.0 + self.config.take_profit_pct, )]; diagnostics.push( "run_daily(10:17/10:18) approximated by daily decision/open execution".to_string(), ); diagnostics.push("market_cap field mapped from daily_features[_enriched]_v1.market_cap to market_cap_bn without intraday fundamentals refresh".to_string()); let mut risk_decisions = Vec::new(); if rebalance && gross_exposure > 0.0 { let (selected_before_ma, selection_diag) = self.selector.select_with_diagnostics(&SelectionContext { decision_date: ctx.decision_date, benchmark, reference_level: signal_level, data: ctx.data, dynamic_universe: ctx.dynamic_universe, risk_config: Some(&self.config.risk_config), defer_selection_risk: ctx.is_lagged_execution(), }); let before_ma_count = selected_before_ma.len(); let mut ma_rejects = Vec::new(); let selected = selected_before_ma .into_iter() .filter(|candidate| { let passed = self.stock_passes_ma_filter(ctx, &candidate.symbol); if !passed && ma_rejects.len() < 8 { ma_rejects.push(candidate.symbol.clone()); } passed }) .collect::>(); let after_ma_count = selected.len(); diagnostics.push(format!( "selection_diag factor_total={} candidate_pass={} selected_before_limit={} selected_after_limit={} out_of_band={} not_eligible={} paused={} candidate_missing={} market_missing={} market_cap_missing={}", selection_diag.factor_total, selection_diag.selected_before_limit, selection_diag.selected_before_limit, selection_diag.selected_after_limit, selection_diag.out_of_band_count, selection_diag.not_eligible_count, selection_diag.paused_count, selection_diag.candidate_missing_count, selection_diag.market_missing_count, selection_diag.market_cap_missing_count, )); diagnostics.push(format!( "selection_band reference_level={:.2} cap_band={:.2}-{:.2} selected_after_ma={} filtered_by_ma={}", selection_diag.reference_level, selection_diag.band_low, selection_diag.band_high, after_ma_count, before_ma_count.saturating_sub(after_ma_count), )); if selection_diag.market_cap_missing_count > 0 { diagnostics.push(format!( "market_cap_missing likely blocks selection; sample={}", selection_diag.missing_market_cap_symbols.join("|") )); } if !selection_diag.rejection_examples.is_empty() { diagnostics.push(format!( "selection_rejections sample={}", selection_diag.rejection_examples.join(" | ") )); } if !selection_diag.risk_decisions.is_empty() { risk_decisions.extend(selection_diag.risk_decisions.clone()); let mut counts = BTreeMap::::new(); for decision in &selection_diag.risk_decisions { *counts.entry(decision.rule_code.clone()).or_insert(0) += 1; } diagnostics.push(format!( "risk_decisions selection_total={} by_rule={}", selection_diag.risk_decisions.len(), counts .iter() .map(|(rule, count)| format!("{rule}:{count}")) .collect::>() .join(",") )); diagnostics.extend( selection_diag .risk_decisions .iter() .take(5) .map(|decision| decision.diagnostic_line()), ); } if !ma_rejects.is_empty() { diagnostics.push(format!( "ma_filter_rejections sample={}", ma_rejects.join("|") )); } if !selected.is_empty() { let per_name_weight = gross_exposure / selected.len() as f64; for candidate in &selected { target_weights.insert(candidate.symbol.clone(), per_name_weight); } diagnostics.push(format!( "selected={} cap_band={:.2}-{:.2} sample={}", selected.len(), selected.first().map(|x| x.band_low).unwrap_or_default(), selected.first().map(|x| x.band_high).unwrap_or_default(), selected .iter() .take(5) .map(|x| format!("{}:{:.2}", x.symbol, x.market_cap_bn)) .collect::>() .join("|") )); } else { diagnostics.push("selected=0 no names survived full pipeline".to_string()); notes.push("no selection after filters; see diagnostics".to_string()); } notes.push(format!("rebalance names={}", target_weights.len())); } if !exit_symbols.is_empty() { notes.push(format!("exit hooks={}", exit_symbols.len())); diagnostics.push(format!( "exit_symbols={}", exit_symbols.iter().cloned().collect::>().join("|") )); } if rebalance && gross_exposure == 0.0 { notes.push("risk throttle forced all-cash".to_string()); } self.last_gross_exposure = Some(gross_exposure); Ok(StrategyDecision { rebalance, target_weights, exit_symbols, order_intents: Vec::new(), notes, diagnostics, risk_decisions, }) } } #[derive(Debug, Clone)] pub struct OmniMicroCapConfig { pub strategy_name: String, pub refresh_rate: usize, pub stocknum: usize, pub xs: f64, pub base_index_level: f64, pub base_cap_floor: f64, pub cap_span: f64, pub padding_ratio: f64, pub min_padding: f64, pub max_padding: f64, pub benchmark_signal_symbol: String, pub benchmark_short_ma_days: usize, pub benchmark_long_ma_days: usize, pub stock_short_ma_days: usize, pub stock_mid_ma_days: usize, pub stock_long_ma_days: usize, pub stock_volume_short_ma_days: usize, pub stock_volume_long_ma_days: usize, pub rsi_rate: f64, pub trade_rate: f64, pub stop_loss_ratio: f64, pub take_profit_ratio: f64, pub skip_month_day_ranges: Vec<(Option, u32, u32, u32)>, pub risk_config: FidcRiskControlConfig, } impl OmniMicroCapConfig { pub fn omni_microcap() -> Self { Self { strategy_name: "omni-microcap".to_string(), refresh_rate: 15, stocknum: 40, xs: 4.0 / 500.0, base_index_level: 2000.0, base_cap_floor: 7.0, cap_span: 10.0, padding_ratio: 0.5, min_padding: 8.0, max_padding: 20.0, benchmark_signal_symbol: "000001.SH".to_string(), benchmark_short_ma_days: 5, benchmark_long_ma_days: 10, stock_short_ma_days: 5, stock_mid_ma_days: 10, stock_long_ma_days: 20, stock_volume_short_ma_days: 5, stock_volume_long_ma_days: 60, rsi_rate: 1.0001, trade_rate: 0.5, stop_loss_ratio: 0.93, take_profit_ratio: 1.07, // The migrated reference logic disables seasonal stop windows in // production-style execution, so the default keeps that behavior. skip_month_day_ranges: Vec::new(), risk_config: FidcRiskControlConfig::default(), } } pub fn aiquant_v104() -> Self { Self { strategy_name: "aiquant-v1.0.4".to_string(), refresh_rate: 120, stocknum: 5, xs: 4.0 / 500.0, base_index_level: 2000.0, base_cap_floor: 7.0, cap_span: 10.0, padding_ratio: 1.2, min_padding: 29.5, max_padding: 50.0, benchmark_signal_symbol: "000852.SH".to_string(), benchmark_short_ma_days: 5, benchmark_long_ma_days: 20, stock_short_ma_days: 5, stock_mid_ma_days: 10, stock_long_ma_days: 30, stock_volume_short_ma_days: 5, stock_volume_long_ma_days: 60, rsi_rate: 1.0001, trade_rate: 0.5, stop_loss_ratio: 0.92, take_profit_ratio: 1.16, skip_month_day_ranges: Vec::new(), risk_config: FidcRiskControlConfig::default(), } } fn in_skip_window(&self, date: NaiveDate) -> bool { let year = date.year() as u32; let month = date.month(); let day = date.day(); self.skip_month_day_ranges .iter() .any(|(window_year, m, start_day, end_day)| { window_year.map(|value| value == year).unwrap_or(true) && month == *m && day >= *start_day && day <= *end_day }) } } pub struct OmniMicroCapStrategy { config: OmniMicroCapConfig, } #[derive(Default)] struct ProjectedExecutionState { execution_cursors: BTreeMap, global_execution_cursor: Option, intraday_turnover: BTreeMap, } #[derive(Debug, Clone, Copy)] struct ProjectedExecutionFill { price: f64, quantity: u32, next_cursor: NaiveDateTime, } impl OmniMicroCapStrategy { pub fn new(config: OmniMicroCapConfig) -> Self { Self { config } } fn stop_loss_tolerance(&self, market: &crate::data::DailyMarketSnapshot) -> f64 { let _ = market; 0.0 } fn cost_model(&self) -> ChinaAShareCostModel { ChinaAShareCostModel::from_trading_constraints(self.config.risk_config.trading_constraints) } fn buy_commission(&self, gross_amount: f64) -> f64 { self.cost_model().commission_for(gross_amount) } fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 { let model = self.cost_model(); model.commission_for(gross_amount) + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) } fn round_lot_quantity( &self, quantity: u32, minimum_order_quantity: u32, order_step_size: u32, ) -> u32 { let step = order_step_size.max(1); let normalized = (quantity / step) * step; if normalized < minimum_order_quantity.max(1) { 0 } else { normalized } } fn decrement_order_quantity( &self, quantity: u32, minimum_order_quantity: u32, order_step_size: u32, ) -> u32 { let minimum = minimum_order_quantity.max(1); if quantity <= minimum { 0 } else { let next = quantity.saturating_sub(order_step_size.max(1)); if next < minimum { 0 } else { next } } } fn intraday_execution_start_time(&self) -> NaiveTime { NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18") } fn projected_round_lot(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 { ctx.data .instrument(symbol) .map(|instrument| instrument.effective_round_lot()) .unwrap_or(100) .max(1) } fn projected_minimum_order_quantity(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 { ctx.data .instrument(symbol) .map(|instrument| instrument.minimum_order_quantity()) .unwrap_or(100) .max(1) } fn projected_order_step_size(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 { ctx.data .instrument(symbol) .map(|instrument| instrument.order_step_size()) .unwrap_or(100) .max(1) } #[allow(dead_code)] fn projected_buy_quantity(&self, cash: f64, sizing_price: f64, execution_price: f64) -> u32 { if cash <= 0.0 || sizing_price <= 0.0 || execution_price <= 0.0 { return 0; } let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100, 100); while quantity > 0 { let gross_amount = execution_price * quantity as f64; if gross_amount + self.buy_commission(gross_amount) <= cash + 1e-6 { return quantity; } quantity = self.decrement_order_quantity(quantity, 100, 100); } 0 } fn projected_execution_price( &self, market: &crate::data::DailyMarketSnapshot, side: OrderSide, ) -> f64 { let _ = side; market.price(PriceField::Last) } fn project_order_value( &self, ctx: &StrategyContext<'_>, projected: &mut PortfolioState, date: NaiveDate, symbol: &str, order_value: f64, reason: &str, execution_state: &mut ProjectedExecutionState, ) -> u32 { if order_value <= 0.0 { return 0; } let round_lot = self.projected_round_lot(ctx, symbol); let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol); let market = match ctx.data.market(date, symbol) { Some(market) => market, None => return 0, }; let sizing_price = market.price(PriceField::Last); if !sizing_price.is_finite() || sizing_price <= 0.0 { return 0; } let mut snapshot_requested_qty = self.round_lot_quantity( ((projected.cash().min(order_value)) / sizing_price).floor() as u32, minimum_order_quantity, order_step_size, ); while snapshot_requested_qty > 0 { let gross_amount = sizing_price * snapshot_requested_qty as f64; let cash_out = gross_amount + self.buy_commission(gross_amount); if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { break; } snapshot_requested_qty = self.decrement_order_quantity( snapshot_requested_qty, minimum_order_quantity, order_step_size, ); } let projected_execution_price = self.projected_execution_price(market, OrderSide::Buy); let projected_fill = self.projected_select_execution_fill( ctx, date, symbol, OrderSide::Buy, u32::MAX, round_lot, minimum_order_quantity, order_step_size, false, Some(projected.cash().min(order_value)), Some(order_value), execution_state, ); let mut quantity = snapshot_requested_qty; while quantity > 0 { let gross_amount = projected_execution_price * quantity as f64; let cash_out = gross_amount + self.buy_commission(gross_amount); if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { break; } quantity = self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); } if quantity == 0 { return 0; } let execution_price = projected_fill .as_ref() .map(|fill| fill.price) .unwrap_or(projected_execution_price); while quantity > 0 { let gross_amount = execution_price * quantity as f64; let cash_out = gross_amount + self.buy_commission(gross_amount); if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { break; } quantity = self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); } if quantity == 0 { return 0; } let fill = ProjectedExecutionFill { price: execution_price, quantity, next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1), }; let gross_amount = fill.price * fill.quantity as f64; let cash_out = gross_amount + self.buy_commission(gross_amount); if cash_out > projected.cash() + 1e-6 || cash_out > order_value + 1e-6 { return 0; } projected.apply_cash_delta(-cash_out); projected .position_mut(symbol) .buy(date, fill.quantity, fill.price); *execution_state .intraday_turnover .entry(symbol.to_string()) .or_default() += fill.quantity; execution_state .execution_cursors .insert(symbol.to_string(), fill.next_cursor); if self.uses_serial_execution_cursor(reason) { execution_state.global_execution_cursor = Some(fill.next_cursor); } fill.quantity } fn project_target_zero( &self, ctx: &StrategyContext<'_>, projected: &mut PortfolioState, date: NaiveDate, symbol: &str, reason: &str, execution_state: &mut ProjectedExecutionState, ) -> Option { let quantity = projected.position(symbol)?.quantity; if quantity == 0 { return None; } let market = ctx.data.market(date, symbol)?; let round_lot = self.projected_round_lot(ctx, symbol); let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol); let fill = self .projected_select_execution_fill( ctx, date, symbol, OrderSide::Sell, quantity, round_lot, minimum_order_quantity, order_step_size, true, None, None, execution_state, ) .unwrap_or(ProjectedExecutionFill { price: self.projected_execution_price(market, OrderSide::Sell), quantity, next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1), }); let gross_amount = fill.price * fill.quantity as f64; let net_cash = gross_amount - self.sell_cost(date, gross_amount); projected .position_mut(symbol) .sell(fill.quantity, fill.price) .ok()?; projected.apply_cash_delta(net_cash); *execution_state .intraday_turnover .entry(symbol.to_string()) .or_default() += fill.quantity; execution_state .execution_cursors .insert(symbol.to_string(), fill.next_cursor); if self.uses_serial_execution_cursor(reason) { execution_state.global_execution_cursor = Some(fill.next_cursor); } projected.prune_flat_positions(); Some(fill.quantity) } fn projected_market_fillable_quantity( &self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str, side: OrderSide, requested_qty: u32, _round_lot: u32, minimum_order_quantity: u32, order_step_size: u32, allow_odd_lot_sell: bool, current_fill_quantity: u32, execution_state: &ProjectedExecutionState, ) -> Option { 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 { return None; } let mut max_fill = requested_qty; if constraints.liquidity_limit_enabled { let top_level_liquidity = match side { OrderSide::Buy => snapshot.liquidity_for_buy(), OrderSide::Sell => snapshot.liquidity_for_sell(), } .min(u32::MAX as u64) as u32; if top_level_liquidity == 0 { return None; } let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell { top_level_liquidity } else { self.round_lot_quantity( top_level_liquidity, minimum_order_quantity, order_step_size, ) }; 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); if constraints.volume_limit_enabled { let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).floor() as i64 - consumed_turnover as i64; if raw_limit <= 0 { return None; } let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { raw_limit as u32 } else { self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size) }; if volume_limited == 0 { return None; } max_fill = max_fill.min(volume_limited); } Some(max_fill) } fn projected_execution_start_cursor( &self, date: NaiveDate, symbol: &str, execution_state: &ProjectedExecutionState, ) -> Option { let _ = (symbol, execution_state); Some(date.and_time(self.intraday_execution_start_time())) } fn projected_select_execution_fill( &self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str, side: OrderSide, requested_qty: u32, round_lot: u32, minimum_order_quantity: u32, order_step_size: u32, allow_odd_lot_sell: bool, cash_limit: Option, gross_limit: Option, execution_state: &ProjectedExecutionState, ) -> Option { if requested_qty == 0 { 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 { let quantity = match side { OrderSide::Buy => { let cash = cash_limit.unwrap_or(f64::INFINITY); let mut take_qty = self.round_lot_quantity( requested_qty, minimum_order_quantity, order_step_size, ); while take_qty > 0 { let candidate_gross = execution_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { take_qty = self.decrement_order_quantity( take_qty, minimum_order_quantity, order_step_size, ); continue; } let candidate_cash = candidate_gross + self.buy_commission(candidate_gross); if candidate_cash <= cash + 1e-6 { break; } take_qty = self.decrement_order_quantity( take_qty, minimum_order_quantity, order_step_size, ); } take_qty } OrderSide::Sell => requested_qty, }; if quantity > 0 { let next_cursor = date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1); return Some(ProjectedExecutionFill { price: execution_price, quantity, next_cursor, }); } } } let start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state); let quotes = ctx.data.execution_quotes_on(date, symbol); let mut filled_qty = 0_u32; let mut gross_amount = 0.0_f64; let mut last_timestamp = None; for quote in quotes { if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) { continue; } let fallback_quote_price = match side { OrderSide::Buy => { if quote.last_price.is_finite() && quote.last_price > 0.0 { Some(quote.last_price) } else { quote.buy_price() } } OrderSide::Sell => quote.sell_price(), }; if fallback_quote_price.is_some() { last_timestamp = Some(quote.timestamp); } if quote.volume_delta == 0 { continue; } let Some(quote_price) = fallback_quote_price else { continue; }; let available_qty = match side { OrderSide::Buy => quote.ask1_volume, OrderSide::Sell => quote.bid1_volume, } .saturating_mul(round_lot.max(1) as u64) .min(u32::MAX as u64) as u32; if available_qty == 0 { continue; } let remaining_qty = requested_qty.saturating_sub(filled_qty); 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); 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); } if take_qty == 0 { continue; } if let Some(cash) = cash_limit { while take_qty > 0 { let candidate_gross = gross_amount + quote_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { take_qty = self.decrement_order_quantity( take_qty, minimum_order_quantity, order_step_size, ); continue; } if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 { break; } take_qty = self.decrement_order_quantity( take_qty, minimum_order_quantity, order_step_size, ); } if take_qty == 0 { break; } } gross_amount += quote_price * take_qty as f64; filled_qty += take_qty; last_timestamp = Some(quote.timestamp); if filled_qty >= requested_qty { break; } } if filled_qty == 0 { return None; } Some(ProjectedExecutionFill { price: gross_amount / filled_qty as f64, quantity: filled_qty, next_cursor: last_timestamp.unwrap() + Duration::seconds(1), }) } fn uses_serial_execution_cursor(&self, reason: &str) -> bool { let _ = reason; false } fn trading_ratio( &self, ctx: &StrategyContext<'_>, date: NaiveDate, ) -> Result<(f64, f64, f64, f64, f64), BacktestError> { // 当前交易日的指数价格(用于MA计算和仓位控制) let current_level = ctx .data .market_decision_close(date, &self.config.benchmark_signal_symbol) .ok_or_else(|| BacktestError::MissingPrice { date, symbol: self.config.benchmark_signal_symbol.clone(), field: "decision_close", })?; // 前一交易日的指数价格(用于市值区间计算,模拟实盘场景) let prev_level = if let Some(prev_date) = ctx.data.previous_trading_date(date, 1) { ctx.data .market_decision_close(prev_date, &self.config.benchmark_signal_symbol) .unwrap_or(current_level) } else { current_level }; let ma_short = ctx .data .market_decision_close_moving_average( date, &self.config.benchmark_signal_symbol, self.config.benchmark_short_ma_days, ) .ok_or_else(|| { BacktestError::Execution(format!( "insufficient benchmark short MA history for {} on {}", self.config.benchmark_signal_symbol, date )) })?; let ma_long = ctx .data .market_decision_close_moving_average( date, &self.config.benchmark_signal_symbol, self.config.benchmark_long_ma_days, ) .ok_or_else(|| { BacktestError::Execution(format!( "insufficient benchmark long MA history for {} on {}", self.config.benchmark_signal_symbol, date )) })?; let trading_ratio = if ma_short < ma_long * self.config.rsi_rate { self.config.trade_rate } else { 1.0 }; Ok((current_level, prev_level, ma_short, ma_long, trading_ratio)) } fn market_cap_band(&self, index_level: f64) -> (f64, f64) { let y = (index_level - self.config.base_index_level) * self.config.xs + self.config.base_cap_floor; let start = y.round(); let end = start + self.config.cap_span; // Apply padding to expand the range let span = end - start; let padding = (span * self.config.padding_ratio) .max(self.config.min_padding) .min(self.config.max_padding); let lower_bound = (start - padding).max(0.0); let upper_bound = end + padding; (lower_bound, upper_bound) } fn stock_passes_ma_filter( &self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str, ) -> bool { let Some(ma_short) = ctx.data.market_decision_close_moving_average( date, symbol, self.config.stock_short_ma_days, ) else { return false; }; let Some(ma_mid) = ctx.data.market_decision_close_moving_average( date, symbol, self.config.stock_mid_ma_days, ) else { return false; }; let Some(ma_long) = ctx.data.market_decision_close_moving_average( date, symbol, self.config.stock_long_ma_days, ) else { return false; }; let ma_pass = ma_short > ma_mid * self.config.rsi_rate && ma_mid * self.config.rsi_rate > ma_long; if !ma_pass { return false; } if self.config.strategy_name.contains("aiquant") || self.config.strategy_name.contains("AiQuant") || self.config.strategy_name.contains("omni") { let Some(volume_ma5) = ctx.data.market_decision_volume_moving_average( date, symbol, self.config.stock_volume_short_ma_days, ) else { return false; }; let Some(volume_ma_long) = ctx.data.market_decision_volume_moving_average( date, symbol, self.config.stock_volume_long_ma_days, ) else { return false; }; if volume_ma5 >= volume_ma_long { return false; } } true } fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool { let Some(position) = ctx.portfolio.position(symbol) else { return false; }; if position.quantity == 0 || position.sellable_qty(date) == 0 { return false; } let Ok(market) = ctx.data.require_market(date, symbol) else { return false; }; let Ok(candidate) = ctx.data.require_candidate(date, symbol) else { return false; }; ChinaAShareRiskControl::sell_rejection_reason_with_config( date, candidate, market, ctx.data.instrument(symbol), Some(position), ChinaAShareRiskControl::sell_check_price(market, PriceField::Last), &self.config.risk_config, ) .is_none() } fn buy_rejection_reason( &self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str, ) -> Result, BacktestError> { let market = ctx.data.require_market(date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?; if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, candidate, market, ctx.data.instrument(symbol), ChinaAShareRiskControl::buy_check_price(market, PriceField::Last), &self.config.risk_config, ) { return Ok(Some(reason.to_string())); } if !self.stock_passes_ma_filter(ctx, date, symbol) { return Ok(Some("ma_filter".to_string())); } Ok(None) } fn selection_risk_decisions( &self, ctx: &StrategyContext<'_>, date: NaiveDate, ) -> Vec { 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) { continue; } let Some(candidate) = ctx.data.candidate(date, &factor.symbol) else { continue; }; let Some(market) = ctx.data.market(date, &factor.symbol) else { continue; }; if let Some(decision) = ChinaAShareRiskControl::selection_rejection_decision_with_config( date, candidate, market, ctx.data.instrument(&factor.symbol), &self.config.risk_config, ) { decisions.push(decision); } } decisions } fn selection_risk_decision_diagnostics( decisions: &[FidcRiskDecisionAudit], sample_limit: usize, ) -> Vec { if decisions.is_empty() { return Vec::new(); } let mut counts = BTreeMap::::new(); for decision in decisions { *counts.entry(decision.rule_code.clone()).or_insert(0) += 1; } let mut diagnostics = vec![format!( "risk_decisions selection_total={} by_rule={}", decisions.len(), counts .iter() .map(|(rule, count)| format!("{rule}:{count}")) .collect::>() .join(",") )]; diagnostics.extend( decisions .iter() .take(sample_limit) .map(|decision| decision.diagnostic_line()), ); diagnostics } fn select_symbols( &self, ctx: &StrategyContext<'_>, date: NaiveDate, band_low: f64, band_high: f64, ) -> Result<(Vec, Vec), BacktestError> { let universe = ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config); let mut diagnostics = Vec::new(); let mut selected = Vec::new(); let start = lower_bound_eligible(&universe, band_low); for candidate in universe.iter().skip(start) { 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()); if let Some(reason) = rejection { if diagnostics.len() < 12 { diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason)); } continue; } selected.push(candidate.symbol.clone()); if selected.len() >= self.config.stocknum { break; } } Ok((selected, diagnostics)) } } impl Strategy for OmniMicroCapStrategy { fn name(&self) -> &str { self.config.strategy_name.as_str() } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { let signal_date = ctx.decision_date; let execution_date = ctx.execution_date; let lagged_execution = ctx.is_lagged_execution(); if self.config.in_skip_window(signal_date) { return Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), order_intents: ctx .portfolio .positions() .keys() .cloned() .map(|symbol| OrderIntent::TimedTargetValue { symbol, target_value: 0.0, style: AlgoOrderStyle::Twap, start_time: Some(self.intraday_execution_start_time()), end_time: Some(self.intraday_execution_start_time()), reason: "seasonal_stop_window".to_string(), }) .collect(), notes: vec![format!("seasonal stop window on {}", signal_date)], diagnostics: vec!["platform-native skip window forced all cash".to_string()], risk_decisions: Vec::new(), }); } let (index_level, prev_index_level, ma_short, ma_long, trading_ratio) = match self.trading_ratio(ctx, signal_date) { Ok(value) => value, Err(BacktestError::Execution(message)) if message.contains("insufficient benchmark") => { return Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: Vec::new(), notes: vec![format!("warmup: {}", message)], diagnostics: vec![ "insufficient history; skip trading on warmup dates".to_string(), ], risk_decisions: Vec::new(), }); } Err(err) => return Err(err), }; // 使用前一交易日的指数价格计算市值区间(模拟实盘场景) 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); let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; let projection_date = signal_date; let mut projected = ctx.portfolio.clone(); let mut projected_execution_state = ProjectedExecutionState::default(); let mut order_intents = Vec::new(); let mut exit_symbols = BTreeSet::new(); for position in ctx.portfolio.positions().values() { if position.quantity == 0 || position.average_cost <= 0.0 { continue; } let Some(current_price) = ctx.data .price(signal_date, &position.symbol, PriceField::Last) else { continue; }; let Some(market) = ctx.data.market(signal_date, &position.symbol) else { continue; }; let stop_hit = current_price <= 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 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 { "stop_loss_exit" } else { "take_profit_exit" }; exit_symbols.insert(position.symbol.clone()); order_intents.push(OrderIntent::TargetValue { symbol: position.symbol.clone(), target_value: 0.0, reason: sell_reason.to_string(), }); if can_sell { self.project_target_zero( ctx, &mut projected, projection_date, &position.symbol, sell_reason, &mut projected_execution_state, ); } if projected.positions().len() < self.config.stocknum { let remaining_slots = self.config.stocknum - projected.positions().len(); if remaining_slots > 0 { let replacement_cash = projected.cash() * trading_ratio / remaining_slots as f64; for symbol in &stock_list { if symbol == &position.symbol || projected.positions().contains_key(symbol) { continue; } if !lagged_execution && self .buy_rejection_reason(ctx, execution_date, symbol)? .is_some() { continue; } order_intents.push(OrderIntent::Value { symbol: symbol.clone(), value: replacement_cash, reason: format!("replacement_after_{}", sell_reason), }); self.project_order_value( ctx, &mut projected, projection_date, symbol, replacement_cash, &format!("replacement_after_{}", sell_reason), &mut projected_execution_state, ); break; } } } } } if periodic_rebalance { let pre_rebalance_symbols = projected .positions() .keys() .cloned() .collect::>(); for symbol in pre_rebalance_symbols.iter() { if stock_list.iter().any(|candidate| candidate == symbol) { continue; } if !lagged_execution && !self.can_sell_position(ctx, execution_date, symbol) { continue; } order_intents.push(OrderIntent::TargetValue { symbol: symbol.clone(), target_value: 0.0, reason: "periodic_rebalance_sell".to_string(), }); self.project_target_zero( ctx, &mut projected, projection_date, symbol, "periodic_rebalance_sell", &mut projected_execution_state, ); } let fixed_buy_cash = projected.cash() * trading_ratio / self.config.stocknum as f64; for symbol in &stock_list { if projected.positions().len() >= self.config.stocknum { break; } if pre_rebalance_symbols.contains(symbol) || projected.positions().contains_key(symbol) { continue; } if !lagged_execution && self .buy_rejection_reason(ctx, execution_date, symbol)? .is_some() { continue; } order_intents.push(OrderIntent::Value { symbol: symbol.clone(), value: fixed_buy_cash, reason: "periodic_rebalance_buy".to_string(), }); self.project_order_value( ctx, &mut projected, projection_date, symbol, fixed_buy_cash, "periodic_rebalance_buy", &mut projected_execution_state, ); } } let mut diagnostics = vec![ format!( "omni_microcap signal={} last={:.2} ma_short={:.2} ma_long={:.2} band={:.0}-{:.0} tr={:.2}", self.config.benchmark_signal_symbol, index_level, ma_short, ma_long, band_low, band_high, trading_ratio ), format!( "selected={} periodic_rebalance={} exits={} projected_positions={} intents={}", stock_list.len(), periodic_rebalance, exit_symbols.len(), projected.positions().len(), order_intents.len() ), "platform schedule signal uses decision_date data; broker applies execution_date price and risk checks".to_string(), ]; if std::env::var("FIDC_BT_DEBUG_POSITION_ORDER") .map(|value| value == "1") .unwrap_or(false) { diagnostics.push(format!( "positions_order={}", ctx.portfolio .positions() .keys() .cloned() .collect::>() .join("|") )); } diagnostics.extend(selection_notes); diagnostics.extend(Self::selection_risk_decision_diagnostics( &risk_decisions, 5, )); let notes = vec![ format!("stock_list={}", stock_list.len()), format!("projected_positions={}", projected.positions().len()), ]; Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols, order_intents, notes, diagnostics, risk_decisions, }) } } fn lower_bound_eligible(rows: &[crate::data::EligibleUniverseSnapshot], target: f64) -> usize { let mut left = 0usize; let mut right = rows.len(); while left < right { let mid = left + (right - left) / 2; if rows[mid].market_cap_bn < target { left = mid + 1; } else { right = mid; } } left } #[cfg(test)] mod tests { use super::*; use crate::{BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot}; #[test] fn omni_microcap_projection_uses_configured_trading_cost() { let mut cfg = OmniMicroCapConfig::omni_microcap(); cfg.risk_config.trading_constraints.commission_rate = 0.0003; cfg.risk_config.trading_constraints.minimum_commission = 5.0; cfg.risk_config .trading_constraints .stamp_tax_rate_after_change = 0.0005; let strategy = OmniMicroCapStrategy::new(cfg); assert!((strategy.buy_commission(100_000.0) - 30.0).abs() < 1e-9); assert!((strategy.buy_commission(1_000.0) - 5.0).abs() < 1e-9); assert!( (strategy.sell_cost(NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), 100_000.0) - 80.0) .abs() < 1e-9 ); } #[test] fn omni_microcap_selection_uses_configured_risk_policy() { let dates = [ NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(), NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(), ]; let symbol = "688001.SH"; let market_rows = dates .iter() .enumerate() .map(|(index, date)| DailyMarketSnapshot { date: *date, symbol: symbol.to_string(), timestamp: Some(format!("{date} 10:18:00")), day_open: 10.0 + index as f64, open: 10.0 + index as f64, high: 10.4 + index as f64, low: 9.8 + index as f64, close: 10.2 + index as f64, last_price: 10.2 + index as f64, bid1: 10.1 + index as f64, ask1: 10.2 + index as f64, prev_close: 10.0 + index as f64, volume: 1_000_000 + index as u64 * 100_000, minute_volume: 10_000, bid1_volume: 10_000, ask1_volume: 10_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 20.0, lower_limit: 5.0, price_tick: 0.01, }) .collect::>(); let factor_rows = dates .iter() .map(|date| DailyFactorSnapshot { date: *date, symbol: symbol.to_string(), market_cap_bn: 10.0, free_float_cap_bn: 9.0, pe_ttm: 12.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }) .collect::>(); let candidate_rows = dates .iter() .map(|date| CandidateEligibility { date: *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: true, is_one_yuan: false, risk_level_code: None, }) .collect::>(); let benchmark_rows = dates .iter() .map(|date| BenchmarkSnapshot { date: *date, benchmark: "000852.SH".to_string(), open: 100.0, close: 101.0, prev_close: 99.0, volume: 1_000_000, }) .collect::>(); let data = DataSet::from_components( vec![Instrument { symbol: symbol.to_string(), name: symbol.to_string(), board: "SH".to_string(), round_lot: 100, listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), delisted_at: None, status: "active".to_string(), }], market_rows, factor_rows, candidate_rows, benchmark_rows, ) .expect("dataset"); let portfolio = PortfolioState::new(1_000_000.0); let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date: dates[2], decision_date: dates[2], decision_index: 0, 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 default_cfg = OmniMicroCapConfig::omni_microcap(); default_cfg.strategy_name = "configured_risk_policy_test".to_string(); default_cfg.stock_short_ma_days = 1; default_cfg.stock_mid_ma_days = 2; 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) .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!( strict_risk_decisions[0] .diagnostic_line() .starts_with("risk_decision=") ); } }