Improve jq microcap execution semantics

This commit is contained in:
boris
2026-04-18 18:02:50 +08:00
parent 9f4165e689
commit 0e2c25e4c4
26 changed files with 5058 additions and 362 deletions

25
Cargo.lock generated
View File

@@ -69,11 +69,18 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "fidc-core" name = "fidc-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"indexmap",
"serde", "serde",
"thiserror", "thiserror",
] ]
@@ -84,6 +91,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.65" version = "0.1.65"
@@ -108,6 +121,18 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "indexmap"
version = "2.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"

View File

@@ -13,5 +13,6 @@ authors = ["OpenAI Codex"]
[workspace.dependencies] [workspace.dependencies]
chrono = { version = "=0.4.44", features = ["serde"] } chrono = { version = "=0.4.44", features = ["serde"] }
indexmap = { version = "=2.11.4", features = ["serde"] }
serde = { version = "=1.0.228", features = ["derive"] } serde = { version = "=1.0.228", features = ["derive"] }
thiserror = "=2.0.18" thiserror = "=2.0.18"

View File

@@ -4,15 +4,18 @@
## 当前能力 ## 当前能力
- Phase 2:增加 snapshot bundle 视图与更贴近 jqdata 策略语义的动态市值带策略 - Phase 3:增加预索引数据层、可配置决策/执行语义,以及更贴近聚宽微盘股脚本的 native 策略
- 日频交易日历与确定性逐日回放 - 日频交易日历与确定性逐日回放
- A 股日频市场快照、估值/因子快照、基准快照、候选资格标记 - A 股日频市场快照、估值/因子快照、基准快照、候选资格标记
- 策略接口与引擎驱动,不直接模拟 `jqdata` API - 策略接口与引擎驱动,不直接模拟 `jqdata` API
- `BacktestConfig` 支持 `decision_lag_trading_days``execution_price_field(open/close/last)`
- `DailyMarketSnapshot` 支持 `day_open` / `last_price`
- Universe 选择器:按指数位置动态切换市值带,再取最小市值 Top-N - Universe 选择器:按指数位置动态切换市值带,再取最小市值 Top-N
- 风险节流:基于指数均线状态切换 100% / 50% / 0% 仓位 - 风险节流:基于指数均线状态切换 100% / 50% / 0% 仓位
- Broker Simulator按次日开盘价撮合支持手续费、印花税、最小佣金 - Broker Simulator按次日开盘价撮合支持手续费、印花税、最小佣金
- 中国 A 股规则钩子T+1、停牌、涨停不可买、跌停不可卖 - 中国 A 股规则钩子T+1、停牌、涨停不可买、跌停不可卖
- 回测输出:权益曲线、成交记录、期末持仓摘要 - 回测输出:权益曲线、成交记录、期末持仓摘要
- 新增 `JqMicroCapStrategy`覆盖动态市值带、停运窗口、1 元股 / ST / 科创板过滤、均线过滤、止损止盈、固定频率再平衡
- `cargo run --bin bt-demo` 可直接运行仓库内置 demo 数据 - `cargo run --bin bt-demo` 可直接运行仓库内置 demo 数据
## Workspace 布局 ## Workspace 布局
@@ -43,12 +46,12 @@
- `calendar`: 交易日历和滚动窗口工具,负责日频迭代和均线 lookback。 - `calendar`: 交易日历和滚动窗口工具,负责日频迭代和均线 lookback。
- `instrument`: 证券静态定义。 - `instrument`: 证券静态定义。
- `data`: 日频市场、因子、基准、候选资格数据模型与 CSV loader。 - `data`: 日频市场、因子、基准、候选资格数据模型与 CSV loader;内部预建 symbol 级价格前缀和、按日预排序 eligible universe
- `universe`: 动态市值带 Universe Selector。 - `universe`: 动态市值带 Universe Selector。
- `portfolio`: 现金、持仓、FIFO lots、T+1 可卖数量、盈亏汇总。 - `portfolio`: 现金、持仓、FIFO lots、T+1 可卖数量、盈亏汇总。
- `rules`: 中国股票规则钩子隔离停牌、涨跌停、T+1 检查。 - `rules`: 中国股票规则钩子隔离停牌、涨跌停、T+1 检查。
- `cost`: 佣金、印花税、最低佣金模型。 - `cost`: 佣金、印花税、最低佣金模型。
- `broker`: 目标权重到订单执行的模拟器,先卖后买,买单按 100 股向下取整 - `broker`: 同时支持“目标权重再平衡”和显式 `order_target_value / order_value` 订单意图,买单按 100 股向下取整;执行价可选 `open / close / last`
- `strategy`: 引擎驱动的策略 trait 与具体策略实现。 - `strategy`: 引擎驱动的策略 trait 与具体策略实现。
- `engine`: 确定性的逐日回测循环和结果收集。 - `engine`: 确定性的逐日回测循环和结果收集。
@@ -81,6 +84,25 @@
这更接近平台化引擎需要的“策略意图”和“执行语义”分离。 这更接近平台化引擎需要的“策略意图”和“执行语义”分离。
新增的 `JqMicroCapStrategy` 更直接对齐 `/聚宽微盘股策略.py`
1. 指数信号使用 `benchmark_signal_symbol` 的同日 `last_price`
2. 市值带按 `round((index_level - base_index_level) * xs + base_cap_floor)` 动态计算。
3. 在预排序后的 eligible universe 上做带内截取,避免每个交易日全表扫描。
4. 叠加脚本中的盘中规则:
- 涨停开盘 / 跌停开盘
- 当前涨停 / 当前跌停
- 停牌 / ST / 名称含退 / 科创板
- 1 元股
- 个股 5/10/20 日均线过滤
5. 止损/止盈与固定 15 日再平衡同时工作。
6. 当前实现将 `run_daily(10:17/10:18)` 近似为“同日快照决策 + `last_price` 执行”,比传统 `T-1 -> T open` 更接近原脚本。
7. 执行层不再只做目标权重映射,而是支持更接近原脚本的显式订单链路:
- `order_target_value(symbol, 0)` 风格清仓
- `order_value(symbol, cash)` 风格补仓
- 止损/止盈后按剩余现金和剩余槽位补首个可买标的
- 定期调仓时先卖出池外持仓,再按固定现金分配逐笔买入
## 与原始 jqdata 策略族的映射 ## 与原始 jqdata 策略族的映射
如果原始逻辑大致是: 如果原始逻辑大致是:
@@ -97,7 +119,7 @@
- `get_price` / `history` -> `DailyMarketSnapshot` + `BenchmarkSnapshot` - `get_price` / `history` -> `DailyMarketSnapshot` + `BenchmarkSnapshot`
- `set_benchmark` -> `BacktestConfig.benchmark_code` - `set_benchmark` -> `BacktestConfig.benchmark_code`
- `filter_paused` / `filter_st` / 新股过滤 -> `CandidateEligibility` - `filter_paused` / `filter_st` / 新股过滤 -> `CandidateEligibility`
- `order_target_value` -> `StrategyDecision.target_weights``BrokerSimulator` 解释执行 - `order_target_value` / `order_value` -> `StrategyDecision.order_intents``BrokerSimulator` 顺序解释执行
- 风险控制逻辑 -> `CnSmallCapRotationStrategy::gross_exposure` - 风险控制逻辑 -> `CnSmallCapRotationStrategy::gross_exposure`
## Phase 2 新增内容 ## Phase 2 新增内容
@@ -108,16 +130,25 @@
- 候选资格快照扩展:补入 `is_kcb``is_one_yuan` - 候选资格快照扩展:补入 `is_kcb``is_one_yuan`
- 增加策略选择行为测试 - 增加策略选择行为测试
## V1 / V2 当前仍保留的简化点 ## Phase 3 新增内容
- `DataSet` 新增 symbol 级价格前缀和,均线查询变为 O(1)
- `DynamicMarketCapBandSelector` 新增预排序 eligible universe + 二分带内截取
- `BrokerSimulator` 新增 `execution_price_field`
- `BacktestEngine` 新增 `decision_lag_trading_days`
- 新增 `JqMicroCapStrategy` 和对应测试
- `StrategyDecision` / `BrokerSimulator` 新增显式订单意图,开始覆盖 `order_target_value / order_value` 语义
## 当前仍保留的简化点
下面这些是刻意保留为 v1 简化,而不是遗漏: 下面这些是刻意保留为 v1 简化,而不是遗漏:
- 只支持日频,不做分钟级、集合竞价、盘中撮合 - 只支持日频 snapshot不直接做逐笔 tick 回放
- 决策基于 `T-1` 收盘后可见数据,在 `T` 开盘价执行 - `JqMicroCapStrategy` 已支持同日 `last_price` 决策/执行,但这仍然是 snapshot 近似,不是盘口逐笔成交
- 不模拟盘口排队、成交量约束和滑点模型,成交默认按开盘价完成。 - 不模拟盘口排队、成交量约束和滑点模型,成交默认按开盘价完成。
- 买单按 100 股整手向下取整,卖单允许按实际持仓数量退出。 - 买单按 100 股整手向下取整,卖单允许按实际持仓数量退出。
- 未处理复权、分红送转、融资融券、可转债、科创板/北交所差异规则。 - 未处理复权、分红送转、融资融券、可转债、科创板/北交所差异规则。
- 止损止盈基于上一交易日收盘价相对持仓成本触发,下一交易日开盘执行 - 止损止盈仍然是 snapshot 驱动,不是逐笔止损链
这些简化都在代码结构上留了扩展位,不会阻断后续升级到更完整的执行层。 这些简化都在代码结构上留了扩展位,不会阻断后续升级到更完整的执行层。
@@ -129,6 +160,14 @@
cargo run --bin bt-demo cargo run --bin bt-demo
``` ```
运行更贴近聚宽微盘股脚本的策略:
```bash
FIDC_BT_STRATEGY=jq-microcap \
FIDC_BT_SIGNAL_SYMBOL=000001.SH \
cargo run --release --bin bt-demo
```
如果要接更接近真实数据面的按日分区 snapshot 目录: 如果要接更接近真实数据面的按日分区 snapshot 目录:
```bash ```bash
@@ -154,7 +193,7 @@ snapshots/
``` ```
其中: 其中:
- `market/`:日级行情快照,可显式携带 `upper_limit/lower_limit` - `market/`:日级行情快照,可显式携带 `upper_limit/lower_limit/day_open/last_price`
- `factors/`:估值/因子快照,可扩展 `turnover_ratio/effective_turnover_ratio` - `factors/`:估值/因子快照,可扩展 `turnover_ratio/effective_turnover_ratio`
- `candidates/`:候选资格/过滤标记快照 - `candidates/`:候选资格/过滤标记快照
- `benchmark/`:业绩基准指数快照 - `benchmark/`:业绩基准指数快照
@@ -192,12 +231,14 @@ cargo build
- broker 做持仓差量执行 - broker 做持仓差量执行
- 不把查询逻辑塞进策略内部,避免回测时频繁回源数据层。 - 不把查询逻辑塞进策略内部,避免回测时频繁回源数据层。
如果未来把日频因子、资格标记、可交易标记和开/收盘价全部预计算到列式存储再按日期分块读入内存6 年全市场回测在 5 分钟是合理目标,原因是: 如果未来把日频因子、资格标记、可交易标记和 `day_open / last_price / high_limit / low_limit` 全部预计算到列式存储再按日期分块读入内存6 年全市场回测在分钟是合理目标,原因是:
- 回测时不再做昂贵的 SQL join - 回测时不再做昂贵的 SQL join
- 因子筛选可直接消费预先物化的 snapshot - 因子筛选可直接消费预先物化并排序的 snapshot
- 组合调仓只关心“目标持仓”和“当前持仓”的差量 - 组合调仓只关心“目标持仓”和“当前持仓”的差量
- 事件流是 append-only适合批量写出和后处理分析 - 事件流是 append-only适合批量写出和后处理分析
- 均线查询通过 prefix sums 变成 O(1)
- 市值带选股通过预排序 universe + 二分定位变成 O(log N + K)
## 距离真实 6 年 / 5 分钟平台还差什么 ## 距离真实 6 年 / 5 分钟平台还差什么

View File

