Files
fidc-backtest-engine/crates/fidc-core/src/strategy.rs
2026-05-18 23:06:47 +08:00

2970 lines
98 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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::{
DailyMarketSnapshot, DataSet, DividendRecord, FactorTextValue, FactorValue,
IntradayExecutionQuote, PriceBar, PriceField, SecuritiesMarginRecord, SplitRecord,
YieldCurvePoint,
};
use crate::engine::BacktestError;
use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent};
use crate::futures::{FuturesAccountState, FuturesOrderIntent};
use crate::instrument::Instrument;
use crate::portfolio::PortfolioState;
use crate::scheduler::ScheduleRule;
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};
pub trait Strategy {
fn name(&self) -> &str;
fn management_fee(
&mut self,
_ctx: &StrategyContext<'_>,
_rate: f64,
) -> Result<Option<f64>, BacktestError> {
Ok(None)
}
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_bar(&mut self, _ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
Ok(StrategyDecision::default())
}
fn on_tick(
&mut self,
_ctx: &StrategyContext<'_>,
_quote: &IntradayExecutionQuote,
) -> Result<StrategyDecision, BacktestError> {
Ok(StrategyDecision::default())
}
fn on_day(&mut self, _ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
Ok(StrategyDecision::default())
}
fn after_trading(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
Ok(())
}
fn on_settlement(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct OpenOrderView {
pub order_id: u64,
pub symbol: String,
pub side: OrderSide,
pub requested_quantity: u32,
pub filled_quantity: u32,
pub remaining_quantity: u32,
pub unfilled_quantity: u32,
pub status: OrderStatus,
pub avg_price: f64,
pub transaction_cost: f64,
pub limit_price: f64,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct OrderRuntimeView {
pub order_id: u64,
pub symbol: String,
pub side: OrderSide,
pub requested_quantity: u32,
pub filled_quantity: u32,
pub unfilled_quantity: u32,
pub status: OrderStatus,
pub avg_price: f64,
pub transaction_cost: f64,
pub limit_price: f64,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct PortfolioRuntimeView {
pub account_type: &'static str,
pub starting_cash: f64,
pub units: f64,
pub cash: f64,
pub available_cash: f64,
pub frozen_cash: f64,
pub market_value: f64,
pub total_value: f64,
pub portfolio_value: f64,
pub total_equity: f64,
pub unit_net_value: f64,
pub static_unit_net_value: f64,
pub daily_pnl: f64,
pub daily_returns: f64,
pub total_returns: f64,
pub transaction_cost: f64,
pub trading_pnl: f64,
pub position_pnl: f64,
pub cash_liabilities: f64,
pub management_fee_rate: f64,
pub management_fees: f64,
}
pub struct StrategyContext<'a> {
pub execution_date: NaiveDate,
pub decision_date: NaiveDate,
pub decision_index: usize,
pub data: &'a DataSet,
pub portfolio: &'a PortfolioState,
pub futures_account: Option<&'a FuturesAccountState>,
pub open_orders: &'a [OpenOrderView],
pub dynamic_universe: Option<&'a BTreeSet<String>>,
pub subscriptions: &'a BTreeSet<String>,
pub process_events: &'a [ProcessEvent],
pub active_process_event: Option<&'a ProcessEvent>,
pub active_datetime: Option<NaiveDateTime>,
pub order_events: &'a [OrderEvent],
pub fills: &'a [FillEvent],
}
impl StrategyContext<'_> {
pub fn current_datetime(&self) -> Option<NaiveDateTime> {
self.active_datetime
}
pub fn current_time(&self) -> Option<NaiveTime> {
self.active_datetime.map(|value| value.time())
}
pub fn has_open_orders(&self) -> bool {
!self.open_orders.is_empty()
}
pub fn open_order_count(&self) -> usize {
self.open_orders.len()
}
pub fn open_buy_order_count(&self) -> usize {
self.open_orders
.iter()
.filter(|order| order.side == OrderSide::Buy)
.count()
}
pub fn open_sell_order_count(&self) -> usize {
self.open_orders
.iter()
.filter(|order| order.side == OrderSide::Sell)
.count()
}
pub fn open_buy_quantity(&self) -> u32 {
self.open_orders
.iter()
.filter(|order| order.side == OrderSide::Buy)
.map(|order| order.remaining_quantity)
.sum()
}
pub fn open_sell_quantity(&self) -> u32 {
self.open_orders
.iter()
.filter(|order| order.side == OrderSide::Sell)
.map(|order| order.remaining_quantity)
.sum()
}
pub fn symbol_open_order_count(&self, symbol: &str) -> usize {
self.open_orders
.iter()
.filter(|order| order.symbol == symbol)
.count()
}
pub fn symbol_open_buy_quantity(&self, symbol: &str) -> u32 {
self.open_orders
.iter()
.filter(|order| order.symbol == symbol && order.side == OrderSide::Buy)
.map(|order| order.remaining_quantity)
.sum()
}
pub fn symbol_open_sell_quantity(&self, symbol: &str) -> u32 {
self.open_orders
.iter()
.filter(|order| order.symbol == symbol && order.side == OrderSide::Sell)
.map(|order| order.remaining_quantity)
.sum()
}
pub fn latest_open_order_id(&self) -> u64 {
self.open_orders
.iter()
.map(|order| order.order_id)
.max()
.unwrap_or(0)
}
pub fn latest_open_order_status(&self) -> &'static str {
self.open_orders
.iter()
.max_by_key(|order| order.order_id)
.map(|order| order.status.as_str())
.unwrap_or("")
}
pub fn latest_open_order_unfilled_quantity(&self) -> u32 {
self.open_orders
.iter()
.max_by_key(|order| order.order_id)
.map(|order| order.unfilled_quantity)
.unwrap_or(0)
}
pub fn latest_symbol_open_order_id(&self, symbol: &str) -> u64 {
self.open_orders
.iter()
.filter(|order| order.symbol == symbol)
.map(|order| order.order_id)
.max()
.unwrap_or(0)
}
pub fn latest_symbol_open_order_status(&self, symbol: &str) -> &'static str {
self.open_orders
.iter()
.filter(|order| order.symbol == symbol)
.max_by_key(|order| order.order_id)
.map(|order| order.status.as_str())
.unwrap_or("")
}
pub fn latest_symbol_open_order_unfilled_quantity(&self, symbol: &str) -> u32 {
self.open_orders
.iter()
.filter(|order| order.symbol == symbol)
.max_by_key(|order| order.order_id)
.map(|order| order.unfilled_quantity)
.unwrap_or(0)
}
pub fn order(&self, order_id: u64) -> Option<OrderRuntimeView> {
let fills = self
.fills
.iter()
.filter(|fill| fill.order_id == Some(order_id))
.collect::<Vec<_>>();
let filled_quantity = fills.iter().map(|fill| fill.quantity).sum::<u32>();
let gross_amount = fills.iter().map(|fill| fill.gross_amount).sum::<f64>();
let transaction_cost = fills
.iter()
.map(|fill| fill.commission + fill.stamp_tax)
.sum::<f64>();
let avg_price = if filled_quantity == 0 {
0.0
} else {
gross_amount / filled_quantity as f64
};
if let Some(order) = self
.open_orders
.iter()
.find(|order| order.order_id == order_id)
{
let filled_quantity = order.filled_quantity.max(filled_quantity);
return Some(OrderRuntimeView {
order_id,
symbol: order.symbol.clone(),
side: order.side,
requested_quantity: order.requested_quantity,
filled_quantity,
unfilled_quantity: order
.unfilled_quantity
.min(order.requested_quantity.saturating_sub(filled_quantity)),
status: order.status,
avg_price: if avg_price > 0.0 {
avg_price
} else {
order.avg_price
},
transaction_cost: if transaction_cost > 0.0 {
transaction_cost
} else {
order.transaction_cost
},
limit_price: order.limit_price,
reason: order.reason.clone(),
});
}
let latest_event = self
.order_events
.iter()
.rev()
.filter(|event| event.order_id == Some(order_id))
.next()?;
let filled_quantity = latest_event.filled_quantity.max(filled_quantity);
Some(OrderRuntimeView {
order_id,
symbol: latest_event.symbol.clone(),
side: latest_event.side,
requested_quantity: latest_event.requested_quantity,
filled_quantity,
unfilled_quantity: latest_event
.requested_quantity
.saturating_sub(filled_quantity),
status: latest_event.status,
avg_price,
transaction_cost,
limit_price: 0.0,
reason: latest_event.reason.clone(),
})
}
pub fn order_status(&self, order_id: u64) -> &'static str {
self.order(order_id)
.map(|order| order.status.as_str())
.unwrap_or("")
}
pub fn order_avg_price(&self, order_id: u64) -> f64 {
self.order(order_id)
.map(|order| order.avg_price)
.unwrap_or(0.0)
}
pub fn order_transaction_cost(&self, order_id: u64) -> f64 {
self.order(order_id)
.map(|order| order.transaction_cost)
.unwrap_or(0.0)
}
pub fn portfolio_view(&self) -> PortfolioRuntimeView {
let frozen_cash = self.frozen_cash();
let cash = self.portfolio.cash();
let total_equity = self.portfolio.total_equity();
PortfolioRuntimeView {
account_type: "STOCK",
starting_cash: self.portfolio.starting_cash(),
units: self.portfolio.units(),
cash,
available_cash: (cash - frozen_cash).max(0.0),
frozen_cash,
market_value: self.portfolio.market_value(),
total_value: self.portfolio.total_value(),
portfolio_value: self.portfolio.portfolio_value(),
total_equity,
unit_net_value: self.portfolio.unit_net_value(),
static_unit_net_value: self.portfolio.static_unit_net_value(),
daily_pnl: self.portfolio.daily_pnl(),
daily_returns: self.portfolio.daily_returns(),
total_returns: self.portfolio.total_returns(),
transaction_cost: self.portfolio.transaction_cost(),
trading_pnl: self.portfolio.trading_pnl(),
position_pnl: self.portfolio.position_pnl(),
cash_liabilities: self.portfolio.cash_liabilities(),
management_fee_rate: self.portfolio.management_fee_rate(),
management_fees: self.portfolio.management_fees(),
}
}
pub fn account(&self) -> PortfolioRuntimeView {
self.portfolio_view()
}
pub fn stock_account(&self) -> PortfolioRuntimeView {
self.portfolio_view()
}
pub fn future_account(&self) -> Option<PortfolioRuntimeView> {
self.futures_account.map(|account| {
let starting_cash = account.starting_cash();
let total_value = account.total_value();
let daily_pnl = account.daily_pnl();
let static_base = total_value - daily_pnl;
let unit_net_value = safe_ratio(total_value, starting_cash);
let static_unit_net_value = safe_ratio(static_base, starting_cash);
PortfolioRuntimeView {
account_type: "FUTURE",
starting_cash,
units: 1.0,
cash: account.cash(),
available_cash: account.cash(),
frozen_cash: account.frozen_cash(),
market_value: account.market_value(),
total_value,
portfolio_value: total_value,
total_equity: total_value,
unit_net_value,
static_unit_net_value,
daily_pnl,
daily_returns: safe_ratio(daily_pnl, static_base),
total_returns: safe_ratio(total_value - starting_cash, starting_cash),
transaction_cost: account.transaction_cost(),
trading_pnl: account.trading_pnl(),
position_pnl: account.position_pnl(),
cash_liabilities: 0.0,
management_fee_rate: 0.0,
management_fees: 0.0,
}
})
}
pub fn account_by_type(&self, account_type: &str) -> Option<PortfolioRuntimeView> {
if account_type.eq_ignore_ascii_case("STOCK") {
Some(self.stock_account())
} else if account_type.eq_ignore_ascii_case("FUTURE") {
self.future_account()
} else {
None
}
}
pub fn accounts(&self) -> BTreeMap<String, PortfolioRuntimeView> {
let mut accounts = BTreeMap::from([("STOCK".to_string(), self.stock_account())]);
if let Some(future_account) = self.future_account() {
accounts.insert("FUTURE".to_string(), future_account);
}
accounts
}
pub fn frozen_cash(&self) -> f64 {
self.open_orders
.iter()
.filter(|order| order.side == OrderSide::Buy)
.map(|order| {
let price = if order.limit_price.is_finite() {
order.limit_price.max(0.0)
} else {
0.0
};
order.remaining_quantity as f64 * price
})
.sum()
}
pub fn available_cash(&self) -> f64 {
(self.portfolio.cash() - self.frozen_cash()).max(0.0)
}
pub fn available_sellable_qty(&self, symbol: &str, raw_sellable_qty: u32) -> u32 {
raw_sellable_qty.saturating_sub(self.symbol_open_sell_quantity(symbol))
}
pub fn has_dynamic_universe(&self) -> bool {
self.dynamic_universe
.is_some_and(|symbols| !symbols.is_empty())
}
pub fn dynamic_universe_count(&self) -> usize {
self.dynamic_universe.map_or(0, BTreeSet::len)
}
pub fn dynamic_universe_contains(&self, symbol: &str) -> bool {
self.dynamic_universe
.is_some_and(|symbols| symbols.contains(symbol))
}
pub fn eligible_universe_on(
&self,
date: NaiveDate,
) -> Vec<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 current_snapshot(&self, symbol: &str) -> Option<&DailyMarketSnapshot> {
self.data.market(self.execution_date, symbol)
}
pub fn history_bars(
&self,
symbol: &str,
bar_count: usize,
frequency: &str,
field: &str,
include_now: bool,
) -> Vec<f64> {
self.data.history_bars_at(
self.execution_date,
self.active_datetime,
symbol,
bar_count,
frequency,
field,
include_now,
)
}
pub fn history_daily_snapshots(
&self,
symbol: &str,
bar_count: usize,
include_now: bool,
) -> Vec<DailyMarketSnapshot> {
self.data
.history_daily_snapshots(self.execution_date, symbol, bar_count, include_now)
}
pub fn history_intraday_quotes(
&self,
symbol: &str,
bar_count: usize,
include_now: bool,
) -> Vec<IntradayExecutionQuote> {
self.data.history_intraday_quotes_at(
self.execution_date,
self.active_datetime,
symbol,
bar_count,
include_now,
)
}
pub fn instrument(&self, symbol: &str) -> Option<&Instrument> {
self.data.instrument(symbol)
}
pub fn instruments(&self, symbols: &[&str]) -> Vec<&Instrument> {
symbols
.iter()
.filter_map(|symbol| self.data.instrument(symbol))
.collect()
}
pub fn instruments_history(&self, symbols: &[&str]) -> Vec<&Instrument> {
self.data.instruments_history(symbols)
}
pub fn active_instruments(&self, symbols: &[&str]) -> Vec<&Instrument> {
self.data.active_instruments(self.execution_date, symbols)
}
pub fn all_instruments(&self) -> Vec<&Instrument> {
self.data.all_instruments()
}
pub fn get_trading_dates(&self, start: NaiveDate, end: NaiveDate) -> Vec<NaiveDate> {
self.data.trading_dates(start, end)
}
pub fn get_previous_trading_date(&self, date: NaiveDate, n: usize) -> Option<NaiveDate> {
self.data.previous_trading_date(date, n)
}
pub fn get_next_trading_date(&self, date: NaiveDate, n: usize) -> Option<NaiveDate> {
self.data.next_trading_date(date, n)
}
pub fn is_suspended(&self, symbol: &str, count: usize) -> Vec<bool> {
self.data
.is_suspended_flags(self.execution_date, symbol, count)
}
pub fn is_st_stock(&self, symbol: &str, count: usize) -> Vec<bool> {
self.data
.is_st_stock_flags(self.execution_date, symbol, count)
}
pub fn get_price(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
frequency: &str,
) -> Vec<PriceBar> {
self.data.get_price(symbol, start, end, frequency)
}
pub fn get_dividend(&self, symbol: &str, start: NaiveDate) -> Vec<DividendRecord> {
let end = self
.data
.previous_trading_date(self.execution_date, 1)
.unwrap_or(self.execution_date);
self.data.get_dividend(symbol, start, end)
}
pub fn get_split(&self, symbol: &str, start: NaiveDate) -> Vec<SplitRecord> {
let end = self
.data
.previous_trading_date(self.execution_date, 1)
.unwrap_or(self.execution_date);
self.data.get_split(symbol, start, end)
}
pub fn get_factor(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
field: &str,
) -> Vec<FactorValue> {
self.data.get_factor(symbol, start, end, field)
}
pub fn get_factor_text(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
field: &str,
) -> Vec<FactorTextValue> {
self.data.get_factor_text(symbol, start, end, field)
}
pub fn get_yield_curve(
&self,
start: NaiveDate,
end: NaiveDate,
tenor: Option<&str>,
) -> Vec<YieldCurvePoint> {
self.data.get_yield_curve(start, end, tenor)
}
pub fn get_margin_stocks(&self, margin_type: &str) -> Vec<String> {
self.data
.get_margin_stocks(self.execution_date, margin_type)
}
pub fn get_securities_margin(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
field: &str,
) -> Vec<SecuritiesMarginRecord> {
self.data.get_securities_margin(symbol, start, end, field)
}
pub fn get_shares(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
share_type: &str,
) -> Vec<FactorValue> {
self.data.get_shares(symbol, start, end, share_type)
}
pub fn get_turnover_rate(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
field: &str,
) -> Vec<FactorValue> {
self.data.get_turnover_rate(symbol, start, end, field)
}
pub fn get_price_change_rate(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
) -> Vec<FactorValue> {
self.data.get_price_change_rate(symbol, start, end)
}
pub fn get_stock_connect(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
field: &str,
) -> Vec<FactorValue> {
self.data.get_stock_connect(symbol, start, end, field)
}
pub fn current_performance(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
field: &str,
) -> Vec<FactorValue> {
self.data.current_performance(symbol, start, end, field)
}
pub fn get_fundamentals(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
field: &str,
) -> Vec<FactorValue> {
self.data.get_fundamentals(symbol, start, end, field)
}
pub fn get_financials(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
field: &str,
) -> Vec<FactorValue> {
self.data.get_financials(symbol, start, end, field)
}
pub fn get_pit_financials(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
field: &str,
) -> Vec<FactorValue> {
self.data.get_pit_financials(symbol, start, end, field)
}
pub fn get_industry(&self, symbol: &str, source: &str, level: usize) -> Option<FactorValue> {
self.data
.get_industry(symbol, self.execution_date, source, level)
}
pub fn get_industry_name(
&self,
symbol: &str,
source: &str,
level: usize,
) -> Option<FactorTextValue> {
self.data
.get_industry_name(symbol, self.execution_date, source, level)
}
pub fn get_dominant_future(&self, underlying_symbol: &str) -> Option<String> {
self.data
.get_dominant_future(underlying_symbol, self.execution_date)
}
pub fn get_dominant_future_price(
&self,
underlying_symbol: &str,
start: NaiveDate,
end: NaiveDate,
frequency: &str,
) -> Vec<PriceBar> {
self.data
.get_dominant_future_price(underlying_symbol, start, end, frequency)
}
pub fn has_subscriptions(&self) -> bool {
!self.subscriptions.is_empty()
}
pub fn subscription_count(&self) -> usize {
self.subscriptions.len()
}
pub fn is_subscribed(&self, symbol: &str) -> bool {
self.subscriptions.contains(symbol)
}
pub fn has_process_events(&self) -> bool {
!self.process_events.is_empty() || self.active_process_event.is_some()
}
pub fn process_event_count(&self) -> usize {
self.process_events.len() + usize::from(self.active_process_event.is_some())
}
pub fn process_event_count_by_kind(&self, kind: crate::events::ProcessEventKind) -> usize {
self.process_events
.iter()
.filter(|event| event.kind == kind)
.count()
+ usize::from(
self.active_process_event
.is_some_and(|event| event.kind == kind),
)
}
pub fn latest_process_event(&self) -> Option<&ProcessEvent> {
self.active_process_event
.or_else(|| self.process_events.last())
}
pub fn latest_process_event_kind(&self) -> &'static str {
self.latest_process_event()
.map(|event| event.kind.as_str())
.unwrap_or("")
}
pub fn latest_process_event_order_id(&self) -> u64 {
self.latest_process_event()
.and_then(|event| event.order_id)
.unwrap_or(0)
}
pub fn latest_process_event_symbol(&self) -> &str {
self.latest_process_event()
.and_then(|event| event.symbol.as_deref())
.unwrap_or("")
}
pub fn latest_process_event_side(&self) -> &'static str {
self.latest_process_event()
.and_then(|event| event.side.as_ref())
.map(OrderSide::as_str)
.unwrap_or("")
}
pub fn latest_process_event_detail(&self) -> &str {
self.latest_process_event()
.map(|event| event.detail.as_str())
.unwrap_or("")
}
pub fn current_process_event_kind(&self) -> &'static str {
self.active_process_event
.map(|event| event.kind.as_str())
.unwrap_or("")
}
pub fn current_process_event_order_id(&self) -> u64 {
self.active_process_event
.and_then(|event| event.order_id)
.unwrap_or(0)
}
pub fn current_process_event_symbol(&self) -> &str {
self.active_process_event
.and_then(|event| event.symbol.as_deref())
.unwrap_or("")
}
pub fn current_process_event_side(&self) -> &'static str {
self.active_process_event
.and_then(|event| event.side.as_ref())
.map(OrderSide::as_str)
.unwrap_or("")
}
pub fn current_process_event_detail(&self) -> &str {
self.active_process_event
.map(|event| event.detail.as_str())
.unwrap_or("")
}
pub fn process_event_counts(&self) -> BTreeMap<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
}
}
fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
if denominator.abs() <= f64::EPSILON {
0.0
} else {
numerator / denominator
}
}
#[derive(Debug, Clone, Default)]
pub struct StrategyDecision {
pub rebalance: bool,
pub target_weights: BTreeMap<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 TargetPortfolioOrderPricing {
LimitPrices(BTreeMap<String, f64>),
AlgoOrder {
style: AlgoOrderStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
},
}
#[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<TargetPortfolioOrderPricing>,
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,
},
DepositWithdraw {
amount: f64,
receiving_days: usize,
reason: String,
},
FinanceRepay {
amount: f64,
reason: String,
},
SetManagementFeeRate {
rate: f64,
reason: String,
},
Futures {
intent: FuturesOrderIntent,
},
}
#[derive(Debug, Clone)]
pub struct CnSmallCapRotationConfig {
pub strategy_name: String,
pub refresh_rate: usize,
pub stocknum: usize,
pub xs: f64,
pub base_index_level: f64,
pub base_cap_floor: f64,
pub cap_span: f64,
pub padding_ratio: f64,
pub min_padding: f64,
pub max_padding: f64,
pub short_ma_days: usize,
pub long_ma_days: usize,
pub stock_short_ma_days: usize,
pub stock_mid_ma_days: usize,
pub stock_long_ma_days: usize,
pub rsi_rate: f64,
pub trade_rate: f64,
pub stop_loss_pct: f64,
pub take_profit_pct: f64,
pub signal_symbol: Option<String>,
pub skip_months: Vec<u32>,
pub skip_month_day_ranges: Vec<(Option<u32>, 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,
padding_ratio: 0.5,
min_padding: 8.0,
max_padding: 20.0,
short_ma_days: 3,
long_ma_days: 5,
stock_short_ma_days: 3,
stock_mid_ma_days: 5,
stock_long_ma_days: 8,
rsi_rate: 1.0001,
trade_rate: 0.5,
stop_loss_pct: 0.08,
take_profit_pct: 0.10,
signal_symbol: None,
skip_months: Vec::new(),
skip_month_day_ranges: Vec::new(),
}
}
pub fn cn_dyn_smallcap_band() -> Self {
Self {
strategy_name: "cn-dyn-smallcap-band".to_string(),
refresh_rate: 15,
stocknum: 40,
xs: 4.0 / 500.0,
base_index_level: 2000.0,
base_cap_floor: 7.0,
cap_span: 10.0,
padding_ratio: 0.5,
min_padding: 8.0,
max_padding: 20.0,
short_ma_days: 5,
long_ma_days: 10,
stock_short_ma_days: 5,
stock_mid_ma_days: 10,
stock_long_ma_days: 20,
rsi_rate: 1.0001,
trade_rate: 0.5,
stop_loss_pct: 0.07,
take_profit_pct: 0.07,
signal_symbol: Some("000852.SH".to_string()),
skip_months: vec![],
skip_month_day_ranges: vec![
(None, 1, 15, 30),
(None, 4, 15, 29),
(None, 8, 15, 31),
(None, 10, 20, 30),
(None, 12, 20, 30),
],
}
}
fn in_skip_window(&self, date: NaiveDate) -> bool {
let year = date.year() as u32;
let month = date.month();
let day = date.day();
self.skip_months.contains(&month)
|| self
.skip_month_day_ranges
.iter()
.any(|(window_year, m, start_day, end_day)| {
window_year.map(|value| value == year).unwrap_or(true)
&& month == *m
&& day >= *start_day
&& day <= *end_day
})
}
}
pub struct CnSmallCapRotationStrategy {
config: CnSmallCapRotationConfig,
selector: DynamicMarketCapBandSelector,
last_gross_exposure: Option<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.padding_ratio,
config.min_padding,
config.max_padding,
),
config,
last_gross_exposure: None,
}
}
fn moving_average(values: &[f64], lookback: usize) -> f64 {
let len = values.len();
let window = values.iter().skip(len.saturating_sub(lookback));
let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| {
(sum + value, count + 1)
});
if count == 0 { 0.0 } else { sum / count as f64 }
}
fn gross_exposure(&self, closes: &[f64]) -> f64 {
if closes.is_empty() {
return 0.0;
}
let current = *closes.last().unwrap_or(&0.0);
let short_ma = Self::moving_average(closes, self.config.short_ma_days);
let long_ma = Self::moving_average(closes, self.config.long_ma_days);
if short_ma < long_ma * self.config.rsi_rate {
self.config.trade_rate
} else if current >= long_ma {
1.0
} else {
self.config.trade_rate
}
}
fn resolve_signal_series(
&self,
ctx: &StrategyContext<'_>,
) -> Result<(String, Vec<f64>, 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 OmniMicroCapConfig {
pub strategy_name: String,
pub refresh_rate: usize,
pub stocknum: usize,
pub xs: f64,
pub base_index_level: f64,
pub base_cap_floor: f64,
pub cap_span: f64,
pub padding_ratio: f64,
pub min_padding: f64,
pub max_padding: f64,
pub benchmark_signal_symbol: String,
pub benchmark_short_ma_days: usize,
pub benchmark_long_ma_days: usize,
pub stock_short_ma_days: usize,
pub stock_mid_ma_days: usize,
pub stock_long_ma_days: usize,
pub rsi_rate: f64,
pub trade_rate: f64,
pub stop_loss_ratio: f64,
pub take_profit_ratio: f64,
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
}
impl OmniMicroCapConfig {
pub fn omni_microcap() -> Self {
Self {
strategy_name: "omni-microcap".to_string(),
refresh_rate: 15,
stocknum: 40,
xs: 4.0 / 500.0,
base_index_level: 2000.0,
base_cap_floor: 7.0,
cap_span: 10.0,
padding_ratio: 0.5,
min_padding: 8.0,
max_padding: 20.0,
benchmark_signal_symbol: "000001.SH".to_string(),
benchmark_short_ma_days: 5,
benchmark_long_ma_days: 10,
stock_short_ma_days: 5,
stock_mid_ma_days: 10,
stock_long_ma_days: 20,
rsi_rate: 1.0001,
trade_rate: 0.5,
stop_loss_ratio: 0.93,
take_profit_ratio: 1.07,
// The migrated reference logic disables seasonal stop windows in
// production-style execution, so the default keeps that behavior.
skip_month_day_ranges: Vec::new(),
}
}
pub fn aiquant_v104() -> Self {
Self {
strategy_name: "aiquant-v1.0.4".to_string(),
refresh_rate: 120,
stocknum: 5,
xs: 4.0 / 500.0,
base_index_level: 2000.0,
base_cap_floor: 7.0,
cap_span: 10.0,
padding_ratio: 1.2,
min_padding: 29.5,
max_padding: 50.0,
benchmark_signal_symbol: "000852.SH".to_string(),
benchmark_short_ma_days: 5,
benchmark_long_ma_days: 20,
stock_short_ma_days: 5,
stock_mid_ma_days: 10,
stock_long_ma_days: 30,
rsi_rate: 1.0001,
trade_rate: 0.5,
stop_loss_ratio: 0.92,
take_profit_ratio: 1.16,
skip_month_day_ranges: Vec::new(),
}
}
fn in_skip_window(&self, date: NaiveDate) -> bool {
let year = date.year() as u32;
let month = date.month();
let day = date.day();
self.skip_month_day_ranges
.iter()
.any(|(window_year, m, start_day, end_day)| {
window_year.map(|value| value == year).unwrap_or(true)
&& month == *m
&& day >= *start_day
&& day <= *end_day
})
}
}
pub struct OmniMicroCapStrategy {
config: OmniMicroCapConfig,
}
#[derive(Debug, Clone)]
struct OmniTruthStockLists {
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 OmniMicroCapStrategy {
pub fn new(config: OmniMicroCapConfig) -> Self {
Self { config }
}
fn truth_stock_list_for_date(&self, date: NaiveDate) -> Option<&Vec<String>> {
omni_truth_stock_lists()
.as_ref()
.and_then(|lists| lists.symbols_by_date.get(&date))
}
fn truth_stock_list_source_path(&self) -> Option<&str> {
omni_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)
}
#[allow(dead_code)]
fn projected_buy_quantity(&self, cash: f64, sizing_price: f64, execution_price: f64) -> u32 {
if cash <= 0.0 || sizing_price <= 0.0 || execution_price <= 0.0 {
return 0;
}
let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100, 100);
while quantity > 0 {
let gross_amount = execution_price * quantity as f64;
if gross_amount + self.buy_commission(gross_amount) <= cash + 1e-6 {
return quantity;
}
quantity = self.decrement_order_quantity(quantity, 100, 100);
}
0
}
fn projected_execution_price(
&self,
market: &crate::data::DailyMarketSnapshot,
side: OrderSide,
) -> f64 {
let _ = side;
market.price(PriceField::Last)
}
fn project_order_value(
&self,
ctx: &StrategyContext<'_>,
projected: &mut PortfolioState,
date: NaiveDate,
symbol: &str,
order_value: f64,
reason: &str,
execution_state: &mut ProjectedExecutionState,
) -> u32 {
if order_value <= 0.0 {
return 0;
}
let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol);
let market = match ctx.data.market(date, symbol) {
Some(market) => market,
None => return 0,
};
let sizing_price = market.price(PriceField::Last);
if !sizing_price.is_finite() || sizing_price <= 0.0 {
return 0;
}
let mut snapshot_requested_qty = self.round_lot_quantity(
((projected.cash().min(order_value)) / sizing_price).floor() as u32,
minimum_order_quantity,
order_step_size,
);
while snapshot_requested_qty > 0 {
let gross_amount = sizing_price * snapshot_requested_qty as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 {
break;
}
snapshot_requested_qty = self.decrement_order_quantity(
snapshot_requested_qty,
minimum_order_quantity,
order_step_size,
);
}
let projected_execution_price = self.projected_execution_price(market, OrderSide::Buy);
let projected_fill = self.projected_select_execution_fill(
ctx,
date,
symbol,
OrderSide::Buy,
u32::MAX,
round_lot,
minimum_order_quantity,
order_step_size,
false,
Some(projected.cash().min(order_value)),
Some(order_value),
execution_state,
);
let mut quantity = snapshot_requested_qty;
while quantity > 0 {
let gross_amount = projected_execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 {
break;
}
quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
}
if quantity == 0 {
return 0;
}
let execution_price = projected_fill
.as_ref()
.map(|fill| fill.price)
.unwrap_or(projected_execution_price);
while quantity > 0 {
let gross_amount = execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 {
break;
}
quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
}
if quantity == 0 {
return 0;
}
let fill = ProjectedExecutionFill {
price: execution_price,
quantity,
next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1),
};
let gross_amount = fill.price * fill.quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out > projected.cash() + 1e-6 || cash_out > order_value + 1e-6 {
return 0;
}
projected.apply_cash_delta(-cash_out);
projected
.position_mut(symbol)
.buy(date, fill.quantity, fill.price);
*execution_state
.intraday_turnover
.entry(symbol.to_string())
.or_default() += fill.quantity;
execution_state
.execution_cursors
.insert(symbol.to_string(), fill.next_cursor);
if self.uses_serial_execution_cursor(reason) {
execution_state.global_execution_cursor = Some(fill.next_cursor);
}
fill.quantity
}
fn project_target_zero(
&self,
ctx: &StrategyContext<'_>,
projected: &mut PortfolioState,
date: NaiveDate,
symbol: &str,
reason: &str,
execution_state: &mut ProjectedExecutionState,
) -> Option<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)
}
#[allow(dead_code)]
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, f64), BacktestError> {
// 当前交易日的指数价格用于MA计算和仓位控制
let current_level = ctx
.data
.market_decision_close(date, &self.config.benchmark_signal_symbol)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: self.config.benchmark_signal_symbol.clone(),
field: "decision_close",
})?;
// 前一交易日的指数价格(用于市值区间计算,模拟实盘场景)
let prev_level = if let Some(prev_date) = ctx.data.previous_trading_date(date, 1) {
ctx.data
.market_decision_close(prev_date, &self.config.benchmark_signal_symbol)
.unwrap_or(current_level)
} else {
current_level
};
let ma_short = ctx
.data
.market_decision_close_moving_average(
date,
&self.config.benchmark_signal_symbol,
self.config.benchmark_short_ma_days,
)
.ok_or_else(|| {
BacktestError::Execution(format!(
"insufficient benchmark short MA history for {} on {}",
self.config.benchmark_signal_symbol, date
))
})?;
let ma_long = ctx
.data
.market_decision_close_moving_average(
date,
&self.config.benchmark_signal_symbol,
self.config.benchmark_long_ma_days,
)
.ok_or_else(|| {
BacktestError::Execution(format!(
"insufficient benchmark long MA history for {} on {}",
self.config.benchmark_signal_symbol, date
))
})?;
let trading_ratio = if ma_short < ma_long * self.config.rsi_rate {
self.config.trade_rate
} else {
1.0
};
Ok((current_level, prev_level, ma_short, ma_long, trading_ratio))
}
fn market_cap_band(&self, index_level: f64) -> (f64, f64) {
let y = (index_level - self.config.base_index_level) * self.config.xs
+ self.config.base_cap_floor;
let start = y.round();
let end = start + self.config.cap_span;
// Apply padding to expand the range
let span = end - start;
let padding = (span * self.config.padding_ratio)
.max(self.config.min_padding)
.min(self.config.max_padding);
let lower_bound = (start - padding).max(0.0);
let upper_bound = end + padding;
(lower_bound, upper_bound)
}
fn stock_passes_ma_filter(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
symbol: &str,
) -> bool {
let Some(ma_short) = ctx.data.market_decision_close_moving_average(
date,
symbol,
self.config.stock_short_ma_days,
) else {
return false;
};
let Some(ma_mid) = ctx.data.market_decision_close_moving_average(
date,
symbol,
self.config.stock_mid_ma_days,
) else {
return false;
};
let Some(ma_long) = ctx.data.market_decision_close_moving_average(
date,
symbol,
self.config.stock_long_ma_days,
) else {
return false;
};
// MA filter: ma_short > ma_mid * rsi_rate && ma_mid * rsi_rate > ma_long
let ma_pass =
ma_short > ma_mid * self.config.rsi_rate && ma_mid * self.config.rsi_rate > ma_long;
// Debug logging for ALL stocks on first decision date
static DEBUG_DATE: std::sync::Mutex<Option<NaiveDate>> = std::sync::Mutex::new(None);
let mut debug_date = DEBUG_DATE.lock().unwrap();
let should_debug = if let Some(d) = *debug_date {
d == date
} else {
*debug_date = Some(date);
true
};
if should_debug {
eprintln!(
"[MA_FILTER] {} cap={:.2} ma5={:.4} ma10={:.4} ma30={:.4} ma10*rsi={:.4} pass={} ({}>{:.4}? {} && {:.4}>{}? {})",
symbol,
ctx.data.market_decision_close(date, symbol).unwrap_or(0.0),
ma_short,
ma_mid,
ma_long,
ma_mid * self.config.rsi_rate,
ma_pass,
ma_short,
ma_mid * self.config.rsi_rate,
ma_short > ma_mid * self.config.rsi_rate,
ma_mid * self.config.rsi_rate,
ma_long,
ma_mid * self.config.rsi_rate > ma_long
);
}
if !ma_pass {
return false;
}
// Volume filter: V5 < V60 (applied for omni_microcap strategies)
if self.config.strategy_name.contains("aiquant")
|| self.config.strategy_name.contains("AiQuant")
|| self.config.strategy_name.contains("omni")
{
let Some(volume_ma5) = ctx
.data
.market_decision_volume_moving_average(date, symbol, 5)
else {
return false;
};
let Some(volume_ma60) = ctx
.data
.market_decision_volume_moving_average(date, symbol, 60)
else {
return false;
};
if volume_ma5 >= volume_ma60 {
return false;
}
}
true
}
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.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 omni_truth_stock_lists() -> &'static Option<OmniTruthStockLists> {
static LISTS: OnceLock<Option<OmniTruthStockLists>> = OnceLock::new();
LISTS.get_or_init(load_omni_truth_stock_lists)
}
fn load_omni_truth_stock_lists() -> Option<OmniTruthStockLists> {
for path in omni_truth_stock_list_candidates() {
if !path.is_file() {
continue;
}
if let Ok(Some(lists)) = load_omni_truth_stock_lists_from_path(&path) {
return Some(lists);
}
}
None
}
fn omni_truth_stock_list_candidates() -> Vec<PathBuf> {
let mut candidates = Vec::new();
for key in [
"FIDC_BT_TRUTH_STOCK_LIST_CSV",
"OMNI_BT_TRUTH_STOCK_LIST_CSV",
"OMNI_BACKTEST_TRUTH_STOCK_LIST_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("data/demo/engine_truth_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_omni_truth_stock_lists_from_path(
path: &Path,
) -> Result<Option<OmniTruthStockLists>, 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(OmniTruthStockLists {
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 OmniMicroCapStrategy {
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!["platform-native skip window forced all cash".to_string()],
});
}
let (index_level, prev_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(prev_index_level);
eprintln!(
"[DEBUG] date={} current_index={:.2} prev_index={:.2} band=[{:.0}, {:.0}]",
date, index_level, prev_index_level, band_low, band_high
);
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 = 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!(
"omni_microcap signal={} last={:.2} ma_short={:.2} ma_long={:.2} band={:.0}-{:.0} tr={:.2}",
self.config.benchmark_signal_symbol, index_level, ma_short, ma_long, band_low, band_high, trading_ratio
),
format!(
"selected={} periodic_rebalance={} exits={} projected_positions={} intents={}",
stock_list.len(),
periodic_rebalance,
exit_symbols.len(),
projected.positions().len(),
order_intents.len()
),
"platform schedule 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("omni_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_omni_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_external_platform_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")
);
}
}