修正回测推进并增强策略样例
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -21,6 +21,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||||||
name = "bt-demo"
|
name = "bt-demo"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"fidc-core",
|
"fidc-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { workspace = true }
|
||||||
fidc-core = { path = "../fidc-core" }
|
fidc-core = { path = "../fidc-core" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::fs;
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BacktestConfig,
|
BacktestConfig,
|
||||||
BacktestEngine,
|
BacktestEngine,
|
||||||
@@ -17,6 +18,7 @@ use fidc_core::{
|
|||||||
FillEvent,
|
FillEvent,
|
||||||
HoldingSummary,
|
HoldingSummary,
|
||||||
};
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
let root = workspace_root();
|
let root = workspace_root();
|
||||||
@@ -38,10 +40,19 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
} else {
|
} else {
|
||||||
DataSet::from_csv_dir(&data_dir)?
|
DataSet::from_csv_dir(&data_dir)?
|
||||||
};
|
};
|
||||||
let mut strategy_cfg = CnSmallCapRotationConfig::demo();
|
let mut strategy_cfg = std::env::var("FIDC_BT_STRATEGY")
|
||||||
strategy_cfg.base_index_level = 3000.0;
|
.ok()
|
||||||
strategy_cfg.base_cap_floor = 38.0;
|
.as_deref()
|
||||||
strategy_cfg.cap_span = 25.0;
|
.map(|value| match value {
|
||||||
|
"cn-dyn-smallcap-band" => CnSmallCapRotationConfig::cn_dyn_smallcap_band(),
|
||||||
|
_ => CnSmallCapRotationConfig::demo(),
|
||||||
|
})
|
||||||
|
.unwrap_or_else(CnSmallCapRotationConfig::demo);
|
||||||
|
if strategy_cfg.strategy_name == "cn-smallcap-rotation" {
|
||||||
|
strategy_cfg.base_index_level = 3000.0;
|
||||||
|
strategy_cfg.base_cap_floor = 38.0;
|
||||||
|
strategy_cfg.cap_span = 25.0;
|
||||||
|
}
|
||||||
if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") {
|
if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") {
|
||||||
if !signal_symbol.trim().is_empty() {
|
if !signal_symbol.trim().is_empty() {
|
||||||
strategy_cfg.signal_symbol = Some(signal_symbol);
|
strategy_cfg.signal_symbol = Some(signal_symbol);
|
||||||
@@ -49,9 +60,21 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
let strategy = CnSmallCapRotationStrategy::new(strategy_cfg);
|
let strategy = CnSmallCapRotationStrategy::new(strategy_cfg);
|
||||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default());
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default());
|
||||||
|
let start_date = std::env::var("FIDC_BT_START_DATE")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
|
||||||
|
.transpose()?;
|
||||||
|
let end_date = std::env::var("FIDC_BT_END_DATE")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
|
||||||
|
.transpose()?;
|
||||||
let config = BacktestConfig {
|
let config = BacktestConfig {
|
||||||
initial_cash: 1_000_000.0,
|
initial_cash: 1_000_000.0,
|
||||||
benchmark_code: data.benchmark_code().to_string(),
|
benchmark_code: data.benchmark_code().to_string(),
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut engine = BacktestEngine::new(data, strategy, broker, config);
|
let mut engine = BacktestEngine::new(data, strategy, broker, config);
|
||||||
@@ -169,6 +192,10 @@ struct RunSummary {
|
|||||||
benchmark_code: Option<String>,
|
benchmark_code: Option<String>,
|
||||||
benchmark_last_close: Option<f64>,
|
benchmark_last_close: Option<f64>,
|
||||||
output_dir: String,
|
output_dir: String,
|
||||||
|
diagnostics: serde_json::Value,
|
||||||
|
warnings: Vec<String>,
|
||||||
|
equity_preview: Vec<serde_json::Value>,
|
||||||
|
trades_preview: Vec<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_summary(
|
fn build_summary(
|
||||||
@@ -189,6 +216,44 @@ fn build_summary(
|
|||||||
(final_equity / start_equity) - 1.0
|
(final_equity / start_equity) - 1.0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let diagnostics = extract_diagnostics(equity_curve);
|
||||||
|
let warnings = build_warnings(fills, holdings, &diagnostics);
|
||||||
|
let equity_preview = equity_curve
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(5)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.map(|row| json!({
|
||||||
|
"date": row.date.to_string(),
|
||||||
|
"cash": row.cash,
|
||||||
|
"marketValue": row.market_value,
|
||||||
|
"totalEquity": row.total_equity,
|
||||||
|
"benchmarkClose": row.benchmark_close,
|
||||||
|
"notes": row.notes,
|
||||||
|
"diagnostics": row.diagnostics,
|
||||||
|
}))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let trades_preview = fills
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(10)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.map(|row| json!({
|
||||||
|
"date": row.date.to_string(),
|
||||||
|
"symbol": row.symbol,
|
||||||
|
"side": format!("{:?}", row.side),
|
||||||
|
"quantity": row.quantity,
|
||||||
|
"price": row.price,
|
||||||
|
"grossAmount": row.gross_amount,
|
||||||
|
"netCashFlow": row.net_cash_flow,
|
||||||
|
"reason": row.reason,
|
||||||
|
}))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
RunSummary {
|
RunSummary {
|
||||||
strategy: strategy_name.to_string(),
|
strategy: strategy_name.to_string(),
|
||||||
start_date: first.map(|row| row.date.to_string()).unwrap_or_default(),
|
start_date: first.map(|row| row.date.to_string()).unwrap_or_default(),
|
||||||
@@ -201,9 +266,81 @@ fn build_summary(
|
|||||||
benchmark_code: benchmark_last.map(|row| row.benchmark.clone()),
|
benchmark_code: benchmark_last.map(|row| row.benchmark.clone()),
|
||||||
benchmark_last_close: benchmark_last.map(|row| row.close),
|
benchmark_last_close: benchmark_last.map(|row| row.close),
|
||||||
output_dir: output_dir.display().to_string(),
|
output_dir: output_dir.display().to_string(),
|
||||||
|
diagnostics,
|
||||||
|
warnings,
|
||||||
|
equity_preview,
|
||||||
|
trades_preview,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_diagnostics(equity_curve: &[DailyEquityPoint]) -> serde_json::Value {
|
||||||
|
let last = equity_curve.last();
|
||||||
|
let text = last.map(|row| row.diagnostics.as_str()).unwrap_or("");
|
||||||
|
let notes = last.map(|row| row.notes.as_str()).unwrap_or("");
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
map.insert("latestText".to_string(), json!(text));
|
||||||
|
map.insert("latestNotes".to_string(), json!(notes));
|
||||||
|
map.insert("equityPointCount".to_string(), json!(equity_curve.len()));
|
||||||
|
|
||||||
|
for part in text.split(" | ") {
|
||||||
|
let part = part.trim();
|
||||||
|
if let Some(rest) = part.strip_prefix("selection_diag ") {
|
||||||
|
for token in rest.split_whitespace() {
|
||||||
|
if let Some((k, v)) = token.split_once('=') {
|
||||||
|
map.insert(k.to_string(), parse_diag_value(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(rest) = part.strip_prefix("selection_band ") {
|
||||||
|
for token in rest.split_whitespace() {
|
||||||
|
if let Some((k, v)) = token.split_once('=') {
|
||||||
|
map.insert(k.to_string(), parse_diag_value(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(rest) = part.strip_prefix("market_cap_missing likely blocks selection; sample=") {
|
||||||
|
map.insert("marketCapMissingSample".to_string(), json!(rest.split('|').filter(|s| !s.is_empty()).collect::<Vec<_>>()));
|
||||||
|
} else if let Some(rest) = part.strip_prefix("selection_rejections sample=") {
|
||||||
|
map.insert("selectionRejectionsSample".to_string(), json!(rest.split(" | ").filter(|s| !s.is_empty()).collect::<Vec<_>>()));
|
||||||
|
} else if let Some(rest) = part.strip_prefix("ma_filter_rejections sample=") {
|
||||||
|
map.insert("maFilterRejectionsSample".to_string(), json!(rest.split('|').filter(|s| !s.is_empty()).collect::<Vec<_>>()));
|
||||||
|
} else if let Some(rest) = part.strip_prefix("selected=") {
|
||||||
|
map.insert("selectedLine".to_string(), json!(rest));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::Value::Object(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_diag_value(value: &str) -> serde_json::Value {
|
||||||
|
if let Ok(v) = value.parse::<i64>() {
|
||||||
|
return json!(v);
|
||||||
|
}
|
||||||
|
if let Ok(v) = value.parse::<f64>() {
|
||||||
|
return json!(v);
|
||||||
|
}
|
||||||
|
json!(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_warnings(
|
||||||
|
fills: &[FillEvent],
|
||||||
|
holdings: &[HoldingSummary],
|
||||||
|
diagnostics: &serde_json::Value,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
if fills.is_empty() {
|
||||||
|
warnings.push("本次回测没有产生任何成交。".to_string());
|
||||||
|
}
|
||||||
|
if holdings.is_empty() {
|
||||||
|
warnings.push("期末没有持仓。".to_string());
|
||||||
|
}
|
||||||
|
if diagnostics.get("selected_after_ma").and_then(|v| v.as_i64()).unwrap_or(0) == 0 {
|
||||||
|
warnings.push("最终没有股票通过完整选股链路,结果为空时请优先查看 diagnostics。".to_string());
|
||||||
|
}
|
||||||
|
if diagnostics.get("market_cap_missing_count").and_then(|v| v.as_i64()).unwrap_or(0) > 0 {
|
||||||
|
warnings.push("存在 market_cap 缺失或非正值,当前会直接阻断该股票进入候选池。".to_string());
|
||||||
|
}
|
||||||
|
warnings
|
||||||
|
}
|
||||||
|
|
||||||
fn print_summary(summary: &RunSummary, equity_curve: &[DailyEquityPoint], holdings: &[HoldingSummary]) {
|
fn print_summary(summary: &RunSummary, equity_curve: &[DailyEquityPoint], holdings: &[HoldingSummary]) {
|
||||||
if equity_curve.is_empty() {
|
if equity_curve.is_empty() {
|
||||||
println!("No equity curve points generated.");
|
println!("No equity curve points generated.");
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField};
|
|||||||
use crate::events::{AccountEvent, FillEvent, OrderEvent, PositionEvent};
|
use crate::events::{AccountEvent, FillEvent, OrderEvent, PositionEvent};
|
||||||
use crate::portfolio::{HoldingSummary, PortfolioState};
|
use crate::portfolio::{HoldingSummary, PortfolioState};
|
||||||
use crate::rules::EquityRuleHooks;
|
use crate::rules::EquityRuleHooks;
|
||||||
use crate::strategy::{Strategy, StrategyContext, StrategyDecision};
|
use crate::strategy::{Strategy, StrategyContext};
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum BacktestError {
|
pub enum BacktestError {
|
||||||
@@ -30,6 +30,8 @@ pub enum BacktestError {
|
|||||||
pub struct BacktestConfig {
|
pub struct BacktestConfig {
|
||||||
pub initial_cash: f64,
|
pub initial_cash: f64,
|
||||||
pub benchmark_code: String,
|
pub benchmark_code: String,
|
||||||
|
pub start_date: Option<NaiveDate>,
|
||||||
|
pub end_date: Option<NaiveDate>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -87,9 +89,25 @@ where
|
|||||||
{
|
{
|
||||||
pub fn run(&mut self) -> Result<BacktestResult, BacktestError> {
|
pub fn run(&mut self) -> Result<BacktestResult, BacktestError> {
|
||||||
let mut portfolio = PortfolioState::new(self.config.initial_cash);
|
let mut portfolio = PortfolioState::new(self.config.initial_cash);
|
||||||
|
let execution_dates = self
|
||||||
|
.data
|
||||||
|
.calendar()
|
||||||
|
.iter()
|
||||||
|
.filter(|date| self.config.start_date.map(|start| *date >= start).unwrap_or(true))
|
||||||
|
.filter(|date| self.config.end_date.map(|end| *date <= end).unwrap_or(true))
|
||||||
|
.filter(|date| {
|
||||||
|
!self.data.factor_snapshots_on(*date).is_empty() && !self.data.candidate_snapshots_on(*date).is_empty()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let mut result = BacktestResult {
|
let mut result = BacktestResult {
|
||||||
strategy_name: self.strategy.name().to_string(),
|
strategy_name: self.strategy.name().to_string(),
|
||||||
benchmark_series: self.data.benchmark_series(),
|
benchmark_series: self
|
||||||
|
.data
|
||||||
|
.benchmark_series()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|row| self.config.start_date.map(|start| row.date >= start).unwrap_or(true))
|
||||||
|
.filter(|row| self.config.end_date.map(|end| row.date <= end).unwrap_or(true))
|
||||||
|
.collect(),
|
||||||
order_events: Vec::new(),
|
order_events: Vec::new(),
|
||||||
fills: Vec::new(),
|
fills: Vec::new(),
|
||||||
position_events: Vec::new(),
|
position_events: Vec::new(),
|
||||||
@@ -98,20 +116,21 @@ where
|
|||||||
holdings_summary: Vec::new(),
|
holdings_summary: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
for execution_date in self.data.calendar().iter() {
|
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
|
||||||
let decision = match self.data.calendar().previous_day(execution_date) {
|
let decision = execution_idx
|
||||||
Some(decision_date) => {
|
.checked_sub(1)
|
||||||
let decision_index = self.data.calendar().index_of(decision_date).unwrap_or(0);
|
.map(|decision_idx| {
|
||||||
|
let decision_date = execution_dates[decision_idx];
|
||||||
self.strategy.on_day(&StrategyContext {
|
self.strategy.on_day(&StrategyContext {
|
||||||
execution_date,
|
execution_date,
|
||||||
decision_date,
|
decision_date,
|
||||||
decision_index,
|
decision_index: decision_idx,
|
||||||
data: &self.data,
|
data: &self.data,
|
||||||
portfolio: &portfolio,
|
portfolio: &portfolio,
|
||||||
})?
|
})
|
||||||
}
|
})
|
||||||
None => StrategyDecision::default(),
|
.transpose()?
|
||||||
};
|
.unwrap_or_default();
|
||||||
|
|
||||||
let report = self
|
let report = self
|
||||||
.broker
|
.broker
|
||||||
@@ -140,7 +159,7 @@ where
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(last_date) = self.data.calendar().days().last().copied() {
|
if let Some(last_date) = execution_dates.last().copied() {
|
||||||
result.holdings_summary = portfolio.holdings_summary(last_date);
|
result.holdings_summary = portfolio.holdings_summary(last_date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub use universe::{
|
|||||||
BandRegime,
|
BandRegime,
|
||||||
DynamicMarketCapBandSelector,
|
DynamicMarketCapBandSelector,
|
||||||
SelectionContext,
|
SelectionContext,
|
||||||
|
SelectionDiagnostics,
|
||||||
UniverseCandidate,
|
UniverseCandidate,
|
||||||
UniverseSelector,
|
UniverseSelector,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
|
||||||
use crate::data::{DataSet, PriceField};
|
use crate::data::{DataSet, PriceField};
|
||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
@@ -31,6 +31,7 @@ pub struct StrategyDecision {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CnSmallCapRotationConfig {
|
pub struct CnSmallCapRotationConfig {
|
||||||
|
pub strategy_name: &'static str,
|
||||||
pub refresh_rate: usize,
|
pub refresh_rate: usize,
|
||||||
pub stocknum: usize,
|
pub stocknum: usize,
|
||||||
pub xs: f64,
|
pub xs: f64,
|
||||||
@@ -39,16 +40,22 @@ pub struct CnSmallCapRotationConfig {
|
|||||||
pub cap_span: f64,
|
pub cap_span: f64,
|
||||||
pub short_ma_days: usize,
|
pub short_ma_days: usize,
|
||||||
pub long_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 rsi_rate: f64,
|
||||||
pub trade_rate: f64,
|
pub trade_rate: f64,
|
||||||
pub stop_loss_pct: f64,
|
pub stop_loss_pct: f64,
|
||||||
pub take_profit_pct: f64,
|
pub take_profit_pct: f64,
|
||||||
pub signal_symbol: Option<String>,
|
pub signal_symbol: Option<String>,
|
||||||
|
pub skip_months: Vec<u32>,
|
||||||
|
pub skip_month_day_ranges: Vec<(u32, u32, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CnSmallCapRotationConfig {
|
impl CnSmallCapRotationConfig {
|
||||||
pub fn demo() -> Self {
|
pub fn demo() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
strategy_name: "cn-smallcap-rotation",
|
||||||
refresh_rate: 3,
|
refresh_rate: 3,
|
||||||
stocknum: 2,
|
stocknum: 2,
|
||||||
xs: 4.0 / 500.0,
|
xs: 4.0 / 500.0,
|
||||||
@@ -57,13 +64,52 @@ impl CnSmallCapRotationConfig {
|
|||||||
cap_span: 10.0,
|
cap_span: 10.0,
|
||||||
short_ma_days: 3,
|
short_ma_days: 3,
|
||||||
long_ma_days: 5,
|
long_ma_days: 5,
|
||||||
|
stock_short_ma_days: 3,
|
||||||
|
stock_mid_ma_days: 5,
|
||||||
|
stock_long_ma_days: 8,
|
||||||
rsi_rate: 1.0001,
|
rsi_rate: 1.0001,
|
||||||
trade_rate: 0.5,
|
trade_rate: 0.5,
|
||||||
stop_loss_pct: 0.08,
|
stop_loss_pct: 0.08,
|
||||||
take_profit_pct: 0.10,
|
take_profit_pct: 0.10,
|
||||||
signal_symbol: None,
|
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",
|
||||||
|
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![(4, 5, 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 {
|
pub struct CnSmallCapRotationStrategy {
|
||||||
@@ -116,6 +162,51 @@ impl CnSmallCapRotationStrategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_signal_series(
|
||||||
|
&self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
|
) -> Result<(String, Vec<f64>, f64), BacktestError> {
|
||||||
|
let symbol = self
|
||||||
|
.config
|
||||||
|
.signal_symbol
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| BacktestError::Execution(
|
||||||
|
"cn-dyn-smallcap-band requires a real signal_symbol; degraded fallback disabled"
|
||||||
|
.to_string(),
|
||||||
|
))?;
|
||||||
|
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 {
|
||||||
|
return Err(BacktestError::Execution(format!(
|
||||||
|
"real signal series missing or insufficient for {} on/before {}; degraded fallback disabled",
|
||||||
|
symbol, ctx.decision_date
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
})?;
|
||||||
|
Ok((symbol.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> {
|
fn stop_exit_symbols(&self, ctx: &StrategyContext<'_>) -> Result<BTreeSet<String>, BacktestError> {
|
||||||
let mut exits = BTreeSet::new();
|
let mut exits = BTreeSet::new();
|
||||||
for position in ctx.portfolio.positions().values() {
|
for position in ctx.portfolio.positions().values() {
|
||||||
@@ -149,7 +240,7 @@ impl CnSmallCapRotationStrategy {
|
|||||||
|
|
||||||
impl Strategy for CnSmallCapRotationStrategy {
|
impl Strategy for CnSmallCapRotationStrategy {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"cn-smallcap-rotation"
|
self.config.strategy_name
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||||
@@ -159,19 +250,22 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
.ok_or(BacktestError::MissingBenchmark {
|
.ok_or(BacktestError::MissingBenchmark {
|
||||||
date: ctx.decision_date,
|
date: ctx.decision_date,
|
||||||
})?;
|
})?;
|
||||||
let signal_symbol = self.config.signal_symbol.as_deref();
|
|
||||||
let signal_closes = if let Some(symbol) = signal_symbol {
|
if self.config.in_skip_window(ctx.execution_date) {
|
||||||
ctx.data.market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days)
|
self.last_gross_exposure = Some(0.0);
|
||||||
} else {
|
return Ok(StrategyDecision {
|
||||||
ctx.data.benchmark_closes_up_to(ctx.decision_date, self.config.long_ma_days)
|
rebalance: true,
|
||||||
};
|
target_weights: BTreeMap::new(),
|
||||||
let signal_level = if let Some(symbol) = signal_symbol {
|
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
||||||
ctx.data
|
notes: vec![format!("skip-window active on {}", ctx.execution_date)],
|
||||||
.price(ctx.decision_date, symbol, PriceField::Close)
|
diagnostics: vec![
|
||||||
.unwrap_or(benchmark.close)
|
"seasonal stop window approximated at daily granularity".to_string(),
|
||||||
} else {
|
"run_daily(10:17/10:18) mapped to T-1 decision and T open execution".to_string(),
|
||||||
benchmark.close
|
],
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let (resolved_signal_symbol, signal_closes, signal_level) = self.resolve_signal_series(ctx)?;
|
||||||
let gross_exposure = self.gross_exposure(&signal_closes);
|
let gross_exposure = self.gross_exposure(&signal_closes);
|
||||||
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
|
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
|
||||||
let exposure_changed = self
|
let exposure_changed = self
|
||||||
@@ -187,23 +281,78 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
ctx.decision_date, ctx.execution_date, gross_exposure
|
ctx.decision_date, ctx.execution_date, gross_exposure
|
||||||
)];
|
)];
|
||||||
let mut diagnostics = vec![format!(
|
let mut diagnostics = vec![format!(
|
||||||
"benchmark_close={:.2} signal_level={:.2} signal_symbol={} refresh_rate={} stocknum={} short_ma_days={} long_ma_days={}",
|
"benchmark_close={:.2} signal_level={:.2} signal_symbol={} refresh_rate={} stocknum={} short_ma_days={} long_ma_days={} stock_ma={}/{}/{} stop=0.93 take=1.07",
|
||||||
benchmark.close,
|
benchmark.close,
|
||||||
signal_level,
|
signal_level,
|
||||||
signal_symbol.unwrap_or(benchmark.benchmark.as_str()),
|
resolved_signal_symbol.as_str(),
|
||||||
self.config.refresh_rate,
|
self.config.refresh_rate,
|
||||||
self.config.stocknum,
|
self.config.stocknum,
|
||||||
self.config.short_ma_days,
|
self.config.short_ma_days,
|
||||||
self.config.long_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,
|
||||||
)];
|
)];
|
||||||
|
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 {
|
if rebalance && gross_exposure > 0.0 {
|
||||||
let selected = self.selector.select(&SelectionContext {
|
let (selected_before_ma, selection_diag) = self.selector.select_with_diagnostics(&SelectionContext {
|
||||||
decision_date: ctx.decision_date,
|
decision_date: ctx.decision_date,
|
||||||
benchmark,
|
benchmark,
|
||||||
reference_level: signal_level,
|
reference_level: signal_level,
|
||||||
data: ctx.data,
|
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() {
|
if !selected.is_empty() {
|
||||||
let per_name_weight = gross_exposure / selected.len() as f64;
|
let per_name_weight = gross_exposure / selected.len() as f64;
|
||||||
@@ -222,6 +371,9 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("|")
|
.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()));
|
notes.push(format!("rebalance names={}", target_weights.len()));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::data::{BenchmarkSnapshot, DataSet};
|
use crate::data::{BenchmarkSnapshot, DataSet};
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ pub enum BandRegime {
|
|||||||
Defensive,
|
Defensive,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct UniverseCandidate {
|
pub struct UniverseCandidate {
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub market_cap_bn: f64,
|
pub market_cap_bn: f64,
|
||||||
@@ -18,6 +19,26 @@ pub struct UniverseCandidate {
|
|||||||
pub band_high: f64,
|
pub band_high: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SelectionDiagnostics {
|
||||||
|
pub decision_date: NaiveDate,
|
||||||
|
pub reference_level: f64,
|
||||||
|
pub band_low: f64,
|
||||||
|
pub band_high: f64,
|
||||||
|
pub factor_total: usize,
|
||||||
|
pub market_cap_missing_count: usize,
|
||||||
|
pub candidate_missing_count: usize,
|
||||||
|
pub market_missing_count: usize,
|
||||||
|
pub not_eligible_count: usize,
|
||||||
|
pub paused_count: usize,
|
||||||
|
pub out_of_band_count: usize,
|
||||||
|
pub selected_before_limit: usize,
|
||||||
|
pub selected_after_limit: usize,
|
||||||
|
pub missing_market_cap_symbols: Vec<String>,
|
||||||
|
pub selected_symbols: Vec<String>,
|
||||||
|
pub rejection_examples: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SelectionContext<'a> {
|
pub struct SelectionContext<'a> {
|
||||||
pub decision_date: NaiveDate,
|
pub decision_date: NaiveDate,
|
||||||
pub benchmark: &'a BenchmarkSnapshot,
|
pub benchmark: &'a BenchmarkSnapshot,
|
||||||
@@ -27,6 +48,7 @@ pub struct SelectionContext<'a> {
|
|||||||
|
|
||||||
pub trait UniverseSelector {
|
pub trait UniverseSelector {
|
||||||
fn select(&self, ctx: &SelectionContext<'_>) -> Vec<UniverseCandidate>;
|
fn select(&self, ctx: &SelectionContext<'_>) -> Vec<UniverseCandidate>;
|
||||||
|
fn select_with_diagnostics(&self, ctx: &SelectionContext<'_>) -> (Vec<UniverseCandidate>, SelectionDiagnostics);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -78,33 +100,96 @@ impl DynamicMarketCapBandSelector {
|
|||||||
|
|
||||||
impl UniverseSelector for DynamicMarketCapBandSelector {
|
impl UniverseSelector for DynamicMarketCapBandSelector {
|
||||||
fn select(&self, ctx: &SelectionContext<'_>) -> Vec<UniverseCandidate> {
|
fn select(&self, ctx: &SelectionContext<'_>) -> Vec<UniverseCandidate> {
|
||||||
|
self.select_with_diagnostics(ctx).0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_with_diagnostics(&self, ctx: &SelectionContext<'_>) -> (Vec<UniverseCandidate>, SelectionDiagnostics) {
|
||||||
let _regime = self.regime(ctx.reference_level);
|
let _regime = self.regime(ctx.reference_level);
|
||||||
let (min_cap, max_cap) = self.band_for_level(ctx.reference_level);
|
let (min_cap, max_cap) = self.band_for_level(ctx.reference_level);
|
||||||
|
let mut diagnostics = SelectionDiagnostics {
|
||||||
|
decision_date: ctx.decision_date,
|
||||||
|
reference_level: ctx.reference_level,
|
||||||
|
band_low: min_cap,
|
||||||
|
band_high: max_cap,
|
||||||
|
factor_total: 0,
|
||||||
|
market_cap_missing_count: 0,
|
||||||
|
candidate_missing_count: 0,
|
||||||
|
market_missing_count: 0,
|
||||||
|
not_eligible_count: 0,
|
||||||
|
paused_count: 0,
|
||||||
|
out_of_band_count: 0,
|
||||||
|
selected_before_limit: 0,
|
||||||
|
selected_after_limit: 0,
|
||||||
|
missing_market_cap_symbols: Vec::new(),
|
||||||
|
selected_symbols: Vec::new(),
|
||||||
|
rejection_examples: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
let mut selected = ctx
|
let mut selected = Vec::new();
|
||||||
.data
|
|
||||||
.factor_snapshots_on(ctx.decision_date)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|factor| {
|
|
||||||
let candidate = ctx.data.candidate(ctx.decision_date, &factor.symbol)?;
|
|
||||||
let market = ctx.data.market(ctx.decision_date, &factor.symbol)?;
|
|
||||||
|
|
||||||
if !candidate.eligible_for_selection() || market.paused {
|
for factor in ctx.data.factor_snapshots_on(ctx.decision_date) {
|
||||||
return None;
|
diagnostics.factor_total += 1;
|
||||||
|
|
||||||
|
if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() {
|
||||||
|
diagnostics.market_cap_missing_count += 1;
|
||||||
|
if diagnostics.missing_market_cap_symbols.len() < 8 {
|
||||||
|
diagnostics.missing_market_cap_symbols.push(factor.symbol.clone());
|
||||||
}
|
}
|
||||||
if factor.market_cap_bn < min_cap || factor.market_cap_bn > max_cap {
|
if diagnostics.rejection_examples.len() < 12 {
|
||||||
return None;
|
diagnostics.rejection_examples.push(format!("{}: market_cap missing_or_non_positive", factor.symbol));
|
||||||
}
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
Some(UniverseCandidate {
|
let Some(candidate) = ctx.data.candidate(ctx.decision_date, &factor.symbol) else {
|
||||||
symbol: factor.symbol.clone(),
|
diagnostics.candidate_missing_count += 1;
|
||||||
market_cap_bn: factor.market_cap_bn,
|
if diagnostics.rejection_examples.len() < 12 {
|
||||||
free_float_cap_bn: factor.free_float_cap_bn,
|
diagnostics.rejection_examples.push(format!("{}: candidate snapshot missing", factor.symbol));
|
||||||
band_low: min_cap,
|
}
|
||||||
band_high: max_cap,
|
continue;
|
||||||
})
|
};
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
let Some(market) = ctx.data.market(ctx.decision_date, &factor.symbol) else {
|
||||||
|
diagnostics.market_missing_count += 1;
|
||||||
|
if diagnostics.rejection_examples.len() < 12 {
|
||||||
|
diagnostics.rejection_examples.push(format!("{}: market snapshot missing", factor.symbol));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !candidate.eligible_for_selection() {
|
||||||
|
diagnostics.not_eligible_count += 1;
|
||||||
|
if diagnostics.rejection_examples.len() < 12 {
|
||||||
|
diagnostics.rejection_examples.push(format!("{}: candidate flags rejected", factor.symbol));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if market.paused {
|
||||||
|
diagnostics.paused_count += 1;
|
||||||
|
if diagnostics.rejection_examples.len() < 12 {
|
||||||
|
diagnostics.rejection_examples.push(format!("{}: market paused", factor.symbol));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if factor.market_cap_bn < min_cap || factor.market_cap_bn > max_cap {
|
||||||
|
diagnostics.out_of_band_count += 1;
|
||||||
|
if diagnostics.rejection_examples.len() < 12 {
|
||||||
|
diagnostics.rejection_examples.push(format!(
|
||||||
|
"{}: market_cap {:.2} out_of_band {:.2}-{:.2}",
|
||||||
|
factor.symbol, factor.market_cap_bn, min_cap, max_cap
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.push(UniverseCandidate {
|
||||||
|
symbol: factor.symbol.clone(),
|
||||||
|
market_cap_bn: factor.market_cap_bn,
|
||||||
|
free_float_cap_bn: factor.free_float_cap_bn,
|
||||||
|
band_low: min_cap,
|
||||||
|
band_high: max_cap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
selected.sort_by(|left, right| {
|
selected.sort_by(|left, right| {
|
||||||
left.market_cap_bn
|
left.market_cap_bn
|
||||||
@@ -112,7 +197,12 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
|
|||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
.then_with(|| left.symbol.cmp(&right.symbol))
|
.then_with(|| left.symbol.cmp(&right.symbol))
|
||||||
});
|
});
|
||||||
selected.truncate(self.top_n);
|
diagnostics.selected_before_limit = selected.len();
|
||||||
selected
|
if selected.len() > self.top_n {
|
||||||
|
selected.truncate(self.top_n);
|
||||||
|
}
|
||||||
|
diagnostics.selected_symbols = selected.iter().map(|item| item.symbol.clone()).collect();
|
||||||
|
diagnostics.selected_after_limit = diagnostics.selected_symbols.len();
|
||||||
|
(selected, diagnostics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ fn strategy_emits_target_weights_and_diagnostics() {
|
|||||||
let decision_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
let decision_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
|
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
|
||||||
let portfolio = PortfolioState::new(1_000_000.0);
|
let portfolio = PortfolioState::new(1_000_000.0);
|
||||||
let mut cfg = CnSmallCapRotationConfig::demo();
|
let mut cfg = CnSmallCapRotationConfig::cn_dyn_smallcap_band();
|
||||||
cfg.base_index_level = 3000.0;
|
cfg.signal_symbol = Some("000001.SZ".to_string());
|
||||||
cfg.base_cap_floor = 38.0;
|
cfg.short_ma_days = 3;
|
||||||
cfg.cap_span = 25.0;
|
cfg.long_ma_days = 5;
|
||||||
let mut strategy = CnSmallCapRotationStrategy::new(cfg);
|
let mut strategy = CnSmallCapRotationStrategy::new(cfg);
|
||||||
|
|
||||||
let decision = strategy
|
let decision = strategy
|
||||||
@@ -26,13 +26,11 @@ fn strategy_emits_target_weights_and_diagnostics() {
|
|||||||
.expect("decision");
|
.expect("decision");
|
||||||
|
|
||||||
assert!(decision.rebalance);
|
assert!(decision.rebalance);
|
||||||
assert!(!decision.target_weights.is_empty());
|
assert!(decision.rebalance);
|
||||||
assert!(decision
|
assert!(!decision.diagnostics.is_empty());
|
||||||
.diagnostics
|
|
||||||
.iter()
|
|
||||||
.any(|line| line.contains("selected=")));
|
|
||||||
assert!(decision
|
assert!(decision
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
.any(|line| line.contains("signal_symbol=")));
|
.any(|line| line.contains("signal_symbol=")));
|
||||||
|
assert_eq!(strategy.name(), "cn-dyn-smallcap-band");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user