Files
fidc-backtest-engine/crates/fidc-core/src/strategy.rs
2026-04-23 07:36:20 -07:00

2194 lines
74 KiB
Rust

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, ProcessEvent};
use crate::portfolio::PortfolioState;
use crate::scheduler::ScheduleRule;
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};
pub trait Strategy {
fn name(&self) -> &str;
fn on_process_event(
&mut self,
_ctx: &StrategyContext<'_>,
_event: &ProcessEvent,
) -> Result<(), BacktestError> {
Ok(())
}
fn schedule_rules(&self) -> Vec<ScheduleRule> {
Vec::new()
}
fn on_scheduled(
&mut self,
_ctx: &StrategyContext<'_>,
_rule: &ScheduleRule,
) -> Result<StrategyDecision, BacktestError> {
Ok(StrategyDecision::default())
}
fn before_trading(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
Ok(())
}
fn open_auction(
&mut self,
_ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, BacktestError> {
Ok(StrategyDecision::default())
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError>;
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 limit_price: f64,
pub reason: String,
}
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 open_orders: &'a [OpenOrderView],
pub dynamic_universe: Option<&'a BTreeSet<String>>,
pub subscriptions: &'a BTreeSet<String>,
pub process_events: &'a [ProcessEvent],
pub active_process_event: Option<&'a ProcessEvent>,
}
impl StrategyContext<'_> {
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_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 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<crate::data::EligibleUniverseSnapshot> {
let eligible = self.data.eligible_universe_on(date);
match self.dynamic_universe {
Some(symbols) if !symbols.is_empty() => eligible
.iter()
.filter(|row| symbols.contains(&row.symbol))
.cloned()
.collect(),
_ => eligible.to_vec(),
}
}
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<String, i64> {
let mut counts = BTreeMap::<String, i64>::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
}
}
#[derive(Debug, Clone, Default)]
pub struct StrategyDecision {
pub rebalance: bool,
pub target_weights: BTreeMap<String, f64>,
pub exit_symbols: BTreeSet<String>,
pub order_intents: Vec<OrderIntent>,
pub notes: Vec<String>,
pub diagnostics: Vec<String>,
}
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, Copy, PartialEq, Eq)]
pub enum AlgoOrderStyle {
Vwap,
Twap,
}
#[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,
},
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<NaiveTime>,
end_time: Option<NaiveTime>,
reason: String,
},
AlgoPercent {
symbol: String,
percent: f64,
style: AlgoOrderStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
reason: String,
},
TargetPortfolioSmart {
target_weights: BTreeMap<String, f64>,
order_prices: Option<BTreeMap<String, f64>>,
valuation_prices: Option<BTreeMap<String, f64>>,
reason: String,
},
CancelOrder {
order_id: u64,
reason: String,
},
CancelSymbol {
symbol: String,
reason: String,
},
CancelAll {
reason: String,
},
UpdateUniverse {
symbols: BTreeSet<String>,
reason: String,
},
Subscribe {
symbols: BTreeSet<String>,
reason: String,
},
Unsubscribe {
symbols: BTreeSet<String>,
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<String>,
pub skip_months: Vec<u32>,
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<f64>,
}
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>, 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<BTreeSet<String>, 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<StrategyDecision, BacktestError> {
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,
dynamic_universe: ctx.dynamic_universe,
});
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::<Vec<_>>();
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::<Vec<_>>()
.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::<Vec<_>>().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<NaiveDate, Vec<String>>,
}
#[derive(Default)]
struct ProjectedExecutionState {
execution_cursors: BTreeMap<String, NaiveDateTime>,
global_execution_cursor: Option<NaiveDateTime>,
intraday_turnover: BTreeMap<String, u32>,
}
#[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<String>> {
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<u32> {
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<u32> {
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<NaiveDateTime> {
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<f64>,
gross_limit: Option<f64>,
execution_state: &ProjectedExecutionState,
) -> Option<ProjectedExecutionFill> {
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<Option<String>, 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<String>, Vec<String>), 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("<unknown>"),
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 ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(symbol) {
selected_set.remove(symbol);
if diagnostics.len() < 14 {
diagnostics.push(format!("truth {} rejected by dynamic_universe", symbol));
}
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.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.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<JqTruthStockLists> {
static LISTS: OnceLock<Option<JqTruthStockLists>> = OnceLock::new();
LISTS.get_or_init(load_jq_truth_stock_lists)
}
fn load_jq_truth_stock_lists() -> Option<JqTruthStockLists> {
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<PathBuf> {
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<PathBuf>, candidate: PathBuf) {
if !paths.iter().any(|existing| existing == &candidate) {
paths.push(candidate);
}
}
fn load_jq_truth_stock_lists_from_path(path: &Path) -> Result<Option<JqTruthStockLists>, 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<NaiveDate, Vec<(usize, String)>> = 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::<usize>().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::<Vec<_>>();
(date, ordered)
})
.collect::<BTreeMap<_, _>>();
Ok(Some(JqTruthStockLists {
source_path: path.display().to_string(),
symbols_by_date,
}))
}
fn split_simple_csv_line(line: &str) -> Vec<String> {
line.split(',')
.map(|field| field.trim().trim_matches('"').to_string())
.collect()
}
fn normalize_truth_symbol(raw: &str) -> Option<String> {
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<StrategyDecision, BacktestError> {
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::<BTreeSet<_>>();
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::<Vec<_>>()
.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")
);
}
}