use std::collections::{BTreeMap, BTreeSet}; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use crate::data::{DataSet, PriceField}; use crate::engine::BacktestError; use crate::events::OrderSide; use crate::portfolio::PortfolioState; use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector}; pub trait Strategy { fn name(&self) -> &str; fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result; } 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, } #[derive(Debug, Clone)] pub enum OrderIntent { TargetValue { symbol: String, target_value: f64, reason: String, }, Value { symbol: String, value: f64, 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(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 stop_loss_tolerance(&self, market: &crate::data::DailyMarketSnapshot) -> f64 { let _ = market; 0.0 } fn buy_commission(&self, gross_amount: f64) -> f64 { (gross_amount * 0.0003).max(5.0) } fn sell_cost(&self, gross_amount: f64) -> f64 { (gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001) } fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 { let lot = round_lot.max(1); (quantity / lot) * lot } 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_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); while quantity > 0 { let gross_amount = execution_price * quantity as f64; let cash_out = gross_amount + self.buy_commission(gross_amount); if cash_out <= cash + 1e-6 { return quantity; } quantity = quantity.saturating_sub(100); } 0 } fn projected_execution_price( &self, market: &crate::data::DailyMarketSnapshot, side: OrderSide, ) -> f64 { let tick = market.effective_price_tick(); let base_price = market.price(PriceField::Last); let adjusted = match side { OrderSide::Buy => base_price + tick * 2.0, OrderSide::Sell => base_price - tick, }; let lower = if market.lower_limit.is_finite() && market.lower_limit > 0.0 { market.lower_limit } else { tick }; let upper = if market.upper_limit.is_finite() && market.upper_limit > 0.0 { market.upper_limit } else { f64::INFINITY }; adjusted.clamp(lower, upper) } 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 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, round_lot, ); let projected_execution_price = self.projected_execution_price(market, OrderSide::Buy); let mut projected_fill = self.projected_select_execution_fill( ctx, date, symbol, OrderSide::Buy, u32::MAX, round_lot, 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; let cash_out = gross_amount + self.buy_commission(gross_amount); if gross_amount <= order_value + 400.0 && cash_out <= projected.cash() + 1e-6 { break; } quantity = quantity.saturating_sub(round_lot); } 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 <= projected.cash() + 1e-6 { break; } quantity = quantity.saturating_sub(round_lot); } 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 { 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 fill = self .projected_select_execution_fill( ctx, date, symbol, OrderSide::Sell, quantity, round_lot, 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(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, 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 lot = round_lot.max(1); 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; } max_fill = max_fill.min(self.round_lot_quantity(top_level_liquidity, lot)); 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 = self.round_lot_quantity(raw_limit as u32, lot); 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, cash_limit: Option, gross_limit: Option, execution_state: &ProjectedExecutionState, ) -> Option { if requested_qty == 0 { return None; } let lot = round_lot.max(1); 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; let mut last_quote_price = 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_quote_price = fallback_quote_price; 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(lot 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 = self.round_lot_quantity(remaining_qty.min(available_qty), lot); 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 = take_qty.saturating_sub(lot); continue; } let candidate_cash_out = candidate_gross + self.buy_commission(candidate_gross); if candidate_cash_out <= cash + 1e-6 { break; } take_qty = take_qty.saturating_sub(lot); } 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 < requested_qty { let remaining_qty = requested_qty.saturating_sub(filled_qty); let mut residual_qty = self.round_lot_quantity(remaining_qty, lot); if residual_qty > 0 { if let Some(residual_price) = last_quote_price { if let Some(cash) = cash_limit { while residual_qty > 0 { let candidate_gross = gross_amount + residual_price * residual_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { residual_qty = residual_qty.saturating_sub(lot); continue; } let candidate_cash_out = candidate_gross + self.buy_commission(candidate_gross); if candidate_cash_out <= cash + 1e-6 { break; } residual_qty = residual_qty.saturating_sub(lot); } } if residual_qty > 0 { gross_amount += residual_price * residual_qty as f64; filled_qty += residual_qty; } } } } 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; }; !(market.paused || candidate.is_paused || !candidate.allow_sell || market.is_at_lower_limit_price(market.sell_price(PriceField::Last))) } 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.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> { 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)) } } 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 }