1394 lines
49 KiB
Rust
1394 lines
49 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
|
|
|
use crate::data::{DataSet, PriceField};
|
|
use crate::engine::BacktestError;
|
|
use crate::events::OrderSide;
|
|
use crate::portfolio::PortfolioState;
|
|
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};
|
|
|
|
pub trait Strategy {
|
|
fn name(&self) -> &str;
|
|
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError>;
|
|
}
|
|
|
|
pub struct StrategyContext<'a> {
|
|
pub execution_date: NaiveDate,
|
|
pub decision_date: NaiveDate,
|
|
pub decision_index: usize,
|
|
pub data: &'a DataSet,
|
|
pub portfolio: &'a PortfolioState,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct StrategyDecision {
|
|
pub rebalance: bool,
|
|
pub target_weights: BTreeMap<String, f64>,
|
|
pub exit_symbols: BTreeSet<String>,
|
|
pub order_intents: Vec<OrderIntent>,
|
|
pub notes: Vec<String>,
|
|
pub diagnostics: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum OrderIntent {
|
|
TargetValue {
|
|
symbol: String,
|
|
target_value: f64,
|
|
reason: String,
|
|
},
|
|
Value {
|
|
symbol: String,
|
|
value: f64,
|
|
reason: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct CnSmallCapRotationConfig {
|
|
pub strategy_name: String,
|
|
pub refresh_rate: usize,
|
|
pub stocknum: usize,
|
|
pub xs: f64,
|
|
pub base_index_level: f64,
|
|
pub base_cap_floor: f64,
|
|
pub cap_span: f64,
|
|
pub short_ma_days: usize,
|
|
pub long_ma_days: usize,
|
|
pub stock_short_ma_days: usize,
|
|
pub stock_mid_ma_days: usize,
|
|
pub stock_long_ma_days: usize,
|
|
pub rsi_rate: f64,
|
|
pub trade_rate: f64,
|
|
pub stop_loss_pct: f64,
|
|
pub take_profit_pct: f64,
|
|
pub signal_symbol: Option<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,
|
|
});
|
|
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(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 stop_loss_tolerance(&self, market: &crate::data::DailyMarketSnapshot) -> f64 {
|
|
let _ = market;
|
|
0.0
|
|
}
|
|
|
|
fn buy_commission(&self, gross_amount: f64) -> f64 {
|
|
(gross_amount * 0.0003).max(5.0)
|
|
}
|
|
|
|
fn sell_cost(&self, gross_amount: f64) -> f64 {
|
|
(gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001)
|
|
}
|
|
|
|
fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 {
|
|
let lot = round_lot.max(1);
|
|
(quantity / lot) * lot
|
|
}
|
|
|
|
fn intraday_execution_start_time(&self) -> NaiveTime {
|
|
NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18")
|
|
}
|
|
|
|
fn projected_round_lot(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 {
|
|
ctx.data
|
|
.instrument(symbol)
|
|
.map(|instrument| instrument.effective_round_lot())
|
|
.unwrap_or(100)
|
|
.max(1)
|
|
}
|
|
|
|
fn projected_buy_quantity(&self, cash: f64, sizing_price: f64, execution_price: f64) -> u32 {
|
|
if cash <= 0.0 || sizing_price <= 0.0 || execution_price <= 0.0 {
|
|
return 0;
|
|
}
|
|
let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100);
|
|
while quantity > 0 {
|
|
let gross_amount = execution_price * quantity as f64;
|
|
let cash_out = gross_amount + self.buy_commission(gross_amount);
|
|
if cash_out <= cash + 1e-6 {
|
|
return quantity;
|
|
}
|
|
quantity = quantity.saturating_sub(100);
|
|
}
|
|
0
|
|
}
|
|
|
|
fn projected_execution_price(
|
|
&self,
|
|
market: &crate::data::DailyMarketSnapshot,
|
|
side: OrderSide,
|
|
) -> f64 {
|
|
let tick = market.effective_price_tick();
|
|
let base_price = market.price(PriceField::Last);
|
|
let adjusted = match side {
|
|
OrderSide::Buy => base_price + tick * 2.0,
|
|
OrderSide::Sell => base_price - tick,
|
|
};
|
|
let lower = if market.lower_limit.is_finite() && market.lower_limit > 0.0 {
|
|
market.lower_limit
|
|
} else {
|
|
tick
|
|
};
|
|
let upper = if market.upper_limit.is_finite() && market.upper_limit > 0.0 {
|
|
market.upper_limit
|
|
} else {
|
|
f64::INFINITY
|
|
};
|
|
adjusted.clamp(lower, upper)
|
|
}
|
|
|
|
fn project_order_value(
|
|
&self,
|
|
ctx: &StrategyContext<'_>,
|
|
projected: &mut PortfolioState,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
order_value: f64,
|
|
reason: &str,
|
|
execution_state: &mut ProjectedExecutionState,
|
|
) -> u32 {
|
|
if order_value <= 0.0 {
|
|
return 0;
|
|
}
|
|
let round_lot = self.projected_round_lot(ctx, symbol);
|
|
let market = match ctx.data.market(date, symbol) {
|
|
Some(market) => market,
|
|
None => return 0,
|
|
};
|
|
let sizing_price = market.price(PriceField::Last);
|
|
if !sizing_price.is_finite() || sizing_price <= 0.0 {
|
|
return 0;
|
|
}
|
|
let snapshot_requested_qty = self.round_lot_quantity(
|
|
((projected.cash().min(order_value)) / sizing_price).floor() as u32,
|
|
round_lot,
|
|
);
|
|
let projected_execution_price = self.projected_execution_price(market, OrderSide::Buy);
|
|
let mut projected_fill = self.projected_select_execution_fill(
|
|
ctx,
|
|
date,
|
|
symbol,
|
|
OrderSide::Buy,
|
|
u32::MAX,
|
|
round_lot,
|
|
Some(projected.cash()),
|
|
Some(order_value + 400.0),
|
|
execution_state,
|
|
);
|
|
let mut quantity = snapshot_requested_qty;
|
|
while quantity > 0 {
|
|
let gross_amount = projected_execution_price * quantity as f64;
|
|
let cash_out = gross_amount + self.buy_commission(gross_amount);
|
|
if gross_amount <= order_value + 400.0
|
|
&& cash_out <= projected.cash() + 1e-6
|
|
{
|
|
break;
|
|
}
|
|
quantity = quantity.saturating_sub(round_lot);
|
|
}
|
|
if quantity == 0 {
|
|
return 0;
|
|
}
|
|
let execution_price = projected_fill
|
|
.as_ref()
|
|
.map(|fill| fill.price)
|
|
.unwrap_or(projected_execution_price);
|
|
while quantity > 0 {
|
|
let gross_amount = execution_price * quantity as f64;
|
|
let cash_out = gross_amount + self.buy_commission(gross_amount);
|
|
if cash_out <= projected.cash() + 1e-6 {
|
|
break;
|
|
}
|
|
quantity = quantity.saturating_sub(round_lot);
|
|
}
|
|
if quantity == 0 {
|
|
return 0;
|
|
}
|
|
let fill = ProjectedExecutionFill {
|
|
price: execution_price,
|
|
quantity,
|
|
next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1),
|
|
};
|
|
let gross_amount = fill.price * fill.quantity as f64;
|
|
let cash_out = gross_amount + self.buy_commission(gross_amount);
|
|
if cash_out > projected.cash() + 1e-6 {
|
|
return 0;
|
|
}
|
|
projected.apply_cash_delta(-cash_out);
|
|
projected
|
|
.position_mut(symbol)
|
|
.buy(date, fill.quantity, fill.price);
|
|
*execution_state
|
|
.intraday_turnover
|
|
.entry(symbol.to_string())
|
|
.or_default() += fill.quantity;
|
|
execution_state
|
|
.execution_cursors
|
|
.insert(symbol.to_string(), fill.next_cursor);
|
|
if self.uses_serial_execution_cursor(reason) {
|
|
execution_state.global_execution_cursor = Some(fill.next_cursor);
|
|
}
|
|
fill.quantity
|
|
}
|
|
|
|
fn project_target_zero(
|
|
&self,
|
|
ctx: &StrategyContext<'_>,
|
|
projected: &mut PortfolioState,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
reason: &str,
|
|
execution_state: &mut ProjectedExecutionState,
|
|
) -> Option<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 fill = self
|
|
.projected_select_execution_fill(
|
|
ctx,
|
|
date,
|
|
symbol,
|
|
OrderSide::Sell,
|
|
quantity,
|
|
round_lot,
|
|
None,
|
|
None,
|
|
execution_state,
|
|
)
|
|
.unwrap_or(ProjectedExecutionFill {
|
|
price: self.projected_execution_price(market, OrderSide::Sell),
|
|
quantity,
|
|
next_cursor: date.and_time(self.intraday_execution_start_time())
|
|
+ Duration::seconds(1),
|
|
});
|
|
let gross_amount = fill.price * fill.quantity as f64;
|
|
let net_cash = gross_amount - self.sell_cost(gross_amount);
|
|
projected
|
|
.position_mut(symbol)
|
|
.sell(fill.quantity, fill.price)
|
|
.ok()?;
|
|
projected.apply_cash_delta(net_cash);
|
|
*execution_state
|
|
.intraday_turnover
|
|
.entry(symbol.to_string())
|
|
.or_default() += fill.quantity;
|
|
execution_state
|
|
.execution_cursors
|
|
.insert(symbol.to_string(), fill.next_cursor);
|
|
if self.uses_serial_execution_cursor(reason) {
|
|
execution_state.global_execution_cursor = Some(fill.next_cursor);
|
|
}
|
|
projected.prune_flat_positions();
|
|
Some(fill.quantity)
|
|
}
|
|
|
|
fn projected_market_fillable_quantity(
|
|
&self,
|
|
ctx: &StrategyContext<'_>,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
side: OrderSide,
|
|
requested_qty: u32,
|
|
round_lot: u32,
|
|
execution_state: &ProjectedExecutionState,
|
|
) -> Option<u32> {
|
|
if requested_qty == 0 {
|
|
return Some(0);
|
|
}
|
|
let snapshot = ctx.data.market(date, symbol)?;
|
|
if snapshot.tick_volume == 0 {
|
|
return None;
|
|
}
|
|
|
|
let lot = round_lot.max(1);
|
|
let mut max_fill = requested_qty;
|
|
let top_level_liquidity = match side {
|
|
OrderSide::Buy => snapshot.liquidity_for_buy(),
|
|
OrderSide::Sell => snapshot.liquidity_for_sell(),
|
|
}
|
|
.min(u32::MAX as u64) as u32;
|
|
if top_level_liquidity == 0 {
|
|
return None;
|
|
}
|
|
max_fill = max_fill.min(self.round_lot_quantity(top_level_liquidity, lot));
|
|
|
|
let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0);
|
|
let raw_limit =
|
|
((snapshot.tick_volume as f64) * 0.25).round() as i64 - consumed_turnover as i64;
|
|
if raw_limit <= 0 {
|
|
return None;
|
|
}
|
|
let volume_limited = self.round_lot_quantity(raw_limit as u32, lot);
|
|
if volume_limited == 0 {
|
|
return None;
|
|
}
|
|
Some(max_fill.min(volume_limited))
|
|
}
|
|
|
|
fn projected_execution_start_cursor(
|
|
&self,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
execution_state: &ProjectedExecutionState,
|
|
) -> Option<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,
|
|
cash_limit: Option<f64>,
|
|
gross_limit: Option<f64>,
|
|
execution_state: &ProjectedExecutionState,
|
|
) -> Option<ProjectedExecutionFill> {
|
|
if requested_qty == 0 {
|
|
return None;
|
|
}
|
|
|
|
let lot = round_lot.max(1);
|
|
let start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state);
|
|
let quotes = ctx.data.execution_quotes_on(date, symbol);
|
|
let mut filled_qty = 0_u32;
|
|
let mut gross_amount = 0.0_f64;
|
|
let mut last_timestamp = None;
|
|
let mut last_quote_price = None;
|
|
|
|
for quote in quotes {
|
|
if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) {
|
|
continue;
|
|
}
|
|
|
|
let fallback_quote_price = match side {
|
|
OrderSide::Buy => {
|
|
if quote.last_price.is_finite() && quote.last_price > 0.0 {
|
|
Some(quote.last_price)
|
|
} else {
|
|
quote.buy_price()
|
|
}
|
|
}
|
|
OrderSide::Sell => quote.sell_price(),
|
|
};
|
|
if fallback_quote_price.is_some() {
|
|
last_quote_price = fallback_quote_price;
|
|
last_timestamp = Some(quote.timestamp);
|
|
}
|
|
|
|
if quote.volume_delta == 0 {
|
|
continue;
|
|
}
|
|
let Some(quote_price) = fallback_quote_price else {
|
|
continue;
|
|
};
|
|
let available_qty = match side {
|
|
OrderSide::Buy => quote.ask1_volume,
|
|
OrderSide::Sell => quote.bid1_volume,
|
|
}
|
|
.saturating_mul(lot as u64)
|
|
.min(u32::MAX as u64) as u32;
|
|
if available_qty == 0 {
|
|
continue;
|
|
}
|
|
|
|
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
|
if remaining_qty == 0 {
|
|
break;
|
|
}
|
|
let mut take_qty = self.round_lot_quantity(remaining_qty.min(available_qty), lot);
|
|
if take_qty == 0 {
|
|
continue;
|
|
}
|
|
|
|
if let Some(cash) = cash_limit {
|
|
while take_qty > 0 {
|
|
let candidate_gross = gross_amount + quote_price * take_qty as f64;
|
|
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
|
|
take_qty = take_qty.saturating_sub(lot);
|
|
continue;
|
|
}
|
|
let candidate_cash_out = candidate_gross + self.buy_commission(candidate_gross);
|
|
if candidate_cash_out <= cash + 1e-6 {
|
|
break;
|
|
}
|
|
take_qty = take_qty.saturating_sub(lot);
|
|
}
|
|
if take_qty == 0 {
|
|
break;
|
|
}
|
|
}
|
|
|
|
gross_amount += quote_price * take_qty as f64;
|
|
filled_qty += take_qty;
|
|
last_timestamp = Some(quote.timestamp);
|
|
|
|
if filled_qty >= requested_qty {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if filled_qty < requested_qty {
|
|
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
|
let mut residual_qty = self.round_lot_quantity(remaining_qty, lot);
|
|
if residual_qty > 0 {
|
|
if let Some(residual_price) = last_quote_price {
|
|
if let Some(cash) = cash_limit {
|
|
while residual_qty > 0 {
|
|
let candidate_gross =
|
|
gross_amount + residual_price * residual_qty as f64;
|
|
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
|
|
residual_qty = residual_qty.saturating_sub(lot);
|
|
continue;
|
|
}
|
|
let candidate_cash_out =
|
|
candidate_gross + self.buy_commission(candidate_gross);
|
|
if candidate_cash_out <= cash + 1e-6 {
|
|
break;
|
|
}
|
|
residual_qty = residual_qty.saturating_sub(lot);
|
|
}
|
|
}
|
|
if residual_qty > 0 {
|
|
gross_amount += residual_price * residual_qty as f64;
|
|
filled_qty += residual_qty;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if filled_qty == 0 {
|
|
return None;
|
|
}
|
|
|
|
Some(ProjectedExecutionFill {
|
|
price: gross_amount / filled_qty as f64,
|
|
quantity: filled_qty,
|
|
next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
|
|
})
|
|
}
|
|
|
|
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
|
|
let _ = reason;
|
|
false
|
|
}
|
|
|
|
fn trading_ratio(
|
|
&self,
|
|
ctx: &StrategyContext<'_>,
|
|
date: NaiveDate,
|
|
) -> Result<(f64, f64, f64, f64), BacktestError> {
|
|
let current_level = ctx
|
|
.data
|
|
.market_decision_close(date, &self.config.benchmark_signal_symbol)
|
|
.ok_or_else(|| BacktestError::MissingPrice {
|
|
date,
|
|
symbol: self.config.benchmark_signal_symbol.clone(),
|
|
field: "decision_close",
|
|
})?;
|
|
let ma_short = ctx
|
|
.data
|
|
.market_decision_close_moving_average(
|
|
date,
|
|
&self.config.benchmark_signal_symbol,
|
|
self.config.benchmark_short_ma_days,
|
|
)
|
|
.ok_or_else(|| {
|
|
BacktestError::Execution(format!(
|
|
"insufficient benchmark short MA history for {} on {}",
|
|
self.config.benchmark_signal_symbol, date
|
|
))
|
|
})?;
|
|
let ma_long = ctx
|
|
.data
|
|
.market_decision_close_moving_average(
|
|
date,
|
|
&self.config.benchmark_signal_symbol,
|
|
self.config.benchmark_long_ma_days,
|
|
)
|
|
.ok_or_else(|| {
|
|
BacktestError::Execution(format!(
|
|
"insufficient benchmark long MA history for {} on {}",
|
|
self.config.benchmark_signal_symbol, date
|
|
))
|
|
})?;
|
|
let trading_ratio = if ma_short < ma_long * self.config.rsi_rate {
|
|
self.config.trade_rate
|
|
} else {
|
|
1.0
|
|
};
|
|
Ok((current_level, ma_short, ma_long, trading_ratio))
|
|
}
|
|
|
|
fn market_cap_band(&self, index_level: f64) -> (f64, f64) {
|
|
let y = (index_level - self.config.base_index_level) * self.config.xs
|
|
+ self.config.base_cap_floor;
|
|
let start = y.round();
|
|
(start, start + self.config.cap_span)
|
|
}
|
|
|
|
fn stock_passes_ma_filter(
|
|
&self,
|
|
ctx: &StrategyContext<'_>,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
) -> bool {
|
|
let Some(ma_short) = ctx.data.market_decision_close_moving_average(
|
|
date,
|
|
symbol,
|
|
self.config.stock_short_ma_days,
|
|
) else {
|
|
return false;
|
|
};
|
|
let Some(ma_mid) = ctx.data.market_decision_close_moving_average(
|
|
date,
|
|
symbol,
|
|
self.config.stock_mid_ma_days,
|
|
) else {
|
|
return false;
|
|
};
|
|
let Some(ma_long) = ctx.data.market_decision_close_moving_average(
|
|
date,
|
|
symbol,
|
|
self.config.stock_long_ma_days,
|
|
) else {
|
|
return false;
|
|
};
|
|
|
|
ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long
|
|
}
|
|
|
|
fn special_name(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool {
|
|
let instrument_name = ctx
|
|
.data
|
|
.instruments()
|
|
.get(symbol)
|
|
.map(|instrument| instrument.name.as_str())
|
|
.unwrap_or("");
|
|
instrument_name.contains("ST")
|
|
|| instrument_name.contains('*')
|
|
|| instrument_name.contains('退')
|
|
}
|
|
|
|
fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool {
|
|
let Some(position) = ctx.portfolio.position(symbol) else {
|
|
return false;
|
|
};
|
|
if position.quantity == 0 || position.sellable_qty(date) == 0 {
|
|
return false;
|
|
}
|
|
let Ok(market) = ctx.data.require_market(date, symbol) else {
|
|
return false;
|
|
};
|
|
let Ok(candidate) = ctx.data.require_candidate(date, symbol) else {
|
|
return false;
|
|
};
|
|
!(market.paused
|
|
|| candidate.is_paused
|
|
|| !candidate.allow_sell
|
|
|| market.is_at_lower_limit_price(market.sell_price(PriceField::Last)))
|
|
}
|
|
|
|
fn buy_rejection_reason(
|
|
&self,
|
|
ctx: &StrategyContext<'_>,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
) -> Result<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.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> {
|
|
let universe = ctx.data.eligible_universe_on(date);
|
|
let mut diagnostics = Vec::new();
|
|
let mut selected = Vec::new();
|
|
let start = lower_bound_eligible(universe, band_low);
|
|
|
|
for candidate in universe.iter().skip(start) {
|
|
if candidate.market_cap_bn > band_high {
|
|
break;
|
|
}
|
|
if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol)? {
|
|
if diagnostics.len() < 12 {
|
|
diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
selected.push(candidate.symbol.clone());
|
|
if selected.len() >= self.config.stocknum {
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok((selected, diagnostics))
|
|
}
|
|
}
|
|
|
|
impl Strategy for JqMicroCapStrategy {
|
|
fn name(&self) -> &str {
|
|
self.config.strategy_name.as_str()
|
|
}
|
|
|
|
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<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
|
|
}
|