@@ -3,20 +3,12 @@ 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 chrono::{NaiveDate, NaiveTime};
use fidc_core::{ use fidc_core::{
BacktestConfig, BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, ChinaAShareCostModel,
BacktestEngine, ChinaEquityRuleHooks, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DailyEquityPoint,
BenchmarkSnapshot, DataSet, FillEvent, HoldingSummary, JqMicroCapConfig, JqMicroCapStrategy, PortfolioState,
BrokerSimulator, PriceField, Strategy, StrategyContext,
ChinaAShareCostModel,
ChinaEquityRuleHooks,
CnSmallCapRotationConfig,
CnSmallCapRotationStrategy,
DataSet,
DailyEquityPoint,
FillEvent,
HoldingSummary,
}; };
use serde_json::json; use serde_json::json;
@@ -40,26 +32,27 @@ fn main() -> Result<(), Box<dyn Error>> {
} else { } else {
DataSet::from_csv_dir(&data_dir)? DataSet::from_csv_dir(&data_dir)?
}; };
let mut strategy_cfg = std::env::var("FIDC_BT_STRATEGY") let strategy_name =
std::env::var("FIDC_BT_STRATEGY").unwrap_or_else(|_| "cn-smallcap-rotation".to_string());
let debug_date = std::env::var("FIDC_BT_DEBUG_DATE")
.ok() .ok()
.as_deref() .filter(|value| !value.trim().is_empty())
.map(|value| match value { .map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
"cn-dyn-smallcap-band" => CnSmallCapRotationConfig::cn_dyn_smallcap_band(), .transpose()?;
_ => CnSmallCapRotationConfig::demo(), let decision_lag = std::env::var("FIDC_BT_DECISION_LAG")
}) .ok()
.unwrap_or_else(CnSmallCapRotationConfig::demo); .and_then(|value| value.parse::<usize>().ok());
if strategy_cfg.strategy_name == "cn-smallcap-rotation" { let execution_price =
strategy_cfg.base_index_level = 3000.0; std::env::var("FIDC_BT_EXECUTION_PRICE")
strategy_cfg.base_cap_floor = 38.0; .ok()
strategy_cfg.cap_span = 25.0; .map(|value| match value.as_str() {
} "close" => PriceField::Close,
if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") { "last" => PriceField::Last,
if !signal_symbol.trim().is_empty() { _ => PriceField::Open,
strategy_cfg.signal_symbol = Some(signal_symbol); });
} let initial_cash = std::env::var("FIDC_BT_INITIAL_CASH")
} .ok()
let strategy = CnSmallCapRotationStrategy::new(strategy_cfg); .and_then(|value| value.parse::<f64>().ok());
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default());
let start_date = std::env::var("FIDC_BT_START_DATE") let start_date = std::env::var("FIDC_BT_START_DATE")
.ok() .ok()
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
@@ -70,19 +63,97 @@ fn main() -> Result<(), Box<dyn Error>> {
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d")) .map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
.transpose()?; .transpose()?;
let config = BacktestConfig { let mut config = BacktestConfig {
initial_cash: 1_000_000.0, initial_cash: initial_cash.unwrap_or(1_000_000.0),
benchmark_code: data.benchmark_code().to_string(), benchmark_code: data.benchmark_code().to_string(),
start_date, start_date,
end_date, end_date,
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
}; };
let result = match strategy_name.as_str() {
"cn-smallcap-rotation" | "cn-dyn-smallcap-band" => {
let mut strategy_cfg = if strategy_name == "cn-dyn-smallcap-band" {
CnSmallCapRotationConfig::cn_dyn_smallcap_band()
} 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 !signal_symbol.trim().is_empty() {
strategy_cfg.signal_symbol = Some(signal_symbol);
}
}
config.decision_lag_trading_days = decision_lag.unwrap_or(1);
config.execution_price_field = execution_price.unwrap_or(PriceField::Open);
let strategy = CnSmallCapRotationStrategy::new(strategy_cfg);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
config.execution_price_field,
);
let mut engine = BacktestEngine::new(data, strategy, broker, config); let mut engine = BacktestEngine::new(data, strategy, broker, config);
let result = engine.run()?; engine.run()?
}
_ => {
let mut strategy_cfg = JqMicroCapConfig::jq_microcap();
if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") {
if !signal_symbol.trim().is_empty() {
strategy_cfg.benchmark_signal_symbol = signal_symbol;
}
}
if let Some(date) = debug_date {
let eligible = data.eligible_universe_on(date);
eprintln!(
"DEBUG eligible_universe_on {} count={}",
date,
eligible.len()
);
for row in eligible.iter().take(20) {
eprintln!(" {} {:.6}", row.symbol, row.market_cap_bn);
}
let mut debug_strategy = JqMicroCapStrategy::new(strategy_cfg.clone());
let decision = debug_strategy.on_day(&StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 1,
data: &data,
portfolio: &PortfolioState::new(10_000_000.0),
})?;
eprintln!("DEBUG notes={:?}", decision.notes);
eprintln!("DEBUG diagnostics={:?}", decision.diagnostics);
return Ok(());
}
config.decision_lag_trading_days = decision_lag.unwrap_or(0);
config.execution_price_field = execution_price.unwrap_or(PriceField::Last);
config.initial_cash = initial_cash.unwrap_or(10_000_000.0);
let strategy = JqMicroCapStrategy::new(strategy_cfg);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
config.execution_price_field,
)
.with_intraday_execution_start_time(
NaiveTime::parse_from_str("10:18:00", "%H:%M:%S").expect("valid 10:18:00"),
)
.with_volume_limit(false)
.with_inactive_limit(false)
.with_liquidity_limit(false);
let mut engine = BacktestEngine::new(data, strategy, broker, config);
engine.run()?
}
};
write_equity_curve_csv(&output_dir.join("equity_curve.csv"), &result.equity_curve)?; write_equity_curve_csv(&output_dir.join("equity_curve.csv"), &result.equity_curve)?;
write_trades_csv(&output_dir.join("trades.csv"), &result.fills)?; write_trades_csv(&output_dir.join("trades.csv"), &result.fills)?;
write_holdings_csv(&output_dir.join("holdings_summary.csv"), &result.holdings_summary)?; write_holdings_csv(
&output_dir.join("holdings_summary.csv"),
&result.holdings_summary,
)?;
let summary = build_summary( let summary = build_summary(
&result.strategy_name, &result.strategy_name,
@@ -110,7 +181,10 @@ fn workspace_root() -> PathBuf {
fn write_equity_curve_csv(path: &Path, rows: &[DailyEquityPoint]) -> Result<(), Box<dyn Error>> { fn write_equity_curve_csv(path: &Path, rows: &[DailyEquityPoint]) -> Result<(), Box<dyn Error>> {
let mut file = fs::File::create(path)?; let mut file = fs::File::create(path)?;
writeln!(file, "date,cash,market_value,total_equity,benchmark_close,notes,diagnostics")?; writeln!(
file,
"date,cash,market_value,total_equity,benchmark_close,notes,diagnostics"
)?;
for row in rows { for row in rows {
writeln!( writeln!(
file, file,
@@ -225,7 +299,8 @@ fn build_summary(
.collect::<Vec<_>>() .collect::<Vec<_>>()
.into_iter() .into_iter()
.rev() .rev()
.map(|row| json!({ .map(|row| {
json!({
"date": row.date.to_string(), "date": row.date.to_string(),
"cash": row.cash, "cash": row.cash,
"marketValue": row.market_value, "marketValue": row.market_value,
@@ -233,7 +308,8 @@ fn build_summary(
"benchmarkClose": row.benchmark_close, "benchmarkClose": row.benchmark_close,
"notes": row.notes, "notes": row.notes,
"diagnostics": row.diagnostics, "diagnostics": row.diagnostics,
})) })
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let trades_preview = fills let trades_preview = fills
.iter() .iter()
@@ -242,7 +318,8 @@ fn build_summary(
.collect::<Vec<_>>() .collect::<Vec<_>>()
.into_iter() .into_iter()
.rev() .rev()
.map(|row| json!({ .map(|row| {
json!({
"date": row.date.to_string(), "date": row.date.to_string(),
"symbol": row.symbol, "symbol": row.symbol,
"side": format!("{:?}", row.side), "side": format!("{:?}", row.side),
@@ -251,7 +328,8 @@ fn build_summary(
"grossAmount": row.gross_amount, "grossAmount": row.gross_amount,
"netCashFlow": row.net_cash_flow, "netCashFlow": row.net_cash_flow,
"reason": row.reason, "reason": row.reason,
})) })
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
RunSummary { RunSummary {
@@ -296,12 +374,35 @@ fn extract_diagnostics(equity_curve: &[DailyEquityPoint]) -> serde_json::Value {
map.insert(k.to_string(), parse_diag_value(v)); map.insert(k.to_string(), parse_diag_value(v));
} }
} }
} else if let Some(rest) = part.strip_prefix("market_cap_missing likely blocks selection; sample=") { } else if let Some(rest) =
map.insert("marketCapMissingSample".to_string(), json!(rest.split('|').filter(|s| !s.is_empty()).collect::<Vec<_>>())); 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=") { } 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<_>>())); 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=") { } 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<_>>())); map.insert(
"maFilterRejectionsSample".to_string(),
json!(
rest.split('|')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
),
);
} else if let Some(rest) = part.strip_prefix("selected=") { } else if let Some(rest) = part.strip_prefix("selected=") {
map.insert("selectedLine".to_string(), json!(rest)); map.insert("selectedLine".to_string(), json!(rest));
} }
@@ -332,16 +433,31 @@ fn build_warnings(
if holdings.is_empty() { if holdings.is_empty() {
warnings.push("期末没有持仓。".to_string()); warnings.push("期末没有持仓。".to_string());
} }
if diagnostics.get("selected_after_ma").and_then(|v| v.as_i64()).unwrap_or(0) == 0 { if diagnostics
warnings.push("最终没有股票通过完整选股链路,结果为空时请优先查看 diagnostics。".to_string()); .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 { if diagnostics
.get("market_cap_missing_count")
.and_then(|v| v.as_i64())
.unwrap_or(0)
> 0
{
warnings.push("存在 market_cap 缺失或非正值,当前会直接阻断该股票进入候选池。".to_string()); warnings.push("存在 market_cap 缺失或非正值,当前会直接阻断该股票进入候选池。".to_string());
} }
warnings 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.");
return; return;
@@ -359,7 +475,14 @@ fn print_summary(summary: &RunSummary, equity_curve: &[DailyEquityPoint], holdin
} }
println!("Recent equity points:"); println!("Recent equity points:");
for point in equity_curve.iter().rev().take(3).collect::<Vec<_>>().into_iter().rev() { for point in equity_curve
.iter()
.rev()
.take(3)
.collect::<Vec<_>>()
.into_iter()
.rev()
{
println!( println!(
" {} equity {:.2} cash {:.2} mv {:.2}", " {} equity {:.2} cash {:.2} mv {:.2}",
point.date, point.total_equity, point.cash, point.market_value point.date, point.total_equity, point.cash, point.market_value

View File

@@ -7,5 +7,6 @@ authors.workspace = true
[dependencies] [dependencies]
chrono.workspace = true chrono.workspace = true
indexmap.workspace = true
serde.workspace = true serde.workspace = true
thiserror.workspace = true thiserror.workspace = true

View File

@@ -1,14 +1,14 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate; use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::cost::CostModel; use crate::cost::CostModel;
use crate::data::{DataSet, PriceField}; use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
use crate::rules::EquityRuleHooks; use crate::rules::EquityRuleHooks;
use crate::strategy::StrategyDecision; use crate::strategy::{OrderIntent, StrategyDecision};
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct BrokerExecutionReport { pub struct BrokerExecutionReport {
@@ -18,10 +18,23 @@ pub struct BrokerExecutionReport {
pub account_events: Vec<AccountEvent>, pub account_events: Vec<AccountEvent>,
} }
#[derive(Debug, Clone, Copy)]
struct ExecutionFill {
price: f64,
quantity: u32,
next_cursor: NaiveDateTime,
}
pub struct BrokerSimulator<C, R> { pub struct BrokerSimulator<C, R> {
cost_model: C, cost_model: C,
rules: R, rules: R,
board_lot_size: u32, board_lot_size: u32,
execution_price_field: PriceField,
volume_percent: f64,
volume_limit: bool,
inactive_limit: bool,
liquidity_limit: bool,
intraday_execution_start_time: Option<NaiveTime>,
} }
impl<C, R> BrokerSimulator<C, R> { impl<C, R> BrokerSimulator<C, R> {
@@ -30,8 +43,57 @@ impl<C, R> BrokerSimulator<C, R> {
cost_model, cost_model,
rules, rules,
board_lot_size: 100, board_lot_size: 100,
execution_price_field: PriceField::Open,
volume_percent: 0.25,
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
intraday_execution_start_time: None,
} }
} }
pub fn new_with_execution_price(
cost_model: C,
rules: R,
execution_price_field: PriceField,
) -> Self {
Self {
cost_model,
rules,
board_lot_size: 100,
execution_price_field,
volume_percent: 0.25,
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
intraday_execution_start_time: None,
}
}
pub fn with_volume_limit(mut self, enabled: bool) -> Self {
self.volume_limit = enabled;
self
}
pub fn with_inactive_limit(mut self, enabled: bool) -> Self {
self.inactive_limit = enabled;
self
}
pub fn with_liquidity_limit(mut self, enabled: bool) -> Self {
self.liquidity_limit = enabled;
self
}
pub fn with_volume_percent(mut self, volume_percent: f64) -> Self {
self.volume_percent = volume_percent;
self
}
pub fn with_intraday_execution_start_time(mut self, start_time: NaiveTime) -> Self {
self.intraday_execution_start_time = Some(start_time);
self
}
} }
impl<C, R> BrokerSimulator<C, R> impl<C, R> BrokerSimulator<C, R>
@@ -39,6 +101,18 @@ where
C: CostModel, C: CostModel,
R: EquityRuleHooks, R: EquityRuleHooks,
{ {
fn buy_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.buy_price(self.execution_price_field)
}
fn sell_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.sell_price(self.execution_price_field)
}
fn sizing_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.price(self.execution_price_field)
}
pub fn execute( pub fn execute(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -47,6 +121,26 @@ where
decision: &StrategyDecision, decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> { ) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default(); let mut report = BrokerExecutionReport::default();
let mut intraday_turnover = BTreeMap::<String, u32>::new();
let mut execution_cursors = BTreeMap::<String, NaiveDateTime>::new();
let mut global_execution_cursor = None::<NaiveDateTime>;
if !decision.order_intents.is_empty() {
for intent in &decision.order_intents {
self.process_order_intent(
date,
portfolio,
data,
intent,
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut report,
)?;
}
portfolio.prune_flat_positions();
return Ok(report);
}
let target_quantities = if decision.rebalance { let target_quantities = if decision.rebalance {
self.target_quantities(date, portfolio, data, &decision.target_weights)? self.target_quantities(date, portfolio, data, &decision.target_weights)?
} else { } else {
@@ -59,7 +153,10 @@ where
sell_symbols.extend(target_quantities.keys().cloned()); sell_symbols.extend(target_quantities.keys().cloned());
for symbol in sell_symbols { for symbol in sell_symbols {
let current_qty = portfolio.position(&symbol).map(|pos| pos.quantity).unwrap_or(0); let current_qty = portfolio
.position(&symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
if current_qty == 0 { if current_qty == 0 {
continue; continue;
} }
@@ -81,6 +178,9 @@ where
&symbol, &symbol,
requested_qty, requested_qty,
sell_reason(decision, &symbol), sell_reason(decision, &symbol),
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut report, &mut report,
)?; )?;
} }
@@ -88,7 +188,10 @@ where
if decision.rebalance { if decision.rebalance {
for (symbol, target_qty) in target_quantities { for (symbol, target_qty) in target_quantities {
let current_qty = portfolio.position(&symbol).map(|pos| pos.quantity).unwrap_or(0); let current_qty = portfolio
.position(&symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
if target_qty > current_qty { if target_qty > current_qty {
let requested_qty = target_qty - current_qty; let requested_qty = target_qty - current_qty;
self.process_buy( self.process_buy(
@@ -98,6 +201,10 @@ where
&symbol, &symbol,
requested_qty, requested_qty,
"rebalance_buy", "rebalance_buy",
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
None,
&mut report, &mut report,
)?; )?;
} }
@@ -108,6 +215,53 @@ where
Ok(report) Ok(report)
} }
fn process_order_intent(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
intent: &OrderIntent,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
match intent {
OrderIntent::TargetValue {
symbol,
target_value,
reason,
} => self.process_target_value(
date,
portfolio,
data,
symbol,
*target_value,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
report,
),
OrderIntent::Value {
symbol,
value,
reason,
} => self.process_value(
date,
portfolio,
data,
symbol,
*value,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
report,
),
}
}
fn target_quantities( fn target_quantities(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -120,14 +274,14 @@ where
for (symbol, weight) in target_weights { for (symbol, weight) in target_weights {
let price = data let price = data
.price(date, symbol, PriceField::Open) .price(date, symbol, self.execution_price_field)
.ok_or_else(|| BacktestError::MissingPrice { .ok_or_else(|| BacktestError::MissingPrice {
date, date,
symbol: symbol.clone(), symbol: symbol.clone(),
field: "open", field: price_field_name(self.execution_price_field),
})?; })?;
let raw_qty = ((equity * weight) / price).floor() as u32; let raw_qty = ((equity * weight) / price).floor() as u32;
let rounded_qty = self.round_buy_quantity(raw_qty); let rounded_qty = self.round_buy_quantity(raw_qty, self.round_lot(data, symbol));
targets.insert(symbol.clone(), rounded_qty); targets.insert(symbol.clone(), rounded_qty);
} }
@@ -142,6 +296,9 @@ where
symbol: &str, symbol: &str,
requested_qty: u32, requested_qty: u32,
reason: &str, reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
report: &mut BrokerExecutionReport, report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?; let snapshot = data.require_market(date, symbol)?;
@@ -150,8 +307,43 @@ where
return Ok(()); return Ok(());
}; };
let rule = self.rules.can_sell(date, snapshot, candidate, position); let rule = self.rules.can_sell(
date,
snapshot,
candidate,
position,
self.execution_price_field,
);
if !rule.allowed { if !rule.allowed {
let status = match rule.reason.as_deref() {
Some("paused")
| Some("sell disabled by eligibility flags")
| Some("open at or below lower limit") => OrderStatus::Canceled,
_ => OrderStatus::Rejected,
};
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status,
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
});
return Ok(());
}
let sellable = position.sellable_qty(date);
let market_limited_qty = self.market_fillable_quantity(
snapshot,
OrderSide::Sell,
requested_qty.min(sellable),
self.round_lot(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
);
let filled_qty = match market_limited_qty {
Ok(quantity) => quantity.min(sellable),
Err(limit_reason) => {
report.order_events.push(OrderEvent { report.order_events.push(OrderEvent {
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -159,13 +351,11 @@ where
requested_quantity: requested_qty, requested_quantity: requested_qty,
filled_quantity: 0, filled_quantity: 0,
status: OrderStatus::Rejected, status: OrderStatus::Rejected,
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()), reason: format!("{reason}: {limit_reason}"),
}); });
return Ok(()); return Ok(());
} }
};
let sellable = position.sellable_qty(date);
let filled_qty = requested_qty.min(sellable);
if filled_qty == 0 { if filled_qty == 0 {
report.order_events.push(OrderEvent { report.order_events.push(OrderEvent {
date, date,
@@ -180,15 +370,42 @@ where
} }
let cash_before = portfolio.cash(); let cash_before = portfolio.cash();
let gross_amount = snapshot.open * filled_qty as f64; let fill = self.resolve_execution_fill(
date,
symbol,
OrderSide::Sell,
snapshot,
data,
filled_qty,
self.round_lot(data, symbol),
execution_cursors,
None,
None,
None,
);
let (filled_qty, execution_price) = if let Some(fill) = fill {
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
if self.uses_serial_execution_cursor(reason) {
*global_execution_cursor = Some(fill.next_cursor);
}
(fill.quantity, fill.price)
} else {
(
filled_qty,
self.sell_price(snapshot),
)
};
let gross_amount = execution_price * filled_qty as f64;
let cost = self.cost_model.calculate(OrderSide::Sell, gross_amount); let cost = self.cost_model.calculate(OrderSide::Sell, gross_amount);
let net_cash = gross_amount - cost.total(); let net_cash = gross_amount - cost.total();
let realized_pnl = portfolio let realized_pnl = portfolio
.position_mut(symbol) .position_mut(symbol)
.sell(filled_qty, snapshot.open) .sell(filled_qty, execution_price)
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
portfolio.apply_cash_delta(net_cash); portfolio.apply_cash_delta(net_cash);
portfolio.prune_flat_positions();
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
let status = if filled_qty < requested_qty { let status = if filled_qty < requested_qty {
OrderStatus::PartiallyFilled OrderStatus::PartiallyFilled
@@ -210,7 +427,7 @@ where
symbol: symbol.to_string(), symbol: symbol.to_string(),
side: OrderSide::Sell, side: OrderSide::Sell,
quantity: filled_qty, quantity: filled_qty,
price: snapshot.open, price: execution_price,
gross_amount, gross_amount,
commission: cost.commission, commission: cost.commission,
stamp_tax: cost.stamp_tax, stamp_tax: cost.stamp_tax,
@@ -221,7 +438,10 @@ where
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
delta_quantity: -(filled_qty as i32), delta_quantity: -(filled_qty as i32),
quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0), quantity_after: portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0),
average_cost: portfolio average_cost: portfolio
.position(symbol) .position(symbol)
.map(|pos| pos.average_cost) .map(|pos| pos.average_cost)
@@ -239,6 +459,139 @@ where
Ok(()) Ok(())
} }
fn process_target_value(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
target_value: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let price = data
.market(date, symbol)
.map(|snapshot| self.sizing_price(snapshot))
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: price_field_name(self.execution_price_field),
})?;
let current_qty = portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let current_value = price * current_qty as f64;
let target_qty = self.round_buy_quantity(
((target_value.max(0.0)) / price).floor() as u32,
self.round_lot(data, symbol),
);
if current_qty > target_qty {
self.process_sell(
date,
portfolio,
data,
symbol,
current_qty - target_qty,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
report,
)?;
} else if target_qty > current_qty {
self.process_buy(
date,
portfolio,
data,
symbol,
target_qty - current_qty,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
None,
report,
)?;
} else if (current_value - target_value).abs() <= f64::EPSILON {
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: if current_qty > 0 {
OrderSide::Sell
} else {
OrderSide::Buy
},
requested_quantity: 0,
filled_quantity: 0,
status: OrderStatus::Filled,
reason: format!("{reason}: already at target value"),
});
}
Ok(())
}
fn process_value(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
value: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
if value.abs() <= f64::EPSILON {
return Ok(());
}
let snapshot = data
.market(date, symbol)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: price_field_name(self.execution_price_field),
})?;
let price = self.sizing_price(snapshot);
let requested_qty =
self.round_buy_quantity(((value.abs()) / price).floor() as u32, self.round_lot(data, symbol));
if value > 0.0 {
self.process_buy(
date,
portfolio,
data,
symbol,
requested_qty,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
Some(value.abs()),
report,
)
} else {
self.process_sell(
date,
portfolio,
data,
symbol,
requested_qty,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
report,
)
}
}
fn process_buy( fn process_buy(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -247,12 +600,18 @@ where
symbol: &str, symbol: &str,
requested_qty: u32, requested_qty: u32,
reason: &str, reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
value_budget: Option<f64>,
report: &mut BrokerExecutionReport, report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?; let snapshot = data.require_market(date, symbol)?;
let candidate = data.require_candidate(date, symbol)?; let candidate = data.require_candidate(date, symbol)?;
let rule = self.rules.can_buy(date, snapshot, candidate); let rule = self
.rules
.can_buy(date, snapshot, candidate, self.execution_price_field);
if !rule.allowed { if !rule.allowed {
report.order_events.push(OrderEvent { report.order_events.push(OrderEvent {
date, date,
@@ -266,8 +625,59 @@ where
return Ok(()); return Ok(());
} }
let filled_qty = let market_limited_qty = self.market_fillable_quantity(
self.affordable_buy_quantity(portfolio.cash(), snapshot.open, requested_qty); snapshot,
OrderSide::Buy,
requested_qty,
self.round_lot(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
);
let constrained_qty = match market_limited_qty {
Ok(quantity) => quantity,
Err(limit_reason) => {
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: {limit_reason}"),
});
return Ok(());
}
};
let fill = self.resolve_execution_fill(
date,
symbol,
OrderSide::Buy,
snapshot,
data,
constrained_qty,
self.round_lot(data, symbol),
execution_cursors,
None,
Some(portfolio.cash()),
value_budget,
);
let (filled_qty, execution_price) = if let Some(fill) = fill {
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
if self.uses_serial_execution_cursor(reason) {
*global_execution_cursor = Some(fill.next_cursor);
}
(fill.quantity, fill.price)
} else {
let execution_price = self.buy_price(snapshot);
let filled_qty = self.affordable_buy_quantity(
portfolio.cash(),
value_budget,
execution_price,
constrained_qty,
self.round_lot(data, symbol),
);
(filled_qty, execution_price)
};
if filled_qty == 0 { if filled_qty == 0 {
report.order_events.push(OrderEvent { report.order_events.push(OrderEvent {
date, date,
@@ -282,12 +692,15 @@ where
} }
let cash_before = portfolio.cash(); let cash_before = portfolio.cash();
let gross_amount = snapshot.open * filled_qty as f64; let gross_amount = execution_price * filled_qty as f64;
let cost = self.cost_model.calculate(OrderSide::Buy, gross_amount); let cost = self.cost_model.calculate(OrderSide::Buy, gross_amount);
let cash_out = gross_amount + cost.total(); let cash_out = gross_amount + cost.total();
portfolio.apply_cash_delta(-cash_out); portfolio.apply_cash_delta(-cash_out);
portfolio.position_mut(symbol).buy(date, filled_qty, snapshot.open); portfolio
.position_mut(symbol)
.buy(date, filled_qty, execution_price);
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
let status = if filled_qty < requested_qty { let status = if filled_qty < requested_qty {
OrderStatus::PartiallyFilled OrderStatus::PartiallyFilled
@@ -309,7 +722,7 @@ where
symbol: symbol.to_string(), symbol: symbol.to_string(),
side: OrderSide::Buy, side: OrderSide::Buy,
quantity: filled_qty, quantity: filled_qty,
price: snapshot.open, price: execution_price,
gross_amount, gross_amount,
commission: cost.commission, commission: cost.commission,
stamp_tax: cost.stamp_tax, stamp_tax: cost.stamp_tax,
@@ -320,7 +733,10 @@ where
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
delta_quantity: filled_qty as i32, delta_quantity: filled_qty as i32,
quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0), quantity_after: portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0),
average_cost: portfolio average_cost: portfolio
.position(symbol) .position(symbol)
.map(|pos| pos.average_cost) .map(|pos| pos.average_cost)
@@ -347,15 +763,16 @@ where
) -> Result<f64, BacktestError> { ) -> Result<f64, BacktestError> {
let mut market_value = 0.0; let mut market_value = 0.0;
for position in portfolio.positions().values() { for position in portfolio.positions().values() {
let price = data let price = data.price(date, &position.symbol, field).ok_or_else(|| {
.price(date, &position.symbol, field) BacktestError::MissingPrice {
.ok_or_else(|| BacktestError::MissingPrice {
date, date,
symbol: position.symbol.clone(), symbol: position.symbol.clone(),
field: match field { field: match field {
PriceField::Open => "open", PriceField::Open => "open",
PriceField::Close => "close", PriceField::Close => "close",
PriceField::Last => "last",
}, },
}
})?; })?;
market_value += price * position.quantity as f64; market_value += price * position.quantity as f64;
} }
@@ -363,22 +780,287 @@ where
Ok(portfolio.cash() + market_value) Ok(portfolio.cash() + market_value)
} }
fn round_buy_quantity(&self, quantity: u32) -> u32 { fn round_lot(&self, data: &DataSet, symbol: &str) -> u32 {
(quantity / self.board_lot_size) * self.board_lot_size data.instruments()
.get(symbol)
.map(|instrument| instrument.effective_round_lot())
.unwrap_or(self.board_lot_size.max(1))
} }
fn affordable_buy_quantity(&self, cash: f64, price: f64, requested_qty: u32) -> u32 { fn round_buy_quantity(&self, quantity: u32, round_lot: u32) -> u32 {
let mut quantity = self.round_buy_quantity(requested_qty); let lot = round_lot.max(1);
(quantity / lot) * lot
}
fn affordable_buy_quantity(
&self,
cash: f64,
gross_limit: Option<f64>,
price: f64,
requested_qty: u32,
round_lot: u32,
) -> u32 {
let lot = round_lot.max(1);
let mut quantity = self.round_buy_quantity(requested_qty, lot);
while quantity > 0 { while quantity > 0 {
let gross = price * quantity as f64; let gross = price * quantity as f64;
if gross_limit.is_some_and(|limit| gross > limit + 1e-6) {
quantity = quantity.saturating_sub(lot);
continue;
}
let cost = self.cost_model.calculate(OrderSide::Buy, gross); let cost = self.cost_model.calculate(OrderSide::Buy, gross);
if gross + cost.total() <= cash + 1e-6 { if gross + cost.total() <= cash + 1e-6 {
return quantity; return quantity;
} }
quantity = quantity.saturating_sub(self.board_lot_size); quantity = quantity.saturating_sub(lot);
} }
0 0
} }
fn market_fillable_quantity(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
requested_qty: u32,
round_lot: u32,
consumed_turnover: u32,
) -> Result<u32, String> {
if requested_qty == 0 {
return Ok(0);
}
if self.inactive_limit && snapshot.tick_volume == 0 {
return Err("tick no volume".to_string());
}
let mut max_fill = requested_qty;
let lot = round_lot.max(1);
if self.liquidity_limit {
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 Err("no quote liquidity".to_string());
}
max_fill = max_fill.min(self.round_buy_quantity(top_level_liquidity, lot));
}
if self.volume_limit {
let raw_limit =
((snapshot.tick_volume as f64) * self.volume_percent).round() as i64
- consumed_turnover as i64;
if raw_limit <= 0 {
return Err("tick volume limit".to_string());
}
let volume_limited = self.round_buy_quantity(raw_limit as u32, lot);
if volume_limited == 0 {
return Err("tick volume limit".to_string());
}
max_fill = max_fill.min(volume_limited);
}
Ok(max_fill)
}
fn resolve_execution_fill(
&self,
date: NaiveDate,
symbol: &str,
side: OrderSide,
_snapshot: &crate::data::DailyMarketSnapshot,
data: &DataSet,
requested_qty: u32,
round_lot: u32,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: Option<NaiveDateTime>,
cash_limit: Option<f64>,
gross_limit: Option<f64>,
) -> Option<ExecutionFill> {
if self.execution_price_field != PriceField::Last {
return None;
}
let start_cursor = execution_cursors
.get(symbol)
.copied()
.into_iter()
.chain(global_execution_cursor)
.chain(
self.intraday_execution_start_time
.map(|start_time| date.and_time(start_time)),
)
.max();
let quotes = data.execution_quotes_on(date, symbol);
self.select_execution_fill(
quotes,
side,
start_cursor,
requested_qty,
round_lot,
cash_limit,
gross_limit,
)
}
fn select_execution_fill(
&self,
quotes: &[IntradayExecutionQuote],
side: OrderSide,
start_cursor: Option<NaiveDateTime>,
requested_qty: u32,
round_lot: u32,
cash_limit: Option<f64>,
gross_limit: Option<f64>,
) -> Option<ExecutionFill> {
if requested_qty == 0 {
return None;
}
let lot = round_lot.max(1);
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 => 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);
}
// Approximate JoinQuant market-order fills with the evolving L1 book after
// the decision time instead of trade VWAP. This keeps quantities/prices
// closer to the observed 10:18 execution logs.
if quote.volume_delta == 0 {
continue;
}
let quote_price = match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
};
let Some(quote_price) = quote_price else {
continue;
};
if !quote_price.is_finite() || quote_price <= 0.0 {
continue;
}
let top_level_liquidity = match side {
OrderSide::Buy => quote.ask1_volume,
OrderSide::Sell => quote.bid1_volume,
};
let available_qty = top_level_liquidity
.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 = remaining_qty.min(available_qty);
take_qty = self.round_buy_quantity(take_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_cost = self.cost_model.calculate(OrderSide::Buy, candidate_gross);
if candidate_gross + candidate_cost.total() <= 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_buy_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_cost =
self.cost_model.calculate(OrderSide::Buy, candidate_gross);
if candidate_gross + candidate_cost.total() <= cash + 1e-6 {
break;
}
residual_qty = residual_qty.saturating_sub(lot);
}
}
if residual_qty > 0 {
let execution_price = match side {
OrderSide::Buy => residual_price,
OrderSide::Sell => residual_price,
};
gross_amount += execution_price * residual_qty as f64;
filled_qty += residual_qty;
}
}
}
}
if filled_qty == 0 {
return None;
}
Some(ExecutionFill {
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 {
matches!(
reason,
"stop_loss_exit" | "take_profit_exit" | "replacement_after_stop_loss_exit"
| "replacement_after_take_profit_exit"
)
}
}
fn price_field_name(field: PriceField) -> &'static str {
match field {
PriceField::Open => "open",
PriceField::Close => "close",
PriceField::Last => "last",
}
} }
fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str { fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {

View File

@@ -45,7 +45,8 @@ impl TradingCalendar {
pub fn previous_day(&self, date: NaiveDate) -> Option<NaiveDate> { pub fn previous_day(&self, date: NaiveDate) -> Option<NaiveDate> {
let idx = self.index_of(date)?; let idx = self.index_of(date)?;
idx.checked_sub(1).and_then(|prev| self.days.get(prev).copied()) idx.checked_sub(1)
.and_then(|prev| self.days.get(prev).copied())
} }
pub fn trailing_days(&self, end: NaiveDate, lookback: usize) -> Vec<NaiveDate> { pub fn trailing_days(&self, end: NaiveDate, lookback: usize) -> Vec<NaiveDate> {

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,9 @@ use thiserror::Error;
use crate::broker::{BrokerExecutionReport, BrokerSimulator}; use crate::broker::{BrokerExecutionReport, BrokerSimulator};
use crate::cost::CostModel; use crate::cost::CostModel;
use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField}; use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField};
use crate::events::{AccountEvent, FillEvent, OrderEvent, PositionEvent}; use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
use crate::portfolio::{HoldingSummary, PortfolioState}; use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
use crate::rules::EquityRuleHooks; use crate::rules::EquityRuleHooks;
use crate::strategy::{Strategy, StrategyContext}; use crate::strategy::{Strategy, StrategyContext};
@@ -32,6 +33,8 @@ pub struct BacktestConfig {
pub benchmark_code: String, pub benchmark_code: String,
pub start_date: Option<NaiveDate>, pub start_date: Option<NaiveDate>,
pub end_date: Option<NaiveDate>, pub end_date: Option<NaiveDate>,
pub decision_lag_trading_days: usize,
pub execution_price_field: PriceField,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -56,6 +59,28 @@ pub struct BacktestResult {
pub position_events: Vec<PositionEvent>, pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>, pub account_events: Vec<AccountEvent>,
pub holdings_summary: Vec<HoldingSummary>, pub holdings_summary: Vec<HoldingSummary>,
pub daily_holdings: Vec<HoldingSummary>,
pub metrics: BacktestMetrics,
}
#[derive(Debug, Clone, Serialize)]
pub struct BacktestDayProgress {
#[serde(with = "date_format")]
pub date: NaiveDate,
pub cash: f64,
pub market_value: f64,
pub total_equity: f64,
pub unit_nav: f64,
pub total_return: f64,
pub benchmark_close: f64,
pub daily_fill_count: usize,
pub cumulative_trade_count: usize,
pub holding_count: usize,
pub notes: String,
pub diagnostics: String,
pub orders: Vec<OrderEvent>,
pub fills: Vec<FillEvent>,
pub holdings: Vec<HoldingSummary>,
} }
pub struct BacktestEngine<S, C, R> { pub struct BacktestEngine<S, C, R> {
@@ -88,15 +113,28 @@ where
R: EquityRuleHooks, R: EquityRuleHooks,
{ {
pub fn run(&mut self) -> Result<BacktestResult, BacktestError> { pub fn run(&mut self) -> Result<BacktestResult, BacktestError> {
self.run_with_progress(|_| {})
}
pub fn run_with_progress<F>(&mut self, mut on_progress: F) -> Result<BacktestResult, BacktestError>
where
F: FnMut(&BacktestDayProgress),
{
let mut portfolio = PortfolioState::new(self.config.initial_cash); let mut portfolio = PortfolioState::new(self.config.initial_cash);
let execution_dates = self let execution_dates = self
.data .data
.calendar() .calendar()
.iter() .iter()
.filter(|date| self.config.start_date.map(|start| *date >= start).unwrap_or(true)) .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.config.end_date.map(|end| *date <= end).unwrap_or(true))
.filter(|date| { .filter(|date| {
!self.data.factor_snapshots_on(*date).is_empty() && !self.data.candidate_snapshots_on(*date).is_empty() !self.data.factor_snapshots_on(*date).is_empty()
&& !self.data.candidate_snapshots_on(*date).is_empty()
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut result = BacktestResult { let mut result = BacktestResult {
@@ -105,8 +143,18 @@ where
.data .data
.benchmark_series() .benchmark_series()
.into_iter() .into_iter()
.filter(|row| self.config.start_date.map(|start| row.date >= start).unwrap_or(true)) .filter(|row| {
.filter(|row| self.config.end_date.map(|end| row.date <= end).unwrap_or(true)) 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(), .collect(),
order_events: Vec::new(), order_events: Vec::new(),
fills: Vec::new(), fills: Vec::new(),
@@ -114,11 +162,33 @@ where
account_events: Vec::new(), account_events: Vec::new(),
equity_curve: Vec::new(), equity_curve: Vec::new(),
holdings_summary: Vec::new(), holdings_summary: Vec::new(),
daily_holdings: Vec::new(),
metrics: BacktestMetrics::default(),
}; };
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
let mut corporate_action_notes = Vec::new();
let receivable_report = self.settle_cash_receivables(
execution_date,
&mut portfolio,
&mut corporate_action_notes,
)?;
self.extend_result(&mut result, receivable_report);
let delisting_report = self.settle_delisted_positions(
execution_date,
&mut portfolio,
&mut corporate_action_notes,
)?;
self.extend_result(&mut result, delisting_report);
let corporate_action_report = self.apply_corporate_actions(
execution_date,
&mut portfolio,
&mut corporate_action_notes,
)?;
self.extend_result(&mut result, corporate_action_report);
let decision = execution_idx let decision = execution_idx
.checked_sub(1) .checked_sub(self.config.decision_lag_trading_days)
.map(|decision_idx| { .map(|decision_idx| {
let decision_date = execution_dates[decision_idx]; let decision_date = execution_dates[decision_idx];
self.strategy.on_day(&StrategyContext { self.strategy.on_day(&StrategyContext {
@@ -132,21 +202,29 @@ where
.transpose()? .transpose()?
.unwrap_or_default(); .unwrap_or_default();
let report = self let report =
.broker self.broker
.execute(execution_date, &mut portfolio, &self.data, &decision)?; .execute(execution_date, &mut portfolio, &self.data, &decision)?;
let daily_fill_count = report.fill_events.len();
let day_orders = report.order_events.clone();
let day_fills = report.fill_events.clone();
self.extend_result(&mut result, report); self.extend_result(&mut result, report);
portfolio.update_prices(execution_date, &self.data, PriceField::Close)?; portfolio.update_prices(execution_date, &self.data, PriceField::Close)?;
let benchmark = self let benchmark =
.data self.data
.benchmark(execution_date) .benchmark(execution_date)
.ok_or(BacktestError::MissingBenchmark { .ok_or(BacktestError::MissingBenchmark {
date: execution_date, date: execution_date,
})?; })?;
let notes = decision.notes.join(" | "); let notes = corporate_action_notes
.into_iter()
.chain(decision.notes.into_iter())
.collect::<Vec<_>>()
.join(" | ");
let diagnostics = decision.diagnostics.join(" | "); let diagnostics = decision.diagnostics.join(" | ");
let holdings_for_day = portfolio.holdings_summary(execution_date);
result.equity_curve.push(DailyEquityPoint { result.equity_curve.push(DailyEquityPoint {
date: execution_date, date: execution_date,
@@ -157,20 +235,295 @@ where
notes, notes,
diagnostics, diagnostics,
}); });
result.daily_holdings.extend(holdings_for_day.clone());
let latest = result
.equity_curve
.last()
.expect("equity point pushed for progress event");
on_progress(&BacktestDayProgress {
date: execution_date,
cash: latest.cash,
market_value: latest.market_value,
total_equity: latest.total_equity,
unit_nav: if self.config.initial_cash.abs() < f64::EPSILON {
0.0
} else {
latest.total_equity / self.config.initial_cash
},
total_return: if self.config.initial_cash.abs() < f64::EPSILON {
0.0
} else {
(latest.total_equity / self.config.initial_cash) - 1.0
},
benchmark_close: latest.benchmark_close,
daily_fill_count,
cumulative_trade_count: result.fills.len(),
holding_count: holdings_for_day.len(),
notes: latest.notes.clone(),
diagnostics: latest.diagnostics.clone(),
orders: day_orders,
fills: day_fills,
holdings: holdings_for_day,
});
} }
if let Some(last_date) = execution_dates.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);
} }
result.metrics = compute_backtest_metrics(
&result.equity_curve,
&result.fills,
&result.daily_holdings,
self.config.initial_cash,
);
Ok(result) Ok(result)
} }
fn extend_result(&self, result: &mut BacktestResult, report: BrokerExecutionReport) { fn extend_result(
result.order_events.extend(report.order_events); &self,
result.fills.extend(report.fill_events); result: &mut BacktestResult,
result.position_events.extend(report.position_events); report: BrokerExecutionReport,
result.account_events.extend(report.account_events); ) -> BrokerExecutionReport {
result.order_events.extend(report.order_events.clone());
result.fills.extend(report.fill_events.clone());
result.position_events.extend(report.position_events.clone());
result.account_events.extend(report.account_events.clone());
report
}
fn apply_corporate_actions(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
notes: &mut Vec<String>,
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
for action in self.data.corporate_actions_on(date) {
if !action.has_effect() {
continue;
}
let Some(existing_position) = portfolio.position(&action.symbol) else {
continue;
};
if existing_position.quantity == 0 {
continue;
}
if action.share_cash.abs() > f64::EPSILON {
let cash_before = portfolio.cash();
let (cash_delta, quantity_after, average_cost) = {
let position = portfolio
.position_mut_if_exists(&action.symbol)
.expect("position exists for dividend action");
let cash_delta = position.apply_cash_dividend(action.share_cash);
(cash_delta, position.quantity, position.average_cost)
};
if cash_delta.abs() > f64::EPSILON {
let payable_date = action.payable_date.unwrap_or(date);
let immediate_cash = payable_date <= date;
let note = if immediate_cash {
portfolio.apply_cash_delta(cash_delta);
format!(
"cash_dividend {} share_cash={:.6} quantity={} cash={:.2}",
action.symbol, action.share_cash, quantity_after, cash_delta
)
} else {
portfolio.add_cash_receivable(CashReceivable {
symbol: action.symbol.clone(),
ex_date: date,
payable_date,
amount: cash_delta,
reason: format!("cash_dividend {:.6}", action.share_cash),
});
format!(
"cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}",
action.symbol, action.share_cash, quantity_after, payable_date, cash_delta
)
};
notes.push(note.clone());
report.account_events.push(AccountEvent {
date,
cash_before,
cash_after: portfolio.cash(),
total_equity: portfolio.total_equity(),
note,
});
report.position_events.push(PositionEvent {
date,
symbol: action.symbol.clone(),
delta_quantity: 0,
quantity_after,
average_cost,
realized_pnl_delta: 0.0,
reason: format!("cash_dividend {:.6}", action.share_cash),
});
}
}
let split_ratio = action.split_ratio();
if (split_ratio - 1.0).abs() > f64::EPSILON {
let (delta_quantity, quantity_after, average_cost) = {
let position = portfolio
.position_mut_if_exists(&action.symbol)
.expect("position exists for split action");
let delta_quantity = position.apply_split_ratio(split_ratio);
(delta_quantity, position.quantity, position.average_cost)
};
if delta_quantity != 0 {
let note = format!(
"stock_split {} ratio={:.6} delta_qty={}",
action.symbol, split_ratio, delta_quantity
);
notes.push(note);
report.position_events.push(PositionEvent {
date,
symbol: action.symbol.clone(),
delta_quantity,
quantity_after,
average_cost,
realized_pnl_delta: 0.0,
reason: format!("stock_split {:.6}", split_ratio),
});
}
}
}
portfolio.prune_flat_positions();
Ok(report)
}
fn settle_cash_receivables(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
notes: &mut Vec<String>,
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
let settled = portfolio.settle_cash_receivables(date);
for receivable in settled {
let note = format!(
"cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}",
receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount
);
notes.push(note.clone());
report.account_events.push(AccountEvent {
date,
cash_before: portfolio.cash() - receivable.amount,
cash_after: portfolio.cash(),
total_equity: portfolio.total_equity(),
note,
});
}
Ok(report)
}
fn settle_delisted_positions(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
notes: &mut Vec<String>,
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
for symbol in symbols {
let Some(position) = portfolio.position(&symbol) else {
continue;
};
if position.quantity == 0 {
continue;
}
let Some(instrument) = self.data.instrument(&symbol) else {
continue;
};
let should_settle = instrument.is_delisted_before(date)
|| (instrument.status.eq_ignore_ascii_case("delisted")
&& instrument.delisted_at.is_none()
&& self.data.market(date, &symbol).is_none());
if !should_settle {
continue;
}
let quantity = position.quantity;
let fallback_reference_price = if position.last_price > 0.0 {
position.last_price
} else {
position.average_cost
};
let effective_delisted_at = instrument
.delisted_at
.or_else(|| self.data.calendar().previous_day(date))
.unwrap_or(date);
let settlement_price = self
.data
.price_on_or_before(effective_delisted_at, &symbol, PriceField::Close)
.or_else(|| self.data.price_on_or_before(date, &symbol, PriceField::Close))
.filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_reference_price);
if !settlement_price.is_finite() || settlement_price <= 0.0 {
return Err(BacktestError::Execution(format!(
"missing delisting settlement price for {} on {}",
symbol, date
)));
}
let cash_before = portfolio.cash();
let gross_amount = settlement_price * quantity as f64;
let realized_pnl_delta = {
let position = portfolio
.position_mut_if_exists(&symbol)
.expect("position exists for delisting settlement");
position
.sell(quantity, settlement_price)
.map_err(BacktestError::Execution)?
};
portfolio.apply_cash_delta(gross_amount);
portfolio.prune_flat_positions();
let reason = format!(
"delisted_cash_settlement effective_date={} status={}",
effective_delisted_at, instrument.status
);
notes.push(reason.clone());
report.order_events.push(OrderEvent {
date,
symbol: symbol.clone(),
side: OrderSide::Sell,
requested_quantity: quantity,
filled_quantity: quantity,
status: OrderStatus::Filled,
reason: reason.clone(),
});
report.fill_events.push(FillEvent {
date,
symbol: symbol.clone(),
side: OrderSide::Sell,
quantity,
price: settlement_price,
gross_amount,
commission: 0.0,
stamp_tax: 0.0,
net_cash_flow: gross_amount,
reason: reason.clone(),
});
report.position_events.push(PositionEvent {
date,
symbol: symbol.clone(),
delta_quantity: -(quantity as i32),
quantity_after: 0,
average_cost: 0.0,
realized_pnl_delta,
reason: reason.clone(),
});
report.account_events.push(AccountEvent {
date,
cash_before,
cash_after: portfolio.cash(),
total_equity: portfolio.total_equity(),
note: reason,
});
}
Ok(report)
} }
} }

View File

@@ -33,6 +33,7 @@ pub enum OrderSide {
pub enum OrderStatus { pub enum OrderStatus {
Filled, Filled,
PartiallyFilled, PartiallyFilled,
Canceled,
Rejected, Rejected,
} }

View File

@@ -1,3 +1,4 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -5,4 +6,55 @@ pub struct Instrument {
pub symbol: String, pub symbol: String,
pub name: String, pub name: String,
pub board: String, pub board: String,
pub round_lot: u32,
#[serde(default, with = "optional_date_format")]
pub listed_at: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub delisted_at: Option<NaiveDate>,
#[serde(default = "default_status")]
pub status: String,
}
impl Instrument {
pub fn effective_round_lot(&self) -> u32 {
self.round_lot.max(1)
}
pub fn is_delisted_before(&self, date: NaiveDate) -> bool {
self.delisted_at.is_some_and(|delisted_at| delisted_at < date)
}
}
fn default_status() -> String {
"active".to_string()
}
mod optional_date_format {
use chrono::NaiveDate;
use serde::{self, Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%d";
pub fn serialize<S>(value: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(date) => serializer.serialize_some(&date.format(FORMAT).to_string()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
match value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
Some(text) => NaiveDate::parse_from_str(text, FORMAT)
.map(Some)
.map_err(serde::de::Error::custom),
None => Ok(None),
}
}
} }

View File

@@ -5,6 +5,7 @@ pub mod data;
pub mod engine; pub mod engine;
pub mod events; pub mod events;
pub mod instrument; pub mod instrument;
pub mod metrics;
pub mod portfolio; pub mod portfolio;
pub mod rules; pub mod rules;
pub mod strategy; pub mod strategy;
@@ -14,39 +15,24 @@ pub use broker::{BrokerExecutionReport, BrokerSimulator};
pub use calendar::TradingCalendar; pub use calendar::TradingCalendar;
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost}; pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
pub use data::{ pub use data::{
BenchmarkSnapshot, BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
CandidateEligibility, DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, EligibleUniverseSnapshot,
DailyFactorSnapshot, IntradayExecutionQuote, PriceField,
DailyMarketSnapshot,
DailySnapshotBundle,
DataSet,
DataSetError,
PriceField,
}; };
pub use engine::{BacktestConfig, BacktestEngine, BacktestError, BacktestResult, DailyEquityPoint}; pub use engine::{
pub use events::{ BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult,
AccountEvent, DailyEquityPoint,
FillEvent,
OrderEvent,
OrderSide,
OrderStatus,
PositionEvent,
}; };
pub use events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
pub use instrument::Instrument; pub use instrument::Instrument;
pub use portfolio::{HoldingSummary, PortfolioState, Position}; pub use metrics::{BacktestMetrics, compute_backtest_metrics};
pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position};
pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck};
pub use strategy::{ pub use strategy::{
CnSmallCapRotationConfig, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy,
CnSmallCapRotationStrategy, OrderIntent, Strategy, StrategyContext, StrategyDecision,
Strategy,
StrategyContext,
StrategyDecision,
}; };
pub use universe::{ pub use universe::{
BandRegime, BandRegime, DynamicMarketCapBandSelector, SelectionContext, SelectionDiagnostics,
DynamicMarketCapBandSelector, UniverseCandidate, UniverseSelector,
SelectionContext,
SelectionDiagnostics,
UniverseCandidate,
UniverseSelector,
}; };

View File

@@ -0,0 +1,437 @@
use std::collections::BTreeMap;
use chrono::{Datelike, NaiveDate};
use serde::{Deserialize, Serialize};
use crate::engine::DailyEquityPoint;
use crate::events::FillEvent;
use crate::portfolio::HoldingSummary;
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
const MONTHS_PER_YEAR: f64 = 12.0;
const DEFAULT_RISK_FREE_RATE: f64 = 0.022;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BacktestMetrics {
pub total_return: f64,
pub annual_return: f64,
pub sharpe: f64,
pub max_drawdown: f64,
pub win_rate: f64,
pub alpha: f64,
pub beta: f64,
pub benchmark_cumulative_return: f64,
pub benchmark_net_value: f64,
pub risk_free_rate: f64,
pub monthly_excess_win_rate: f64,
pub excess_cumulative_return: f64,
pub excess_annual_return: f64,
pub max_drawdown_duration_days: usize,
pub total_trade_days: usize,
pub sortino: f64,
pub information_ratio: f64,
pub tracking_error: f64,
pub volatility: f64,
pub excess_return: f64,
pub excess_sharpe: f64,
pub excess_volatility: f64,
pub excess_max_drawdown: f64,
pub holding_count: usize,
pub average_weight: f64,
pub max_weight: f64,
pub concentration: f64,
pub weight_std_dev: f64,
pub median_weight: f64,
pub average_daily_turnover: f64,
pub total_assets: f64,
pub cash_balance: f64,
pub unit_nav: f64,
pub initial_cash: f64,
pub excess_win_rate: f64,
pub monthly_sharpe: f64,
pub monthly_volatility: f64,
}
pub fn compute_backtest_metrics(
equity_curve: &[DailyEquityPoint],
fills: &[FillEvent],
daily_holdings: &[HoldingSummary],
initial_cash: f64,
) -> BacktestMetrics {
let Some(first_point) = equity_curve.first() else {
return BacktestMetrics {
risk_free_rate: DEFAULT_RISK_FREE_RATE,
initial_cash,
..BacktestMetrics::default()
};
};
let Some(last_point) = equity_curve.last() else {
return BacktestMetrics {
risk_free_rate: DEFAULT_RISK_FREE_RATE,
initial_cash,
..BacktestMetrics::default()
};
};
let trade_days = equity_curve.len();
let returns = equity_curve
.windows(2)
.map(|window| pct_change(window[0].total_equity, window[1].total_equity))
.collect::<Vec<_>>();
let benchmark_returns = equity_curve
.windows(2)
.map(|window| pct_change(window[0].benchmark_close, window[1].benchmark_close))
.collect::<Vec<_>>();
let excess_returns = returns
.iter()
.zip(benchmark_returns.iter())
.map(|(lhs, rhs)| lhs - rhs)
.collect::<Vec<_>>();
let benchmark_net_value = if first_point.benchmark_close.abs() < f64::EPSILON {
1.0
} else {
last_point.benchmark_close / first_point.benchmark_close
};
let benchmark_cumulative_return = benchmark_net_value - 1.0;
let total_return = if initial_cash.abs() < f64::EPSILON {
0.0
} else {
(last_point.total_equity / initial_cash) - 1.0
};
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
total_return
} else {
(last_point.total_equity / initial_cash) / benchmark_net_value - 1.0
};
let excess_return = total_return - benchmark_cumulative_return;
let annual_return = annualize_return(total_return, trade_days);
let excess_annual_return = annualize_return(excess_cumulative_return, trade_days);
let risk_free_rate = DEFAULT_RISK_FREE_RATE;
let daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR;
let sharpe = annualized_sharpe(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
let sortino = annualized_sortino(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
let information_ratio = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
let tracking_error = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
let volatility = annualized_std(&returns, TRADING_DAYS_PER_YEAR);
let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
let equity_nav = equity_curve
.iter()
.map(|point| safe_div(point.total_equity, initial_cash, 1.0))
.collect::<Vec<_>>();
let benchmark_nav_series = equity_curve
.iter()
.map(|point| safe_div(point.benchmark_close, first_point.benchmark_close, 1.0))
.collect::<Vec<_>>();
let excess_nav_series = equity_nav
.iter()
.zip(benchmark_nav_series.iter())
.map(|(lhs, rhs)| safe_div(*lhs, *rhs, *lhs))
.collect::<Vec<_>>();
let (max_drawdown, max_drawdown_duration_days) = drawdown_stats(&equity_nav);
let (excess_max_drawdown, _) = drawdown_stats(&excess_nav_series);
let winning_days = returns.iter().filter(|value| **value > 0.0).count();
let excess_winning_days = excess_returns.iter().filter(|value| **value > 0.0).count();
let win_rate = ratio(winning_days, returns.len());
let excess_win_rate = ratio(excess_winning_days, excess_returns.len());
let monthly_portfolio_returns = group_monthly_returns(equity_curve, |point| point.total_equity);
let monthly_benchmark_returns =
group_monthly_returns(equity_curve, |point| point.benchmark_close);
let monthly_excess_returns = monthly_portfolio_returns
.iter()
.zip(monthly_benchmark_returns.iter())
.map(|(lhs, rhs)| lhs - rhs)
.collect::<Vec<_>>();
let monthly_excess_win_rate = ratio(
monthly_excess_returns
.iter()
.filter(|value| **value > 0.0)
.count(),
monthly_excess_returns.len(),
);
let monthly_sharpe = annualized_sharpe(
&monthly_portfolio_returns,
risk_free_rate / MONTHS_PER_YEAR,
MONTHS_PER_YEAR,
);
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
let turnover_by_date = fills.iter().fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
*acc.entry(fill.date).or_default() += fill.gross_amount.abs();
acc
});
let equity_by_date = equity_curve
.iter()
.map(|point| (point.date, point.total_equity))
.collect::<BTreeMap<_, _>>();
let average_daily_turnover = if equity_curve.is_empty() {
0.0
} else {
equity_curve
.iter()
.map(|point| {
let traded = turnover_by_date.get(&point.date).copied().unwrap_or_default();
safe_div(traded, point.total_equity.max(initial_cash * 0.5), 0.0)
})
.sum::<f64>()
/ equity_curve.len() as f64
};
let latest_date = last_point.date;
let latest_holdings = daily_holdings
.iter()
.filter(|row| row.date == latest_date && row.quantity > 0)
.collect::<Vec<_>>();
let weights = latest_holdings
.iter()
.map(|holding| safe_div(holding.market_value, last_point.total_equity, 0.0))
.collect::<Vec<_>>();
let holding_count = latest_holdings.len();
let average_weight = mean(&weights);
let max_weight = weights
.iter()
.copied()
.fold(0.0_f64, |acc, value| acc.max(value));
let concentration = weights.iter().map(|weight| weight * weight).sum::<f64>();
let weight_std_dev = std_dev(&weights);
let median_weight = median(&weights);
let total_trade_days = equity_by_date.len();
BacktestMetrics {
total_return,
annual_return,
sharpe,
max_drawdown,
win_rate,
alpha,
beta,
benchmark_cumulative_return,
benchmark_net_value,
risk_free_rate,
monthly_excess_win_rate,
excess_cumulative_return,
excess_annual_return,
max_drawdown_duration_days,
total_trade_days,
sortino,
information_ratio,
tracking_error,
volatility,
excess_return,
excess_sharpe,
excess_volatility,
excess_max_drawdown,
holding_count,
average_weight,
max_weight,
concentration,
weight_std_dev,
median_weight,
average_daily_turnover,
total_assets: last_point.total_equity,
cash_balance: last_point.cash,
unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0),
initial_cash,
excess_win_rate,
monthly_sharpe,
monthly_volatility,
}
}
fn pct_change(previous: f64, current: f64) -> f64 {
if previous.abs() < f64::EPSILON {
0.0
} else {
(current / previous) - 1.0
}
}
fn annualize_return(total_return: f64, periods: usize) -> f64 {
if periods == 0 {
return 0.0;
}
let periods = periods as f64;
let base = 1.0 + total_return;
if base <= 0.0 {
return -1.0;
}
base.powf(TRADING_DAYS_PER_YEAR / periods) - 1.0
}
fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
if returns.len() < 2 {
return 0.0;
}
let adjusted = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
let mean_ret = mean(&adjusted);
let std = std_dev(&adjusted);
if std <= f64::EPSILON {
0.0
} else {
mean_ret / std * periods_per_year.sqrt()
}
}
fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
if returns.is_empty() {
return 0.0;
}
let adjusted = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
let downside = adjusted
.iter()
.filter(|value| **value < 0.0)
.map(|value| value.powi(2))
.collect::<Vec<_>>();
if downside.is_empty() {
return 0.0;
}
let downside_dev = (downside.iter().sum::<f64>() / downside.len() as f64).sqrt();
if downside_dev <= f64::EPSILON {
0.0
} else {
mean(&adjusted) / downside_dev * periods_per_year.sqrt()
}
}
fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 {
std_dev(values) * periods_per_year.sqrt()
}
fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64, f64) {
if returns.len() < 2 || returns.len() != benchmark_returns.len() {
return (0.0, 0.0);
}
let strategy_excess = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
let benchmark_excess = benchmark_returns
.iter()
.map(|value| value - daily_rf)
.collect::<Vec<_>>();
let mean_strategy = mean(&strategy_excess);
let mean_benchmark = mean(&benchmark_excess);
let variance_benchmark = variance(&benchmark_excess);
if variance_benchmark <= f64::EPSILON {
return (0.0, 0.0);
}
let covariance = strategy_excess
.iter()
.zip(benchmark_excess.iter())
.map(|(lhs, rhs)| (lhs - mean_strategy) * (rhs - mean_benchmark))
.sum::<f64>()
/ (strategy_excess.len() - 1) as f64;
let beta = covariance / variance_benchmark;
let alpha = (mean_strategy - beta * mean_benchmark) * TRADING_DAYS_PER_YEAR;
(alpha, beta)
}
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
let mut peak = 0.0_f64;
let mut max_drawdown = 0.0_f64;
let mut duration = 0_usize;
let mut max_duration = 0_usize;
for value in nav {
if *value >= peak {
peak = *value;
duration = 0;
continue;
}
if peak > f64::EPSILON {
let drawdown = (*value / peak) - 1.0;
if drawdown < max_drawdown {
max_drawdown = drawdown;
}
}
duration += 1;
if duration > max_duration {
max_duration = duration;
}
}
(max_drawdown, max_duration)
}
fn group_monthly_returns<F>(equity_curve: &[DailyEquityPoint], value_fn: F) -> Vec<f64>
where
F: Fn(&DailyEquityPoint) -> f64,
{
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
let mut month_first = BTreeMap::<(i32, u32), f64>::new();
for point in equity_curve {
let key = (point.date.year(), point.date.month());
month_first.entry(key).or_insert_with(|| value_fn(point));
month_last.insert(key, value_fn(point));
}
let mut keys = month_last.keys().copied().collect::<Vec<_>>();
keys.sort_unstable();
keys.into_iter()
.filter_map(|key| {
let first = month_first.get(&key).copied().unwrap_or_default();
let last = month_last.get(&key).copied().unwrap_or_default();
if first.abs() < f64::EPSILON {
None
} else {
Some((last / first) - 1.0)
}
})
.collect()
}
fn mean(values: &[f64]) -> f64 {
if values.is_empty() {
0.0
} else {
values.iter().sum::<f64>() / values.len() as f64
}
}
fn variance(values: &[f64]) -> f64 {
if values.len() < 2 {
return 0.0;
}
let avg = mean(values);
values
.iter()
.map(|value| (value - avg).powi(2))
.sum::<f64>()
/ (values.len() - 1) as f64
}
fn std_dev(values: &[f64]) -> f64 {
variance(values).sqrt()
}
fn median(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut sorted = values.to_vec();
sorted.sort_by(|lhs, rhs| lhs.partial_cmp(rhs).unwrap_or(std::cmp::Ordering::Equal));
let mid = sorted.len() / 2;
if sorted.len() % 2 == 0 {
(sorted[mid - 1] + sorted[mid]) / 2.0
} else {
sorted[mid]
}
}
fn ratio(numerator: usize, denominator: usize) -> f64 {
if denominator == 0 {
0.0
} else {
numerator as f64 / denominator as f64
}
}
fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
if denominator.abs() < f64::EPSILON {
fallback
} else {
numerator / denominator
}
}

View File

@@ -1,6 +1,5 @@
use std::collections::BTreeMap;
use chrono::NaiveDate; use chrono::NaiveDate;
use indexmap::IndexMap;
use serde::Serialize; use serde::Serialize;
use crate::data::{DataSet, DataSetError, PriceField}; use crate::data::{DataSet, DataSetError, PriceField};
@@ -124,19 +123,71 @@ impl Position {
self.average_cost = total_cost / self.quantity as f64; self.average_cost = total_cost / self.quantity as f64;
} }
pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 {
if self.quantity == 0 || !dividend_per_share.is_finite() || dividend_per_share == 0.0 {
return 0.0;
}
for lot in &mut self.lots {
lot.price -= dividend_per_share;
}
self.average_cost -= dividend_per_share;
self.last_price -= dividend_per_share;
self.quantity as f64 * dividend_per_share
}
pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 {
if self.quantity == 0 || !ratio.is_finite() || ratio <= 0.0 || (ratio - 1.0).abs() < 1e-9
{
return 0;
}
let old_quantity = self.quantity;
let mut scaled_lots = self
.lots
.iter()
.map(|lot| PositionLot {
acquired_date: lot.acquired_date,
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
price: lot.price / ratio,
})
.collect::<Vec<_>>();
let expected_total = round_half_up_u32(old_quantity as f64 * ratio);
let scaled_total = scaled_lots.iter().map(|lot| lot.quantity).sum::<u32>();
if let Some(last_lot) = scaled_lots.last_mut() {
if scaled_total < expected_total {
last_lot.quantity += expected_total - scaled_total;
} else if scaled_total > expected_total {
last_lot.quantity = last_lot
.quantity
.saturating_sub(scaled_total - expected_total);
}
}
scaled_lots.retain(|lot| lot.quantity > 0);
self.lots = scaled_lots;
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
self.last_price /= ratio;
self.recalculate_average_cost();
self.quantity as i32 - old_quantity as i32
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PortfolioState { pub struct PortfolioState {
cash: f64, cash: f64,
positions: BTreeMap<String, Position>, positions: IndexMap<String, Position>,
cash_receivables: Vec<CashReceivable>,
} }
impl PortfolioState { impl PortfolioState {
pub fn new(initial_cash: f64) -> Self { pub fn new(initial_cash: f64) -> Self {
Self { Self {
cash: initial_cash, cash: initial_cash,
positions: BTreeMap::new(), positions: IndexMap::new(),
cash_receivables: Vec::new(),
} }
} }
@@ -144,7 +195,7 @@ impl PortfolioState {
self.cash self.cash
} }
pub fn positions(&self) -> &BTreeMap<String, Position> { pub fn positions(&self) -> &IndexMap<String, Position> {
&self.positions &self.positions
} }
@@ -152,6 +203,10 @@ impl PortfolioState {
self.positions.get(symbol) self.positions.get(symbol)
} }
pub fn position_mut_if_exists(&mut self, symbol: &str) -> Option<&mut Position> {
self.positions.get_mut(symbol)
}
pub fn position_mut(&mut self, symbol: &str) -> &mut Position { pub fn position_mut(&mut self, symbol: &str) -> &mut Position {
self.positions self.positions
.entry(symbol.to_string()) .entry(symbol.to_string())
@@ -166,6 +221,29 @@ impl PortfolioState {
self.positions.retain(|_, position| !position.is_flat()); self.positions.retain(|_, position| !position.is_flat());
} }
pub fn add_cash_receivable(&mut self, receivable: CashReceivable) {
self.cash_receivables.push(receivable);
}
pub fn settle_cash_receivables(&mut self, date: NaiveDate) -> Vec<CashReceivable> {
let mut settled = Vec::new();
let mut pending = Vec::new();
for receivable in self.cash_receivables.drain(..) {
if receivable.payable_date <= date {
self.cash += receivable.amount;
settled.push(receivable);
} else {
pending.push(receivable);
}
}
self.cash_receivables = pending;
settled
}
pub fn cash_receivables(&self) -> &[CashReceivable] {
&self.cash_receivables
}
pub fn update_prices( pub fn update_prices(
&mut self, &mut self,
date: NaiveDate, date: NaiveDate,
@@ -173,15 +251,16 @@ impl PortfolioState {
field: PriceField, field: PriceField,
) -> Result<(), DataSetError> { ) -> Result<(), DataSetError> {
for position in self.positions.values_mut() { for position in self.positions.values_mut() {
let price = data let price = data.price(date, &position.symbol, field).ok_or_else(|| {
.price(date, &position.symbol, field) DataSetError::MissingSnapshot {
.ok_or_else(|| DataSetError::MissingSnapshot {
kind: match field { kind: match field {
PriceField::Open => "open price", PriceField::Open => "open price",
PriceField::Close => "close price", PriceField::Close => "close price",
PriceField::Last => "last price",
}, },
date, date,
symbol: position.symbol.clone(), symbol: position.symbol.clone(),
}
})?; })?;
position.last_price = price; position.last_price = price;
} }
@@ -214,6 +293,30 @@ impl PortfolioState {
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn positions_preserve_insertion_order() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
portfolio.position_mut("603657.SH").buy(date, 100, 10.0);
portfolio.position_mut("001266.SZ").buy(date, 100, 10.0);
portfolio.position_mut("601798.SH").buy(date, 100, 10.0);
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
assert_eq!(
symbols,
vec![
"603657.SH".to_string(),
"001266.SZ".to_string(),
"601798.SH".to_string()
]
);
}
}
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct HoldingSummary { pub struct HoldingSummary {
#[serde(with = "date_format")] #[serde(with = "date_format")]
@@ -227,6 +330,15 @@ pub struct HoldingSummary {
pub realized_pnl: f64, pub realized_pnl: f64,
} }
#[derive(Debug, Clone)]
pub struct CashReceivable {
pub symbol: String,
pub ex_date: NaiveDate,
pub payable_date: NaiveDate,
pub amount: f64,
pub reason: String,
}
mod date_format { mod date_format {
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::Serializer; use serde::Serializer;
@@ -240,3 +352,11 @@ mod date_format {
serializer.serialize_str(&date.format(FORMAT).to_string()) serializer.serialize_str(&date.format(FORMAT).to_string())
} }
} }
fn round_half_up_u32(value: f64) -> u32 {
if !value.is_finite() || value <= 0.0 {
0
} else {
value.round() as u32
}
}

View File

@@ -1,6 +1,6 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use crate::data::{CandidateEligibility, DailyMarketSnapshot}; use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
use crate::portfolio::Position; use crate::portfolio::Position;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -31,6 +31,7 @@ pub trait EquityRuleHooks {
execution_date: NaiveDate, execution_date: NaiveDate,
snapshot: &DailyMarketSnapshot, snapshot: &DailyMarketSnapshot,
candidate: &CandidateEligibility, candidate: &CandidateEligibility,
price_field: PriceField,
) -> RuleCheck; ) -> RuleCheck;
fn can_sell( fn can_sell(
@@ -39,6 +40,7 @@ pub trait EquityRuleHooks {
snapshot: &DailyMarketSnapshot, snapshot: &DailyMarketSnapshot,
candidate: &CandidateEligibility, candidate: &CandidateEligibility,
position: &Position, position: &Position,
price_field: PriceField,
) -> RuleCheck; ) -> RuleCheck;
} }
@@ -46,12 +48,12 @@ pub trait EquityRuleHooks {
pub struct ChinaEquityRuleHooks; pub struct ChinaEquityRuleHooks;
impl ChinaEquityRuleHooks { impl ChinaEquityRuleHooks {
fn at_upper_limit(snapshot: &DailyMarketSnapshot) -> bool { fn at_upper_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool {
snapshot.open >= snapshot.upper_limit - 1e-6 snapshot.is_at_upper_limit_price(snapshot.buy_price(price_field))
} }
fn at_lower_limit(snapshot: &DailyMarketSnapshot) -> bool { fn at_lower_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool {
snapshot.open <= snapshot.lower_limit + 1e-6 snapshot.is_at_lower_limit_price(snapshot.sell_price(price_field))
} }
} }
@@ -61,6 +63,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
_execution_date: NaiveDate, _execution_date: NaiveDate,
snapshot: &DailyMarketSnapshot, snapshot: &DailyMarketSnapshot,
candidate: &CandidateEligibility, candidate: &CandidateEligibility,
price_field: PriceField,
) -> RuleCheck { ) -> RuleCheck {
if snapshot.paused || candidate.is_paused { if snapshot.paused || candidate.is_paused {
return RuleCheck::reject("paused"); return RuleCheck::reject("paused");
@@ -68,7 +71,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
if !candidate.allow_buy { if !candidate.allow_buy {
return RuleCheck::reject("buy disabled by eligibility flags"); return RuleCheck::reject("buy disabled by eligibility flags");
} }
if Self::at_upper_limit(snapshot) { if Self::at_upper_limit(snapshot, price_field) {
return RuleCheck::reject("open at or above upper limit"); return RuleCheck::reject("open at or above upper limit");
} }
@@ -81,6 +84,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
snapshot: &DailyMarketSnapshot, snapshot: &DailyMarketSnapshot,
candidate: &CandidateEligibility, candidate: &CandidateEligibility,
position: &Position, position: &Position,
price_field: PriceField,
) -> RuleCheck { ) -> RuleCheck {
if snapshot.paused || candidate.is_paused { if snapshot.paused || candidate.is_paused {
return RuleCheck::reject("paused"); return RuleCheck::reject("paused");
@@ -88,7 +92,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
if !candidate.allow_sell { if !candidate.allow_sell {
return RuleCheck::reject("sell disabled by eligibility flags"); return RuleCheck::reject("sell disabled by eligibility flags");
} }
if Self::at_lower_limit(snapshot) { if Self::at_lower_limit(snapshot, price_field) {
return RuleCheck::reject("open at or below lower limit"); return RuleCheck::reject("open at or below lower limit");
} }
if position.sellable_qty(execution_date) == 0 { if position.sellable_qty(execution_date) == 0 {

View File

@@ -25,10 +25,25 @@ pub struct StrategyDecision {
pub rebalance: bool, pub rebalance: bool,
pub target_weights: BTreeMap<String, f64>, pub target_weights: BTreeMap<String, f64>,
pub exit_symbols: BTreeSet<String>, pub exit_symbols: BTreeSet<String>,
pub order_intents: Vec<OrderIntent>,
pub notes: Vec<String>, pub notes: Vec<String>,
pub diagnostics: 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)] #[derive(Debug, Clone)]
pub struct CnSmallCapRotationConfig { pub struct CnSmallCapRotationConfig {
pub strategy_name: String, pub strategy_name: String,
@@ -97,7 +112,13 @@ impl CnSmallCapRotationConfig {
take_profit_pct: 0.07, take_profit_pct: 0.07,
signal_symbol: Some("000852.SH".to_string()), signal_symbol: Some("000852.SH".to_string()),
skip_months: vec![], skip_months: vec![],
skip_month_day_ranges: vec![(1, 15, 30), (4, 15, 29), (8, 15, 31), (10, 20, 30), (12, 20, 30)], skip_month_day_ranges: vec![
(1, 15, 30),
(4, 15, 29),
(8, 15, 31),
(10, 20, 30),
(12, 20, 30),
],
} }
} }
@@ -136,12 +157,10 @@ impl CnSmallCapRotationStrategy {
fn moving_average(values: &[f64], lookback: usize) -> f64 { fn moving_average(values: &[f64], lookback: usize) -> f64 {
let len = values.len(); let len = values.len();
let window = values.iter().skip(len.saturating_sub(lookback)); let window = values.iter().skip(len.saturating_sub(lookback));
let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| (sum + value, count + 1)); let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| {
if count == 0 { (sum + value, count + 1)
0.0 });
} else { if count == 0 { 0.0 } else { sum / count as f64 }
sum / count as f64
}
} }
fn gross_exposure(&self, closes: &[f64]) -> f64 { fn gross_exposure(&self, closes: &[f64]) -> f64 {
@@ -166,23 +185,11 @@ impl CnSmallCapRotationStrategy {
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
) -> Result<(String, Vec<f64>, f64), BacktestError> { ) -> Result<(String, Vec<f64>, f64), BacktestError> {
let symbol = self if let Some(symbol) = self.config.signal_symbol.as_deref() {
.config let closes =
.signal_symbol ctx.data
.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); .market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days);
if closes.len() < 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 let close = ctx
.data .data
.price(ctx.decision_date, symbol, PriceField::Close) .price(ctx.decision_date, symbol, PriceField::Close)
@@ -191,12 +198,32 @@ impl CnSmallCapRotationStrategy {
symbol: symbol.to_string(), symbol: symbol.to_string(),
field: "close", field: "close",
})?; })?;
Ok((symbol.to_string(), closes, 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 { fn stock_passes_ma_filter(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool {
let closes = ctx let closes =
.data ctx.data
.market_closes_up_to(ctx.decision_date, symbol, self.config.stock_long_ma_days); .market_closes_up_to(ctx.decision_date, symbol, self.config.stock_long_ma_days);
if closes.len() < self.config.stock_long_ma_days { if closes.len() < self.config.stock_long_ma_days {
return false; return false;
@@ -207,7 +234,10 @@ impl CnSmallCapRotationStrategy {
ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long 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() {
if position.quantity == 0 { if position.quantity == 0 {
@@ -244,8 +274,8 @@ impl Strategy for CnSmallCapRotationStrategy {
} }
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> { fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let benchmark = ctx let benchmark =
.data ctx.data
.benchmark(ctx.decision_date) .benchmark(ctx.decision_date)
.ok_or(BacktestError::MissingBenchmark { .ok_or(BacktestError::MissingBenchmark {
date: ctx.decision_date, date: ctx.decision_date,
@@ -257,15 +287,35 @@ impl Strategy for CnSmallCapRotationStrategy {
rebalance: true, rebalance: true,
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
order_intents: Vec::new(),
notes: vec![format!("skip-window active on {}", ctx.execution_date)], notes: vec![format!("skip-window active on {}", ctx.execution_date)],
diagnostics: vec![ diagnostics: vec![
"seasonal stop window approximated at daily granularity".to_string(), "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(), "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) = self.resolve_signal_series(ctx)?; 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 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
@@ -295,11 +345,14 @@ impl Strategy for CnSmallCapRotationStrategy {
1.0 - self.config.stop_loss_pct, 1.0 - self.config.stop_loss_pct,
1.0 + self.config.take_profit_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(
"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()); 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_before_ma, selection_diag) = self.selector.select_with_diagnostics(&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,
@@ -353,7 +406,10 @@ impl Strategy for CnSmallCapRotationStrategy {
)); ));
} }
if !ma_rejects.is_empty() { if !ma_rejects.is_empty() {
diagnostics.push(format!("ma_filter_rejections sample={}", ma_rejects.join("|"))); diagnostics.push(format!(
"ma_filter_rejections sample={}",
ma_rejects.join("|")
));
} }
if !selected.is_empty() { if !selected.is_empty() {
@@ -398,8 +454,581 @@ impl Strategy for CnSmallCapRotationStrategy {
rebalance, rebalance,
target_weights, target_weights,
exit_symbols, exit_symbols,
order_intents: Vec::new(),
notes, notes,
diagnostics, 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,
}
impl JqMicroCapStrategy {
pub fn new(config: JqMicroCapConfig) -> Self {
Self { config }
}
fn stop_loss_tolerance(&self, market: &crate::data::DailyMarketSnapshot) -> f64 {
market.effective_price_tick() * 6.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_board_lot(&self, quantity: u32) -> u32 {
(quantity / 100) * 100
}
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_board_lot((cash / sizing_price).floor() as u32);
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 project_order_value(
&self,
projected: &mut PortfolioState,
date: NaiveDate,
symbol: &str,
sizing_price: f64,
execution_price: f64,
order_value: f64,
) -> u32 {
let quantity = self.projected_buy_quantity(
projected.cash().min(order_value),
sizing_price,
execution_price,
);
if quantity == 0 {
return 0;
}
let gross_amount = execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
projected.apply_cash_delta(-cash_out);
projected.position_mut(symbol).buy(date, quantity, execution_price);
quantity
}
fn project_target_zero(
&self,
projected: &mut PortfolioState,
symbol: &str,
sell_price: f64,
) -> Option<u32> {
let quantity = projected.position(symbol)?.quantity;
if quantity == 0 {
return None;
}
let gross_amount = sell_price * quantity as f64;
let net_cash = gross_amount - self.sell_cost(gross_amount);
projected.position_mut(symbol).sell(quantity, sell_price).ok()?;
projected.apply_cash_delta(net_cash);
projected.prune_flat_positions();
Some(quantity)
}
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 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 sell_price = market.sell_price(PriceField::Last);
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(&mut projected, &position.symbol, sell_price);
}
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),
});
if let Some(market) = ctx.data.market(date, symbol) {
self.project_order_value(
&mut projected,
date,
symbol,
market.buy_price(PriceField::Last),
market.buy_price(PriceField::Last),
replacement_cash,
);
}
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(),
});
if let Some(price) = ctx
.data
.market(date, symbol)
.map(|market| market.sell_price(PriceField::Last))
{
self.project_target_zero(&mut projected, symbol, price);
}
}
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(),
});
if let Some(market) = ctx.data.market(date, symbol) {
self.project_order_value(
&mut projected,
date,
symbol,
market.buy_price(PriceField::Last),
market.buy_price(PriceField::Last),
fixed_buy_cash,
);
}
}
}
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
}

View File

@@ -1,7 +1,7 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::Serialize; use serde::Serialize;
use crate::data::{BenchmarkSnapshot, DataSet}; use crate::data::{BenchmarkSnapshot, DataSet, EligibleUniverseSnapshot};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BandRegime { pub enum BandRegime {
@@ -48,7 +48,10 @@ 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); fn select_with_diagnostics(
&self,
ctx: &SelectionContext<'_>,
) -> (Vec<UniverseCandidate>, SelectionDiagnostics);
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -103,7 +106,10 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
self.select_with_diagnostics(ctx).0 self.select_with_diagnostics(ctx).0
} }
fn select_with_diagnostics(&self, ctx: &SelectionContext<'_>) -> (Vec<UniverseCandidate>, SelectionDiagnostics) { 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 { let mut diagnostics = SelectionDiagnostics {
@@ -125,78 +131,24 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
rejection_examples: Vec::new(), rejection_examples: Vec::new(),
}; };
diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len();
diagnostics.market_cap_missing_count = diagnostics
.factor_total
.saturating_sub(ctx.data.eligible_universe_on(ctx.decision_date).len());
let eligible = ctx.data.eligible_universe_on(ctx.decision_date);
let start_idx = lower_bound_by_market_cap(eligible, min_cap);
let mut selected = Vec::new(); let mut selected = Vec::new();
for factor in ctx.data.factor_snapshots_on(ctx.decision_date) { for factor in eligible.iter().skip(start_idx) {
diagnostics.factor_total += 1; if factor.market_cap_bn > max_cap {
break;
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 diagnostics.rejection_examples.len() < 12 { selected.push(to_universe_candidate(factor, min_cap, max_cap));
diagnostics.rejection_examples.push(format!("{}: market_cap missing_or_non_positive", factor.symbol));
}
continue;
} }
let Some(candidate) = ctx.data.candidate(ctx.decision_date, &factor.symbol) else { diagnostics.out_of_band_count = eligible.len().saturating_sub(selected.len());
diagnostics.candidate_missing_count += 1;
if diagnostics.rejection_examples.len() < 12 {
diagnostics.rejection_examples.push(format!("{}: candidate snapshot missing", factor.symbol));
}
continue;
};
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| {
left.market_cap_bn
.partial_cmp(&right.market_cap_bn)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| left.symbol.cmp(&right.symbol))
});
diagnostics.selected_before_limit = selected.len(); diagnostics.selected_before_limit = selected.len();
if selected.len() > self.top_n { if selected.len() > self.top_n {
selected.truncate(self.top_n); selected.truncate(self.top_n);
@@ -206,3 +158,31 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
(selected, diagnostics) (selected, diagnostics)
} }
} }
fn lower_bound_by_market_cap(rows: &[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
}
fn to_universe_candidate(
factor: &EligibleUniverseSnapshot,
band_low: f64,
band_high: f64,
) -> UniverseCandidate {
UniverseCandidate {
symbol: factor.symbol.clone(),
market_cap_bn: factor.market_cap_bn,
free_float_cap_bn: factor.free_float_cap_bn,
band_low,
band_high,
}
}

View File

@@ -2,12 +2,8 @@ use chrono::NaiveDate;
use fidc_core::cost::CostModel; use fidc_core::cost::CostModel;
use fidc_core::rules::EquityRuleHooks; use fidc_core::rules::EquityRuleHooks;
use fidc_core::{ use fidc_core::{
CandidateEligibility, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot,
ChinaAShareCostModel, OrderSide, Position, PriceField,
ChinaEquityRuleHooks,
DailyMarketSnapshot,
OrderSide,
Position,
}; };
fn d(year: i32, month: u32, day: u32) -> NaiveDate { fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -32,15 +28,25 @@ fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapsho
DailyMarketSnapshot { DailyMarketSnapshot {
date: d(2024, 1, 3), date: d(2024, 1, 3),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: Some("2024-01-03 10:18:00".to_string()),
day_open: open,
open, open,
high: open, high: open,
low: open, low: open,
close: open, close: open,
last_price: open,
bid1: open,
ask1: open,
prev_close: 10.0, prev_close: 10.0,
volume: 1_000_000, volume: 1_000_000,
tick_volume: 100_000,
bid1_volume: 50_000,
ask1_volume: 50_000,
trading_phase: Some("continuous".to_string()),
paused: false, paused: false,
upper_limit, upper_limit,
lower_limit, lower_limit,
price_tick: 0.01,
} }
} }
@@ -69,14 +75,11 @@ fn china_rule_hooks_block_same_day_sell_under_t_plus_one() {
&snapshot(10.1, 11.0, 9.0), &snapshot(10.1, 11.0, 9.0),
&candidate(), &candidate(),
&position, &position,
PriceField::Open,
); );
assert!(!check.allowed); assert!(!check.allowed);
assert!(check assert!(check.reason.as_deref().unwrap_or_default().contains("t+1"));
.reason
.as_deref()
.unwrap_or_default()
.contains("t+1"));
} }
#[test] #[test]
@@ -86,20 +89,62 @@ fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() {
let mut position = Position::new("000001.SZ"); let mut position = Position::new("000001.SZ");
position.buy(d(2024, 1, 2), 1_000, 10.0); position.buy(d(2024, 1, 2), 1_000, 10.0);
let buy_check = hooks.can_buy(d(2024, 1, 3), &snapshot(11.0, 11.0, 9.0), &candidate); let buy_check = hooks.can_buy(
d(2024, 1, 3),
&snapshot(11.0, 11.0, 9.0),
&candidate,
PriceField::Open,
);
assert!(!buy_check.allowed); assert!(!buy_check.allowed);
assert!(buy_check assert!(
buy_check
.reason .reason
.as_deref() .as_deref()
.unwrap_or_default() .unwrap_or_default()
.contains("upper limit")); .contains("upper limit")
);
let sell_check = let sell_check = hooks.can_sell(
hooks.can_sell(d(2024, 1, 3), &snapshot(9.0, 11.0, 9.0), &candidate, &position); d(2024, 1, 3),
&snapshot(9.0, 11.0, 9.0),
&candidate,
&position,
PriceField::Open,
);
assert!(!sell_check.allowed); assert!(!sell_check.allowed);
assert!(sell_check assert!(
sell_check
.reason .reason
.as_deref() .as_deref()
.unwrap_or_default() .unwrap_or_default()
.contains("lower limit")); .contains("lower limit")
);
}
#[test]
fn china_rule_hooks_use_tick_size_tolerance_for_price_limits() {
let hooks = ChinaEquityRuleHooks;
let candidate = candidate();
let near_upper = DailyMarketSnapshot {
price_tick: 0.001,
..snapshot(10.9995, 11.0, 9.0)
};
let buy_check = hooks.can_buy(d(2024, 1, 3), &near_upper, &candidate, PriceField::Open);
assert!(!buy_check.allowed);
let near_lower = DailyMarketSnapshot {
price_tick: 0.001,
..snapshot(9.0005, 11.0, 9.0)
};
let mut position = Position::new("000001.SZ");
position.buy(d(2024, 1, 2), 1_000, 10.0);
let sell_check = hooks.can_sell(
d(2024, 1, 3),
&near_lower,
&candidate,
&position,
PriceField::Open,
);
assert!(!sell_check.allowed);
} }

View File

@@ -0,0 +1,54 @@
use chrono::NaiveDate;
use fidc_core::{CashReceivable, PortfolioState, Position};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
#[test]
fn cash_dividend_adjusts_cost_basis_and_returns_cash_delta() {
let mut position = Position::new("000001.SZ");
position.buy(d(2025, 1, 2), 1_000, 10.0);
let cash_delta = position.apply_cash_dividend(0.5);
assert!((cash_delta - 500.0).abs() < 1e-9);
assert_eq!(position.quantity, 1_000);
assert!((position.average_cost - 9.5).abs() < 1e-9);
assert!((position.last_price - 9.5).abs() < 1e-9);
}
#[test]
fn stock_split_scales_lots_quantity_and_average_cost() {
let mut position = Position::new("000001.SZ");
position.buy(d(2025, 1, 2), 1_000, 10.0);
let delta_quantity = position.apply_split_ratio(1.2);
assert_eq!(delta_quantity, 200);
assert_eq!(position.quantity, 1_200);
assert!((position.average_cost - (10.0 / 1.2)).abs() < 1e-9);
assert!((position.last_price - (10.0 / 1.2)).abs() < 1e-9);
assert_eq!(position.sellable_qty(d(2025, 1, 3)), 1_200);
}
#[test]
fn portfolio_settles_cash_receivable_on_payable_date() {
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio.add_cash_receivable(CashReceivable {
symbol: "000001.SZ".to_string(),
ex_date: d(2025, 1, 2),
payable_date: d(2025, 1, 5),
amount: 500.0,
reason: "cash_dividend 0.5".to_string(),
});
let settled_early = portfolio.settle_cash_receivables(d(2025, 1, 4));
assert!(settled_early.is_empty());
assert!((portfolio.cash() - 1_000_000.0).abs() < 1e-9);
let settled = portfolio.settle_cash_receivables(d(2025, 1, 5));
assert_eq!(settled.len(), 1);
assert!((portfolio.cash() - 1_000_500.0).abs() < 1e-9);
assert!(portfolio.cash_receivables().is_empty());
}

View File

@@ -0,0 +1,249 @@
use chrono::NaiveDate;
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
Instrument, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision,
};
use std::collections::{BTreeMap, BTreeSet};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
struct BuyThenHoldStrategy;
impl Strategy for BuyThenHoldStrategy {
fn name(&self) -> &str {
"buy-then-hold"
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, fidc_core::BacktestError> {
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
return Ok(StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Value {
symbol: "000001.SZ".to_string(),
value: 10_000.0,
reason: "seed_position".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
});
}
Ok(StrategyDecision::default())
}
}
#[test]
fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() {
let date1 = d(2025, 1, 2);
let date2 = d(2025, 1, 3);
let data = DataSet::from_components(
vec![
Instrument {
symbol: "000001.SZ".to_string(),
name: "Delisted".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: Some(date1),
status: "delisted".to_string(),
},
Instrument {
symbol: "000002.SZ".to_string(),
name: "Anchor".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
},
],
vec![
DailyMarketSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.0,
low: 10.0,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 5.0,
open: 5.0,
high: 5.1,
low: 4.9,
close: 5.0,
last_price: 5.0,
bid1: 4.99,
ask1: 5.01,
prev_close: 5.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 5.5,
lower_limit: 4.5,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 5.1,
open: 5.1,
high: 5.2,
low: 5.0,
close: 5.1,
last_price: 5.1,
bid1: 5.09,
ask1: 5.11,
prev_close: 5.0,
volume: 120_000,
tick_volume: 120_000,
bid1_volume: 120_000,
ask1_volume: 120_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 5.5,
lower_limit: 4.5,
price_tick: 0.01,
},
],
vec![
DailyFactorSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
},
DailyFactorSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
market_cap_bn: 30.0,
free_float_cap_bn: 28.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
},
DailyFactorSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
market_cap_bn: 31.0,
free_float_cap_bn: 29.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
},
],
vec![
CandidateEligibility {
date: date1,
symbol: "000001.SZ".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
},
CandidateEligibility {
date: date1,
symbol: "000002.SZ".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
},
CandidateEligibility {
date: date2,
symbol: "000002.SZ".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
},
],
vec![
BenchmarkSnapshot {
date: date1,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
},
BenchmarkSnapshot {
date: date2,
benchmark: "000300.SH".to_string(),
open: 101.0,
close: 101.0,
prev_close: 100.0,
volume: 1_100_000,
},
],
)
.expect("dataset");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut engine = BacktestEngine::new(
data,
BuyThenHoldStrategy,
broker,
BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(date1),
end_date: Some(date2),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
);
let result = engine.run().expect("backtest succeeds");
assert_eq!(result.fills.len(), 2);
assert!(result
.fills
.iter()
.any(|fill| fill.reason.contains("delisted_cash_settlement") && fill.symbol == "000001.SZ"));
assert!(result
.holdings_summary
.iter()
.all(|holding| holding.symbol != "000001.SZ"));
}

View File

@@ -0,0 +1,338 @@
use chrono::NaiveDate;
use fidc_core::{
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument,
OrderIntent, PortfolioState, PriceField, StrategyDecision,
};
use std::collections::{BTreeMap, BTreeSet};
#[test]
fn broker_executes_explicit_order_value_buy() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components(
vec![Instrument {
symbol: "000002.SZ".to_string(),
name: "Test".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.1,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: "000002.SZ".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
}],
vec![CandidateEligibility {
date,
symbol: "000002.SZ".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Value {
symbol: "000002.SZ".to_string(),
value: 100_000.0,
reason: "test_order_value".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("broker execution");
assert!(!report.fill_events.is_empty());
assert_eq!(report.fill_events[0].symbol, "000002.SZ");
assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Buy);
assert!(portfolio.position("000002.SZ").is_some());
assert!(portfolio.cash() < 1_000_000.0);
}
#[test]
fn broker_uses_instrument_round_lot_for_buy_sizing() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components(
vec![Instrument {
symbol: "688001.SH".to_string(),
name: "KSH".to_string(),
board: "KSH".to_string(),
round_lot: 200,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "688001.SH".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.1,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: "688001.SH".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 20.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
}],
vec![CandidateEligibility {
date,
symbol: "688001.SH".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(10_500.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Value {
symbol: "688001.SH".to_string(),
value: 10_500.0,
reason: "round_lot".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("broker execution");
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].quantity, 1000);
}
#[test]
fn same_day_sell_then_rebuy_reinserts_position_at_end() {
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
let instruments = symbols
.iter()
.map(|symbol| Instrument {
symbol: (*symbol).to_string(),
name: (*symbol).to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
})
.collect::<Vec<_>>();
let market = symbols
.iter()
.map(|symbol| DailyMarketSnapshot {
date,
symbol: (*symbol).to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.1,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
})
.collect::<Vec<_>>();
let factors = symbols
.iter()
.map(|symbol| DailyFactorSnapshot {
date,
symbol: (*symbol).to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
})
.collect::<Vec<_>>();
let candidates = symbols
.iter()
.map(|symbol| CandidateEligibility {
date,
symbol: (*symbol).to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
})
.collect::<Vec<_>>();
let data = DataSet::from_components(
instruments,
market,
factors,
candidates,
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio.position_mut("000001.SZ").buy(prev_date, 100, 10.0);
portfolio.position_mut("000002.SZ").buy(prev_date, 100, 10.0);
portfolio.position_mut("000003.SZ").buy(prev_date, 100, 10.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![
OrderIntent::TargetValue {
symbol: "000002.SZ".to_string(),
target_value: 0.0,
reason: "sell_then_rebuy".to_string(),
},
OrderIntent::Value {
symbol: "000002.SZ".to_string(),
value: 10_000.0,
reason: "sell_then_rebuy".to_string(),
},
],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("broker execution");
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
assert_eq!(
symbols,
vec![
"000001.SZ".to_string(),
"000003.SZ".to_string(),
"000002.SZ".to_string(),
]
);
}

View File

@@ -20,10 +20,11 @@ fn can_load_partitioned_snapshot_dir() {
fs::create_dir_all(dir.join("market/2024/01")).unwrap(); fs::create_dir_all(dir.join("market/2024/01")).unwrap();
fs::create_dir_all(dir.join("factors/2024/01")).unwrap(); fs::create_dir_all(dir.join("factors/2024/01")).unwrap();
fs::create_dir_all(dir.join("candidates/2024/01")).unwrap(); fs::create_dir_all(dir.join("candidates/2024/01")).unwrap();
fs::create_dir_all(dir.join("corporate_actions/2024/01")).unwrap();
fs::write( fs::write(
dir.join("instruments.csv"), dir.join("instruments.csv"),
"symbol,name,exchange,lot_size\n000001.SZ,PingAn,SZ,100\n", "symbol,name,board,round_lot,listed_at,delisted_at,status\n000001.SZ,PingAn,SZ,100,2020-01-01,,active\n",
) )
.unwrap(); .unwrap();
fs::write( fs::write(
@@ -33,7 +34,7 @@ fn can_load_partitioned_snapshot_dir() {
.unwrap(); .unwrap();
fs::write( fs::write(
dir.join("market/2024/01/2024-01-02.csv"), dir.join("market/2024/01/2024-01-02.csv"),
"date,symbol,open,high,low,close,prev_close,volume,paused,upper_limit,lower_limit\n2024-01-02,000001.SZ,10,10.5,9.9,10.2,10,100000,false,11,9\n", "date,symbol,open,high,low,close,prev_close,volume,paused,upper_limit,lower_limit,day_open,last_price,bid1,ask1,price_tick\n2024-01-02,000001.SZ,10,10.5,9.9,10.2,10,100000,false,11,9,10.1,10.15,10.14,10.16,0.01\n",
) )
.unwrap(); .unwrap();
fs::write( fs::write(
@@ -46,10 +47,48 @@ fn can_load_partitioned_snapshot_dir() {
"date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan\n2024-01-02,000001.SZ,false,false,false,true,true,false,false\n", "date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan\n2024-01-02,000001.SZ,false,false,false,true,true,false,false\n",
) )
.unwrap(); .unwrap();
fs::write(
dir.join("corporate_actions/2024/01/2024-01-02.csv"),
"date,symbol,payable_date,share_cash,share_bonus,share_gift,issue_quantity,issue_price,reform,adjust_factor\n2024-01-02,000001.SZ,2024-01-05,0.5,0.1,0.0,0,0,false,1.05\n",
)
.unwrap();
let data = DataSet::from_partitioned_dir(&dir).expect("partitioned dataset"); let data = DataSet::from_partitioned_dir(&dir).expect("partitioned dataset");
assert_eq!(data.benchmark_code(), "CSI300.DEMO"); assert_eq!(data.benchmark_code(), "CSI300.DEMO");
assert!(data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()).len() == 1); assert!(
data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap())
.len()
== 1
);
let market_rows = data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
let snapshot = market_rows
.first()
.expect("market snapshot");
assert_eq!(snapshot.day_open, 10.1);
assert_eq!(snapshot.last_price, 10.15);
assert_eq!(snapshot.price_tick, 0.01);
assert_eq!(
data.instruments()
.get("000001.SZ")
.expect("instrument")
.round_lot,
100
);
assert_eq!(
data.instruments()
.get("000001.SZ")
.expect("instrument")
.listed_at,
Some(chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap())
);
let actions = data.corporate_actions_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0].payable_date,
Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 5).unwrap())
);
assert!((actions[0].share_cash - 0.5).abs() < 1e-9);
assert!((actions[0].split_ratio() - 1.1).abs() < 1e-9);
let _ = fs::remove_dir_all(&dir); let _ = fs::remove_dir_all(&dir);
} }

View File

@@ -1,5 +1,8 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use fidc_core::{CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, Strategy, StrategyContext, PortfolioState}; use fidc_core::{
CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, JqMicroCapConfig,
JqMicroCapStrategy, PortfolioState, Strategy, StrategyContext,
};
use std::path::PathBuf; use std::path::PathBuf;
#[test] #[test]
@@ -28,9 +31,51 @@ fn strategy_emits_target_weights_and_diagnostics() {
assert!(decision.rebalance); assert!(decision.rebalance);
assert!(decision.rebalance); assert!(decision.rebalance);
assert!(!decision.diagnostics.is_empty()); assert!(!decision.diagnostics.is_empty());
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"); assert_eq!(strategy.name(), "cn-dyn-smallcap-band");
} }
#[test]
fn jq_strategy_emits_same_day_decision() {
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data/demo");
let data = DataSet::from_csv_dir(&data_dir).expect("demo data");
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let portfolio = PortfolioState::new(1_000_000.0);
let mut cfg = JqMicroCapConfig::jq_microcap();
cfg.benchmark_signal_symbol = "000001.SZ".to_string();
cfg.benchmark_short_ma_days = 3;
cfg.benchmark_long_ma_days = 5;
cfg.stock_short_ma_days = 3;
cfg.stock_mid_ma_days = 4;
cfg.stock_long_ma_days = 5;
let mut strategy = JqMicroCapStrategy::new(cfg);
let decision = strategy
.on_day(&StrategyContext {
execution_date,
decision_date: execution_date,
decision_index: 0,
data: &data,
portfolio: &portfolio,
})
.expect("jq decision");
assert!(!decision.rebalance);
assert!(
decision
.diagnostics
.iter()
.any(|line| line.contains("jq_microcap signal="))
);
assert!(
decision
.diagnostics
.iter()
.any(|line| line.contains("selected="))
);
}

View File

@@ -0,0 +1,36 @@
{
"strategyId": "jq-microcap",
"benchmark": {
"instrumentId": "000852.SH",
"fallbackInstrumentId": "000852.SH"
},
"signalSymbol": "000001.SH",
"engineConfig": {
"templateId": "joinquant-microcap-original",
"signalSymbol": "000001.SH",
"rankLimit": 40,
"refreshRate": 15,
"rsiRate": 1.0001,
"dynamicRange": {
"baseIndexLevel": 2000.0,
"baseCapFloor": 7.0,
"capSpan": 10.0,
"xs": 0.008
},
"stockMaFilter": {
"shortDays": 5,
"midDays": 10,
"longDays": 20,
"rsiRate": 1.0001
},
"indexThrottle": {
"shortDays": 5,
"longDays": 10,
"rsiRate": 1.0001,
"defensiveExposure": 0.5
},
"stopLossMultiplier": 0.93,
"takeProfitMultiplier": 1.07,
"skipWindows": []
}
}

View File

@@ -0,0 +1,21 @@
聚宽原始脚本 `/Users/boris/WorkSpace/fidc-backtest-engine/聚宽微盘股策略.py` 已映射到
`聚宽微盘股策略.engine_spec.json`
映射关系:
- `g.refresh_rate = 15` -> `engineConfig.refreshRate = 15`
- `g.stocknum = 40` -> `engineConfig.rankLimit = 40`
- `g.XS = 4 / 500` -> `engineConfig.dynamicRange.xs = 0.008`
- `current_price` 基准指数 `000001.XSHG` -> `signalSymbol = 000001.SH`
- `set_benchmark('000852.XSHG')` -> `benchmark.instrumentId = 000852.SH`
- `g.CloseRate = 1.07` -> `takeProfitMultiplier = 1.07`
- `g.LossRate = 0.93` -> `stopLossMultiplier = 0.93`
- `g.RSIRate = 1.0001` -> `rsiRate = 1.0001`
- `g.TradeRate = 0.5` -> `indexThrottle.defensiveExposure = 0.5`
- 个股均线过滤 `5/10/20` -> `stockMaFilter.short/mid/longDays`
- 指数均线过滤 `5/10` -> `indexThrottle.short/longDays`
说明:
- 原脚本里 `validate_date()` 虽然定义了停运窗口,但 `check_stocks()` 里紧接着把 `g.OpenYN = 1` 强制打开,
所以实际执行日志中停运窗口无效。当前 spec 因此使用 `skipWindows = []`,以匹配真实聚宽执行结果。

654
聚宽微盘股策略.py Normal file
View File

@@ -0,0 +1,654 @@
'''
设定的市值17—26亿 可以根据指数的变化来更改
比如3300点集中在15—25亿市值最小的四十只 3400点 集中在17—27亿
对应指数乘以一个系数 对应市值选出40只
'''
from jqdata import *
from datetime import datetime, timedelta
## 初始化函数,设定要操作的股票、基准等等
def initialize(context):
set_benchmark('000852.XSHG') #对标中证1000
# True为开启动态复权模式使用真实价格交易
set_option('use_real_price', True)
# 设定成交量比例
set_option('order_volume_ratio', 1)
# 股票类交易手续费是:买入时佣金万分之三,卖出时佣金万分之三加千分之一印花税, 每笔交易佣金最低扣5块钱
set_order_cost(OrderCost(open_tax=0, close_tax=0.001, \
open_commission=0.0003, close_commission=0.0003,\
close_today_commission=0, min_commission=5), type='stock')
# 交易日计时器
g.days = 0
# 分仓常量参数,无须人为修改
g.TR = 1;
# 区间最高价
g.summit = {}
g.muster = []
# 运行状态 1/运行; 0/停运
g.OpenYN = 1
# 开始范围
#g.mystart = 13
# 截至范围
#g.myend = 23
# 调仓频率
g.refresh_rate = 15
# 运行函数1
run_daily(trade, time='10:18')
# 运行函数2
run_daily(CPtrade, time='10:17')
# 持仓数量
g.stocknum = 40
# 上证指数对应系数
g.XS = 4/500
#止盈比率
g.CloseRate = 1.07
#止损比率
g.LossRate = 0.93
# 均线上涨比率
g.RSIRate = 1.0001
# 保证金仓位比率: 1/2为半仓
g.TradeRate = 0.5
# 调试日志展示数量
g.debug_log_limit = 60
g.debug_boundary_window = 5
g.market_cap_map = {}
def _fmt_num(value, digits=2):
try:
return ('%.' + str(digits) + 'f') % float(value)
except Exception:
return str(value)
def _safe_bar_values(bar_data, field):
try:
values = bar_data[field]
except Exception:
values = getattr(bar_data, field)
try:
return [float(x) for x in list(values)]
except Exception:
try:
return list(values)
except Exception:
return []
def _safe_bar_last(bar_data, field):
values = _safe_bar_values(bar_data, field)
if values:
return values[-1]
return None
def _market_cap_text(stock):
cap = getattr(g, 'market_cap_map', {}).get(stock)
if cap is None:
return 'None'
return _fmt_num(cap, 2) + '亿'
def _describe_stock(stock, extras=None):
parts = [stock, '市值=' + _market_cap_text(stock)]
if extras:
parts.extend(extras)
return ','.join(parts)
def _log_detail_items(label, details, limit=None, chunk=10):
total = len(details)
if total == 0:
log.info(label + ': 0')
return
if limit is None:
limit = total
shown = details[:limit]
log.info('%s: 总数=%d, 展示=%d' % (label, total, len(shown)))
for idx in range(0, len(shown), chunk):
part = shown[idx:idx + chunk]
log.info('%s[%d-%d]: %s' % (
label,
idx + 1,
idx + len(part),
' | '.join(part)
))
if total > limit:
log.info('%s: 其余%d项省略' % (label, total - limit))
def _log_market_cap_snapshot(df, caller):
if df is None or len(df) == 0:
log.info('市值筛选快照[%s]: 无数据' % caller)
return
top_n = min(len(df), g.debug_log_limit)
top_details = []
for i in range(top_n):
row = df.iloc[i]
top_details.append('%d.%s:%s亿' % (
i + 1,
row['code'],
_fmt_num(row['market_cap'], 2)
))
_log_detail_items('市值排序前段[%s]' % caller, top_details, limit=top_n, chunk=8)
start_idx = max(0, g.stocknum - g.debug_boundary_window - 1)
end_idx = min(len(df), g.stocknum + g.debug_boundary_window)
boundary_details = []
for i in range(start_idx, end_idx):
row = df.iloc[i]
boundary_details.append('%d.%s:%s亿' % (
i + 1,
row['code'],
_fmt_num(row['market_cap'], 2)
))
_log_detail_items('市值排序边界[%s]' % caller, boundary_details, limit=len(boundary_details), chunk=5)
## 选出小市值股票
def check_stocks(context, caller='unknown'):
g.today = context.current_dt
validate_date()
#不停运参数
g.OpenYN = 1
log.info('选股开始[%s]: 日期=%s, g.days=%d, refresh_rate=%d, 持仓数=%d' % (
caller,
context.current_dt.strftime('%Y-%m-%d %H:%M:%S'),
g.days,
g.refresh_rate,
len(context.portfolio.positions)
))
# 检查日期是否在范围内
if g.OpenYN == 0:
log.warn("该时段属于停运==================范围")
return []
else:
# g.OpenYN = 1
g.security = '000001.XSHG'
#000852.XSHG
close_data = get_bars(g.security, count=1, unit='1d', fields=['close'])
# 取得过去五天的平均价格
MA5 = close_data['close'].mean()
###################################################
close5_data = get_bars(g.security, count=5, unit='1d', fields=['close'])
# 获取股票的收盘价
close10_data = get_bars(g.security, count=10, unit='1d', fields=['close'])
# 取得过去五天的平均价格
MA5 = close5_data['close'].mean()
# 取得过去十天的平均价格
MA10 = close10_data['close'].mean()
#5日线下穿,则半仓交易
if MA5 < MA10*g.RSIRate:
g.TR = g.TradeRate
elif MA5 >= MA10*g.RSIRate:
g.TR = 1
###################################################
close5_list = _safe_bar_values(close5_data, 'close')
log.info('指数均线调试[%s][%s] close[-5:]=%s, ma5=%s, ma10=%s, ma5<ma10*RSIRate=%s, TR=%s' % (
caller,
context.current_dt.strftime('%Y-%m-%d'),
str([round(float(v), 2) for v in close5_list]),
_fmt_num(MA5, 4),
_fmt_num(MA10, 4),
str(MA5 < MA10 * g.RSIRate),
_fmt_num(g.TR, 4)
))
# 取得上一时间点价格
current_price = close_data['close'][-1]
log.info('中证指数(current_price)'+str(current_price))
if current_price == 2000:
g.mystart = 7
g.myend = 17
elif current_price > 0:
#Y = (current_price - 3000) *g.XS + 14
Y = (current_price - 2000) *g.XS + 7
g.mystart = Y
g.myend = Y + 10
# mystart = g.start
# myend = g.end
mystart = round(g.mystart)
myend = round(g.myend)
log.info('价格区间为:'+ str(mystart) + '~'+str(myend))
# 设定查询条件
q = query(
valuation.code,
valuation.market_cap
).filter(
valuation.market_cap.between(mystart,myend)
).order_by(
valuation.market_cap.asc()
)
# 选出低市值的股票构成buylist
df = get_fundamentals(q)
g.market_cap_map = {}
if df is not None and len(df) > 0:
for _, row in df.iterrows():
try:
g.market_cap_map[row['code']] = float(row['market_cap'])
except Exception:
g.market_cap_map[row['code']] = None
log.info('市值筛选结果[%s]: %d只股票' % (caller, 0 if df is None else len(df)))
_log_market_cap_snapshot(df, caller)
buylist =list(df['code'])
# 过滤停牌ST科创新股1元股
buylist = filter_paused_stock(buylist, caller=caller)
final_list = buylist[:g.stocknum]
g.muster = final_list
_log_detail_items(
'最终选股[%s]' % caller,
[_describe_stock(stock) for stock in final_list],
limit=len(final_list),
chunk=8
)
boundary_slice = buylist[max(0, g.stocknum - g.debug_boundary_window): min(len(buylist), g.stocknum + g.debug_boundary_window)]
_log_detail_items(
'最终选股边界[%s]' % caller,
[_describe_stock(stock) for stock in boundary_slice],
limit=len(boundary_slice),
chunk=5
)
return final_list
def before_trading_start(context):
# 取得当前日期
g.todayDT = context.current_dt
g.today = context.current_dt.strftime('%Y-%m-%d')
g.start = context.current_dt + timedelta(-2)
g.market_cap_map = {}
## 交易函数
def CPtrade(context):
## 选股
stock_list = check_stocks(context, caller='CPtrade')
if g.OpenYN == 0:
#log.warn("日期属于范围")
## 获取持仓列表
sell_list = list(context.portfolio.positions.keys())
# 如果有持仓,则卖出
if len(sell_list) > 0 :
for stock in sell_list:
order_target_value(stock, 0)
return
## 交易函数
def trade(context):
## 选股
stock_list = check_stocks(context, caller='trade')
if g.OpenYN == 0:
#log.warn("日期属于范围")
## 获取持仓列表
sell_list = list(context.portfolio.positions.keys())
# 如果有持仓,则卖出
if len(sell_list) > 0 :
for stock in sell_list:
order_target_value(stock, 0)
return
g.changeYN = 0
curr_data = get_current_data()
log.info('交易调试[trade]: 日期=%s, g.days=%d, refresh_hit=%s, TR=%s, cash=%s, total_value=%s, 持仓数=%d, 目标池数=%d' % (
context.current_dt.strftime('%Y-%m-%d %H:%M:%S'),
g.days,
str(g.days % g.refresh_rate == 0),
_fmt_num(g.TR, 4),
_fmt_num(context.portfolio.cash, 2),
_fmt_num(context.portfolio.total_value, 2),
len(context.portfolio.positions),
len(stock_list)
))
_log_detail_items(
'交易目标池[trade]',
[_describe_stock(stock) for stock in stock_list],
limit=len(stock_list),
chunk=8
)
# --------------------------------------------------------------------------
for stockPos in context.portfolio.positions:
# SS、记录股票峰值信息
# if g.summit.get(stock, 0)<data[stock].high: g.summit[stock]=data[stock].high
# S0、取得最近几天股票价格信息
grid = get_price(stockPos, start_date=g.start, end_date=g.today, fields=['open', 'high', 'low', 'close', 'high_limit', 'paused'])
# SP、跳过退市、停牌、无效数据
# if grid.paused[-1]: continue
# S1、目前持仓不在预选股票池中(g.muster)则清仓
# if stock not in g.muster:
# order_target(stock,0)
# log.info("市值清仓:%s" % (stock))
# S2、回撤10%则清仓
hold = context.portfolio.positions[stockPos]
current_price = curr_data[stockPos].last_price
avg_cost = hold.avg_cost
return_ratio = current_price / avg_cost - 1 if avg_cost else 0
grid_high_limit = _safe_bar_last(grid, 'high_limit')
grid_close = _safe_bar_last(grid, 'close')
grid_paused = _safe_bar_last(grid, 'paused')
stop_hit = (current_price / avg_cost < g.LossRate) if avg_cost else False
profit_hit = (current_price < grid_high_limit and current_price / avg_cost > g.CloseRate) if (avg_cost and grid_high_limit is not None) else False
log.info('止盈止损评估[%s] %s: qty=%s, avg_cost=%s, current=%s, return=%s%%, stop_threshold=%s, profit_threshold=%s, high_limit=%s, bar.close=%s, paused=%s, in_target=%s, stop_hit=%s, profit_hit=%s' % (
context.current_dt.strftime('%Y-%m-%d'),
stockPos,
str(getattr(hold, 'total_amount', getattr(hold, 'amount', 'None'))),
_fmt_num(avg_cost, 4),
_fmt_num(current_price, 4),
_fmt_num(return_ratio * 100, 2),
_fmt_num(g.LossRate, 4),
_fmt_num(g.CloseRate, 4),
_fmt_num(grid_high_limit, 4),
_fmt_num(grid_close, 4),
str(grid_paused),
str(stockPos in stock_list),
str(stop_hit),
str(profit_hit)
))
if stop_hit:
order_target_value(stockPos, 0)
g.changeYN = 1
log.error('止损清仓:%s,当前价=%.2f,成本价=%.2f,收益率=%.2f%%,high_limit=%s,bar.close=%s' % (
stockPos,
current_price,
hold.avg_cost,
return_ratio * 100,
_fmt_num(grid_high_limit, 4),
_fmt_num(grid_close, 4)
))
# S3、今日高开、今日未涨停则清仓
elif profit_hit:
order_target_value(stockPos, 0)
g.changeYN = 1
log.warn('止盈清仓:%s,当前价=%s,成本价=%s,收益率=%s%%,high_limit=%s,bar.close=%s' % (
stockPos,
_fmt_num(current_price, 4),
_fmt_num(hold.avg_cost, 4),
_fmt_num(return_ratio * 100, 2),
_fmt_num(grid_high_limit, 4),
_fmt_num(grid_close, 4)
))
else:
g.changeYN = 0
if g.changeYN == 1:
sell_list = list(context.portfolio.positions.keys())
replacement_candidates = [stock for stock in stock_list if stockPos != stock and stock not in sell_list]
_log_detail_items(
'补仓候选[%s][%s]' % (context.current_dt.strftime('%Y-%m-%d'), stockPos),
[_describe_stock(stock) for stock in replacement_candidates],
limit=min(len(replacement_candidates), g.debug_log_limit),
chunk=6
)
# log.warn('portfoliocash' + str(context.portfolio.cash))
for stock in stock_list:
if len(context.portfolio.positions.keys()) < g.stocknum:
## 获取持仓列表
sell_list = list(context.portfolio.positions.keys())
if stockPos != stock and stock not in sell_list :
## 分配资金
if len(context.portfolio.positions) < g.stocknum :
Num = g.stocknum - len(context.portfolio.positions)
Cash = context.portfolio.cash * g.TR / Num
#gridbuy = get_price(stock, start_date=g.start, end_date=g.today, fields=['open', 'high', 'low_limit', 'close', 'high_limit'])
#if gridbuy.open[-1] > gridbuy.low_limit[-1]:
log.info('补仓买入:卖出=%s,买入=%s,Cash=%s,Num=%d,TR=%s,持仓数=%d' % (
stockPos,
stock,
_fmt_num(Cash, 2),
Num,
_fmt_num(g.TR, 4),
len(context.portfolio.positions)
))
order_value(stock, Cash)
#else :
# log.warn("忽略跌停股票:" + stock)
break
else:
continue
if g.days%g.refresh_rate == 0:
log.info('定期调仓触发:日期=%s,g.days=%d,refresh_rate=%d,TR=%s' % (
context.current_dt.strftime('%Y-%m-%d'),
g.days,
g.refresh_rate,
_fmt_num(g.TR, 4)
))
## 获取持仓列表
sell_list = list(context.portfolio.positions.keys())
rebalance_sell_list = [stock for stock in sell_list if stock not in stock_list]
_log_detail_items(
'定期调仓卖出名单',
[_describe_stock(stock) for stock in rebalance_sell_list],
limit=len(rebalance_sell_list),
chunk=8
)
# 如果有持仓,则卖出
if len(sell_list) > 0 :
for stock in sell_list:
if stock not in stock_list:
log.info('定期调仓卖出:%s' % stock)
order_target_value(stock, 0)
## 分配资金
if len(context.portfolio.positions) < g.stocknum :
Num = g.stocknum - len(context.portfolio.positions)
# Cash = context.portfolio.cash/Num * g.TR
Cash = context.portfolio.cash * g.TR / g.stocknum
else:
Cash = 0
log.info('定期调仓资金分配Cash=%s,Num=%s,cash=%s,total_value=%s' % (
_fmt_num(Cash, 2),
str(Num if len(context.portfolio.positions) < g.stocknum else 0),
_fmt_num(context.portfolio.cash, 2),
_fmt_num(context.portfolio.total_value, 2)
))
## 买入股票
for stock in stock_list:
if len(context.portfolio.positions.keys()) < g.stocknum:
if stock not in sell_list:
log.info('定期调仓买入:%s,Cash=%s' % (stock, _fmt_num(Cash, 2)))
order_value(stock, Cash)
# 天计数加一
g.days = 1
else:
g.days += 1
# 过滤日期
# 一月十号到三十一号
# 四月十号到四月二十九日 期间
# 八月十日到八月三十一日
# 十月二十日 到十月三十日
def validate_date():
# date = g.todayDT.strftime('%Y-%m-%d')
# date = datetime.strptime(g.todayDT, "%Y-%m-%d")
date = g.today
# 检查是否是4月10日到4月29日之间
if date.month == 1 and 15 <= date.day <= 30:
g.OpenYN = 0
elif date.month == 4 and 15 <= date.day <= 29:
g.OpenYN = 0
elif date.month == 8 and 15 <= date.day <= 31:
g.OpenYN = 0
elif date.month == 10 and 20 <= date.day <= 30:
g.OpenYN = 0
elif date.month == 12 and 20 <= date.day <= 30:
g.OpenYN = 0
else :
g.OpenYN = 1
# 过滤停牌股票
def filter_paused_stock(stock_list, caller='unknown'):
curr_data = get_current_data()
raw_count = len(stock_list)
risk_filtered = []
risk_removed = []
for stock in stock_list:
reasons = []
if curr_data[stock].day_open == curr_data[stock].high_limit:
reasons.append('涨停开盘')
if curr_data[stock].day_open == curr_data[stock].low_limit:
reasons.append('跌停开盘')
if curr_data[stock].last_price == curr_data[stock].high_limit:
reasons.append('当前涨停')
if curr_data[stock].last_price == curr_data[stock].low_limit:
reasons.append('当前跌停')
if curr_data[stock].paused:
reasons.append('停牌')
if curr_data[stock].is_st:
reasons.append('ST')
if 'ST' in curr_data[stock].name:
reasons.append('名称含ST')
if '*' in curr_data[stock].name:
reasons.append('名称含*')
if '退' in curr_data[stock].name:
reasons.append('名称含退')
if stock.startswith('688'):
reasons.append('科创板')
if reasons:
risk_removed.append(_describe_stock(stock, [
'原因=' + '/'.join(reasons),
'open=' + _fmt_num(curr_data[stock].day_open, 2),
'last=' + _fmt_num(curr_data[stock].last_price, 2),
'high_limit=' + _fmt_num(curr_data[stock].high_limit, 2),
'low_limit=' + _fmt_num(curr_data[stock].low_limit, 2),
'name=' + str(curr_data[stock].name)
]))
else:
risk_filtered.append(stock)
log.info('风险过滤[%s]: %d -> %d, 移除=%d' % (
caller,
raw_count,
len(risk_filtered),
len(risk_removed)
))
_log_detail_items(
'风险过滤移除[%s]' % caller,
risk_removed,
limit=min(len(risk_removed), g.debug_log_limit),
chunk=4
)
one_yuan_filtered = []
one_yuan_removed = []
for stock in risk_filtered:
if curr_data[stock].day_open > 1:
one_yuan_filtered.append(stock)
else:
one_yuan_removed.append(_describe_stock(stock, [
'原因=1元股过滤',
'open=' + _fmt_num(curr_data[stock].day_open, 2),
'last=' + _fmt_num(curr_data[stock].last_price, 2)
]))
log.info('1元股过滤[%s]: %d -> %d, 移除=%d' % (
caller,
len(risk_filtered),
len(one_yuan_filtered),
len(one_yuan_removed)
))
_log_detail_items(
'1元股过滤移除[%s]' % caller,
one_yuan_removed,
limit=min(len(one_yuan_removed), g.debug_log_limit),
chunk=4
)
new_list = []
ma_pass_details = {}
ma_removed_details = []
for stock in one_yuan_filtered:
# 获取股票的收盘价
close5_data = get_bars(stock, count=5, unit='1d', fields=['close'])
# 获取股票的收盘价
close10_data = get_bars(stock, count=10, unit='1d', fields=['close'])
# 获取股票的收盘价
close20_data = get_bars(stock, count=20, unit='1d', fields=['close'])
# 取得过去五天的平均价格
MA5 = close5_data['close'].mean()
# 取得过去十天的平均价格
MA10 = close10_data['close'].mean()
# 取得过去二十天的平均价格
MA20 = close20_data['close'].mean()
# 取得上一时间点价格
# current_price = close_data['close'][-1]
close5_list = [round(float(v), 2) for v in _safe_bar_values(close5_data, 'close')]
ma_condition_1 = MA5 > MA10 * g.RSIRate
ma_condition_2 = MA10 > MA20
detail = _describe_stock(stock, [
'close[-5:]=' + str(close5_list),
'ma5=' + _fmt_num(MA5, 4),
'ma10=' + _fmt_num(MA10, 4),
'ma20=' + _fmt_num(MA20, 4),
'ma5>ma10*RSIRate=' + str(ma_condition_1),
'ma10>ma20=' + str(ma_condition_2)
])
if MA5 > MA10*g.RSIRate> MA20*g.RSIRate:
new_list.append(stock)
ma_pass_details[stock] = detail
else:
ma_removed_details.append(detail)
log.info('均线过滤[%s]: %d -> %d, 移除=%d' % (
caller,
len(one_yuan_filtered),
len(new_list),
len(ma_removed_details)
))
_log_detail_items(
'均线过滤移除[%s]' % caller,
ma_removed_details,
limit=min(len(ma_removed_details), g.debug_log_limit),
chunk=3
)
ma_boundary_details = [ma_pass_details[stock] for stock in new_list[:min(len(new_list), g.stocknum + g.debug_boundary_window)]]
_log_detail_items(
'均线通过前段[%s]' % caller,
ma_boundary_details,
limit=len(ma_boundary_details),
chunk=3
)
return new_list