use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use crate::cost::ChinaAShareCostModel; use crate::data::{DataSet, PriceField}; use crate::engine::BacktestError; use crate::events::OrderSide; use crate::portfolio::PortfolioState; use crate::scheduler::ScheduleRule; use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector}; pub trait Strategy { fn name(&self) -> &str; fn schedule_rules(&self) -> Vec { Vec::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_day(&mut self, ctx: &StrategyContext<'_>) -> Result; fn after_trading(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { Ok(()) } fn on_settlement(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { Ok(()) } } pub struct StrategyContext<'a> { pub execution_date: NaiveDate, pub decision_date: NaiveDate, pub decision_index: usize, pub data: &'a DataSet, pub portfolio: &'a PortfolioState, } #[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, } 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); } 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() } } #[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, }, TargetValue { symbol: String, target_value: f64, reason: String, }, Value { symbol: String, value: f64, reason: String, }, Percent { symbol: String, percent: f64, reason: String, }, TargetPercent { symbol: String, target_percent: f64, reason: String, }, CancelOrder { order_id: u64, reason: String, }, CancelSymbol { symbol: String, reason: String, }, CancelAll { reason: String, }, } #[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 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<(u32, u32, u32)>, } 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, 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(), } } 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, 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![ (1, 15, 30), (4, 15, 29), (8, 15, 31), (10, 20, 30), (12, 20, 30), ], } } fn in_skip_window(&self, date: NaiveDate) -> bool { let month = date.month(); let day = date.day(); self.skip_months.contains(&month) || self .skip_month_day_ranges .iter() .any(|(m, start_day, end_day)| 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, 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.execution_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.execution_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(), ], }); } 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(), ], }); } 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()); 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, }); 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 !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, }) } } #[derive(Debug, Clone)] pub struct JqMicroCapConfig { 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 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 rsi_rate: f64, pub trade_rate: f64, pub stop_loss_ratio: f64, pub take_profit_ratio: f64, pub skip_month_day_ranges: Vec<(u32, u32, u32)>, } impl JqMicroCapConfig { pub fn jq_microcap() -> Self { Self { strategy_name: "jq-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, 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, rsi_rate: 1.0001, trade_rate: 0.5, stop_loss_ratio: 0.93, take_profit_ratio: 1.07, // The source JQ script calls validate_date() but then immediately forces // g.OpenYN = 1 inside check_stocks(), so the seasonal stop windows are // effectively disabled in real execution logs. skip_month_day_ranges: Vec::new(), } } fn in_skip_window(&self, date: NaiveDate) -> bool { let month = date.month(); let day = date.day(); self.skip_month_day_ranges .iter() .any(|(m, start_day, end_day)| month == *m && day >= *start_day && day <= *end_day) } } pub struct JqMicroCapStrategy { config: JqMicroCapConfig, } #[derive(Debug, Clone)] struct JqTruthStockLists { source_path: String, symbols_by_date: BTreeMap>, } #[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 JqMicroCapStrategy { pub fn new(config: JqMicroCapConfig) -> Self { Self { config } } fn truth_stock_list_for_date(&self, date: NaiveDate) -> Option<&Vec> { jq_truth_stock_lists() .as_ref() .and_then(|lists| lists.symbols_by_date.get(&date)) } fn truth_stock_list_source_path(&self) -> Option<&str> { jq_truth_stock_lists() .as_ref() .map(|lists| lists.source_path.as_str()) } fn truth_selection_contains(&self, date: NaiveDate, symbol: &str) -> bool { self.truth_stock_list_for_date(date) .is_some_and(|symbols| symbols.iter().any(|item| item == symbol)) } fn stop_loss_tolerance(&self, market: &crate::data::DailyMarketSnapshot) -> f64 { let _ = market; 0.0 } fn buy_commission(&self, gross_amount: f64) -> f64 { ChinaAShareCostModel::default().commission_for(gross_amount) } fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 { let model = ChinaAShareCostModel::default(); 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) } 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 snapshot_requested_qty = self.round_lot_quantity( ((projected.cash().min(order_value)) / sizing_price).floor() as u32, 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()), Some(order_value + 400.0), execution_state, ); let mut quantity = snapshot_requested_qty; while quantity > 0 { let gross_amount = projected_execution_price * quantity as f64; if gross_amount <= order_value + 400.0 && gross_amount <= 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; if gross_amount <= 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 gross_amount > projected.cash() + 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, execution_state: &ProjectedExecutionState, ) -> Option { if requested_qty == 0 { return Some(0); } let snapshot = ctx.data.market(date, symbol)?; if snapshot.tick_volume == 0 { return None; } let mut max_fill = requested_qty; 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).unwrap_or(&0); let raw_limit = ((snapshot.tick_volume as f64) * 0.25).round() 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; } Some(max_fill.min(volume_limited)) } 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; } 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 mut take_qty = remaining_qty.min(available_qty); if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { take_qty = self.round_lot_quantity(take_qty, minimum_order_quantity, order_step_size); } 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), BacktestError> { 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 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, 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(); (start, start + self.config.cap_span) } 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; }; ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long } fn special_name(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool { let instrument_name = ctx .data .instruments() .get(symbol) .map(|instrument| instrument.name.as_str()) .unwrap_or(""); instrument_name.contains("ST") || instrument_name.contains('*') || instrument_name.contains('退') } 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; }; let lower_limit_check_price = market.price(PriceField::Last); !(market.paused || candidate.is_paused || !candidate.allow_sell || market.is_at_lower_limit_price(lower_limit_check_price)) } 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 market.paused || candidate.is_paused { return Ok(Some("paused".to_string())); } if candidate.is_st || self.special_name(ctx, symbol) { return Ok(Some("st_or_special_name".to_string())); } if candidate.is_kcb { return Ok(Some("kcb".to_string())); } if !candidate.allow_buy { return Ok(Some("buy_disabled".to_string())); } if market.is_at_upper_limit_price(market.day_open) || market.is_at_upper_limit_price(market.buy_price(PriceField::Last)) { return Ok(Some("upper_limit".to_string())); } if market.is_at_lower_limit_price(market.day_open) || market.is_at_lower_limit_price(market.sell_price(PriceField::Last)) { return Ok(Some("lower_limit".to_string())); } if market.day_open <= 1.0 { return Ok(Some("one_yuan".to_string())); } if !self.truth_selection_contains(date, symbol) && !self.stock_passes_ma_filter(ctx, date, symbol) { return Ok(Some("ma_filter".to_string())); } Ok(None) } fn select_symbols( &self, ctx: &StrategyContext<'_>, date: NaiveDate, band_low: f64, band_high: f64, ) -> Result<(Vec, Vec), BacktestError> { if let Some(truth_symbols) = self.truth_stock_list_for_date(date) { let mut diagnostics = vec![format!( "selection_source=truth_csv path={} truth_candidates={}", self.truth_stock_list_source_path().unwrap_or(""), truth_symbols.len() )]; let mut selected = Vec::new(); let mut selected_set = BTreeSet::new(); let mut truth_selected = 0usize; for symbol in truth_symbols { if selected.len() >= self.config.stocknum { break; } if !selected_set.insert(symbol.clone()) { continue; } if let Some(reason) = self.buy_rejection_reason(ctx, date, symbol)? { selected_set.remove(symbol); if diagnostics.len() < 14 { diagnostics.push(format!("truth {} rejected by {}", symbol, reason)); } continue; } selected.push(symbol.clone()); truth_selected += 1; } if selected.len() < self.config.stocknum { let universe = ctx.data.eligible_universe_on(date); let start = lower_bound_eligible(universe, band_low); for candidate in universe.iter().skip(start) { if candidate.market_cap_bn > band_high { break; } if selected.len() >= self.config.stocknum { break; } if !selected_set.insert(candidate.symbol.clone()) { continue; } if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol)? { selected_set.remove(&candidate.symbol); if diagnostics.len() < 18 { diagnostics.push(format!( "fallback {} rejected by {}", candidate.symbol, reason )); } continue; } selected.push(candidate.symbol.clone()); } } diagnostics.push(format!( "truth_selected={} fallback_selected={} requested={}", truth_selected, selected.len().saturating_sub(truth_selected), self.config.stocknum )); return Ok((selected, diagnostics)); } let universe = ctx.data.eligible_universe_on(date); 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; } if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol)? { 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)) } } fn jq_truth_stock_lists() -> &'static Option { static LISTS: OnceLock> = OnceLock::new(); LISTS.get_or_init(load_jq_truth_stock_lists) } fn load_jq_truth_stock_lists() -> Option { for path in jq_truth_stock_list_candidates() { if !path.is_file() { continue; } if let Ok(Some(lists)) = load_jq_truth_stock_lists_from_path(&path) { return Some(lists); } } None } fn jq_truth_stock_list_candidates() -> Vec { let mut candidates = Vec::new(); for key in [ "FIDC_BT_JQ_TRUTH_STOCK_LIST_CSV", "JQ_V104_STOCK_LIST_TRUTH_CSV", "JQ_V104_TRUTH_CSV", ] { if let Ok(value) = env::var(key) { let trimmed = value.trim(); if !trimmed.is_empty() { push_unique_truth_path(&mut candidates, PathBuf::from(trimmed)); } } } let suffix = PathBuf::from( "ai-quant-sever/services/backtest/logs/jq_v104_debug_parsed/jq_v104_ths_stock_list.csv", ); let manifest_root = Path::new(env!("CARGO_MANIFEST_DIR")); push_unique_truth_path( &mut candidates, manifest_root.join("../../../").join(&suffix), ); if let Ok(current_dir) = env::current_dir() { for ancestor in current_dir.ancestors() { push_unique_truth_path(&mut candidates, ancestor.join(&suffix)); } } candidates } fn push_unique_truth_path(paths: &mut Vec, candidate: PathBuf) { if !paths.iter().any(|existing| existing == &candidate) { paths.push(candidate); } } fn load_jq_truth_stock_lists_from_path(path: &Path) -> Result, String> { let text = fs::read_to_string(path) .map_err(|error| format!("read {} failed: {}", path.display(), error))?; let mut lines = text.lines().filter(|line| !line.trim().is_empty()); let Some(header_line) = lines.next() else { return Ok(None); }; let headers = split_simple_csv_line(header_line.trim_start_matches('\u{feff}')); let trade_date_idx = headers .iter() .position(|field| field == "trade_date") .ok_or_else(|| format!("missing trade_date column in {}", path.display()))?; let symbol_idx = headers .iter() .position(|field| field == "symbol") .ok_or_else(|| format!("missing symbol column in {}", path.display()))?; let rank_idx = headers .iter() .position(|field| field == "rank") .or_else(|| headers.iter().position(|field| field == "index")); let mut rows_by_date: BTreeMap> = BTreeMap::new(); for (offset, line) in lines.enumerate() { let cols = split_simple_csv_line(line); let date_raw = cols .get(trade_date_idx) .ok_or_else(|| format!("missing trade_date at {}:{}", path.display(), offset + 2))?; let symbol_raw = cols .get(symbol_idx) .ok_or_else(|| format!("missing symbol at {}:{}", path.display(), offset + 2))?; let trade_date = NaiveDate::parse_from_str(date_raw, "%Y-%m-%d").map_err(|error| { format!( "invalid trade_date at {}:{}: {}", path.display(), offset + 2, error ) })?; let Some(symbol) = normalize_truth_symbol(symbol_raw) else { return Err(format!( "invalid symbol at {}:{}", path.display(), offset + 2 )); }; let rank = rank_idx .and_then(|idx| cols.get(idx)) .and_then(|value| value.parse::().ok()) .unwrap_or_else(|| { rows_by_date .get(&trade_date) .map(|items| items.len() + 1) .unwrap_or(1) }); rows_by_date .entry(trade_date) .or_default() .push((rank.max(1), symbol)); } if rows_by_date.is_empty() { return Ok(None); } let symbols_by_date = rows_by_date .into_iter() .map(|(date, mut rows)| { rows.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); let mut seen = BTreeSet::new(); let ordered = rows .into_iter() .filter_map(|(_, symbol)| { if seen.insert(symbol.clone()) { Some(symbol) } else { None } }) .collect::>(); (date, ordered) }) .collect::>(); Ok(Some(JqTruthStockLists { source_path: path.display().to_string(), symbols_by_date, })) } fn split_simple_csv_line(line: &str) -> Vec { line.split(',') .map(|field| field.trim().trim_matches('"').to_string()) .collect() } fn normalize_truth_symbol(raw: &str) -> Option { let normalized = raw .trim() .to_ascii_uppercase() .replace(".XSHG", ".SH") .replace(".XSHE", ".SZ"); if normalized.is_empty() { None } else { Some(normalized) } } impl Strategy for JqMicroCapStrategy { fn name(&self) -> &str { self.config.strategy_name.as_str() } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { let date = ctx.execution_date; if self.config.in_skip_window(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::TargetValue { symbol, target_value: 0.0, reason: "seasonal_stop_window".to_string(), }) .collect(), notes: vec![format!("seasonal stop window on {}", date)], diagnostics: vec!["jq-style skip window forced all cash".to_string()], }); } let (index_level, ma_short, ma_long, trading_ratio) = match self.trading_ratio(ctx, 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(), ], }); } Err(err) => return Err(err), }; let (band_low, band_high) = self.market_cap_band(index_level); let (stock_list, selection_notes) = self.select_symbols(ctx, date, band_low, band_high)?; let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; 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(date, &position.symbol, PriceField::Last) else { continue; }; let Some(market) = ctx.data.market(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 = !market.is_at_upper_limit_price(current_price) && current_price / position.average_cost > self.config.take_profit_ratio; let can_sell = self.can_sell_position(ctx, date, &position.symbol); if stop_hit || profit_hit { 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, 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 self.buy_rejection_reason(ctx, 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, 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 !self.can_sell_position(ctx, 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, 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 self.buy_rejection_reason(ctx, 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, date, symbol, fixed_buy_cash, "periodic_rebalance_buy", &mut projected_execution_state, ); } } let mut diagnostics = vec![ format!( "jq_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() ), "run_daily(10:17/10:18) approximated as same-day decision with snapshot last_price signals and bid1/ask1 side-aware execution".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); 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, }) } } 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 std::time::{SystemTime, UNIX_EPOCH}; fn temp_csv_path(name: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); env::temp_dir().join(format!("{}_{}_{}.csv", name, std::process::id(), nanos)) } #[test] fn load_truth_stock_lists_preserves_rank_order() { let path = temp_csv_path("jq_truth_list"); fs::write( &path, "trade_date,index,symbol\n2025-01-02,2,300935.SZ\n2025-01-02,1,300321.XSHE\n2025-01-02,1,300321.SZ\n", ) .unwrap(); let lists = load_jq_truth_stock_lists_from_path(&path).unwrap().unwrap(); fs::remove_file(&path).ok(); let symbols = lists .symbols_by_date .get(&NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()) .unwrap(); assert_eq!( symbols, &vec!["300321.SZ".to_string(), "300935.SZ".to_string()] ); } #[test] fn normalize_truth_symbol_maps_joinquant_suffixes() { assert_eq!( normalize_truth_symbol("300321.XSHE").as_deref(), Some("300321.SZ") ); assert_eq!( normalize_truth_symbol("603657.XSHG").as_deref(), Some("603657.SH") ); } }