Improve jq microcap execution semantics
This commit is contained in:
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
63
README.md
63
README.md
@@ -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 分钟平台还差什么
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
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()?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut engine = BacktestEngine::new(data, strategy, broker, config);
|
|
||||||
let result = 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,15 +299,17 @@ fn build_summary(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.rev()
|
.rev()
|
||||||
.map(|row| json!({
|
.map(|row| {
|
||||||
"date": row.date.to_string(),
|
json!({
|
||||||
"cash": row.cash,
|
"date": row.date.to_string(),
|
||||||
"marketValue": row.market_value,
|
"cash": row.cash,
|
||||||
"totalEquity": row.total_equity,
|
"marketValue": row.market_value,
|
||||||
"benchmarkClose": row.benchmark_close,
|
"totalEquity": row.total_equity,
|
||||||
"notes": row.notes,
|
"benchmarkClose": row.benchmark_close,
|
||||||
"diagnostics": row.diagnostics,
|
"notes": row.notes,
|
||||||
}))
|
"diagnostics": row.diagnostics,
|
||||||
|
})
|
||||||
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let trades_preview = fills
|
let trades_preview = fills
|
||||||
.iter()
|
.iter()
|
||||||
@@ -242,16 +318,18 @@ fn build_summary(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.rev()
|
.rev()
|
||||||
.map(|row| json!({
|
.map(|row| {
|
||||||
"date": row.date.to_string(),
|
json!({
|
||||||
"symbol": row.symbol,
|
"date": row.date.to_string(),
|
||||||
"side": format!("{:?}", row.side),
|
"symbol": row.symbol,
|
||||||
"quantity": row.quantity,
|
"side": format!("{:?}", row.side),
|
||||||
"price": row.price,
|
"quantity": row.quantity,
|
||||||
"grossAmount": row.gross_amount,
|
"price": row.price,
|
||||||
"netCashFlow": row.net_cash_flow,
|
"grossAmount": row.gross_amount,
|
||||||
"reason": row.reason,
|
"netCashFlow": row.net_cash_flow,
|
||||||
}))
|
"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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,22 +307,55 @@ 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 {
|
report.order_events.push(OrderEvent {
|
||||||
date,
|
date,
|
||||||
symbol: symbol.to_string(),
|
symbol: symbol.to_string(),
|
||||||
side: OrderSide::Sell,
|
side: OrderSide::Sell,
|
||||||
requested_quantity: requested_qty,
|
requested_quantity: requested_qty,
|
||||||
filled_quantity: 0,
|
filled_quantity: 0,
|
||||||
status: OrderStatus::Rejected,
|
status,
|
||||||
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
|
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
|
||||||
});
|
});
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let sellable = position.sellable_qty(date);
|
let sellable = position.sellable_qty(date);
|
||||||
let filled_qty = requested_qty.min(sellable);
|
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 {
|
||||||
|
date,
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
side: OrderSide::Sell,
|
||||||
|
requested_quantity: requested_qty,
|
||||||
|
filled_quantity: 0,
|
||||||
|
status: OrderStatus::Rejected,
|
||||||
|
reason: format!("{reason}: {limit_reason}"),
|
||||||
|
});
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
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,38 +763,304 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pub enum OrderSide {
|
|||||||
pub enum OrderStatus {
|
pub enum OrderStatus {
|
||||||
Filled,
|
Filled,
|
||||||
PartiallyFilled,
|
PartiallyFilled,
|
||||||
|
Canceled,
|
||||||
Rejected,
|
Rejected,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
|
|||||||
437
crates/fidc-core/src/metrics.rs
Normal file
437
crates/fidc-core/src/metrics.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,16 +251,17 @@ 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;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,38 +185,46 @@ 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()
|
.market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days);
|
||||||
.ok_or_else(|| BacktestError::Execution(
|
if closes.len() >= self.config.long_ma_days {
|
||||||
"cn-dyn-smallcap-band requires a real signal_symbol; degraded fallback disabled"
|
let close = ctx
|
||||||
.to_string(),
|
.data
|
||||||
))?;
|
.price(ctx.decision_date, symbol, PriceField::Close)
|
||||||
|
.ok_or_else(|| BacktestError::MissingPrice {
|
||||||
|
date: ctx.decision_date,
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
field: "close",
|
||||||
|
})?;
|
||||||
|
return Ok((symbol.to_string(), closes, close));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let closes = ctx
|
let closes = ctx
|
||||||
.data
|
.data
|
||||||
.market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days);
|
.benchmark_closes_up_to(ctx.decision_date, 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!(
|
return Err(BacktestError::Execution(format!(
|
||||||
"real signal series missing or insufficient for {} on/before {}; degraded fallback disabled",
|
"signal series insufficient on/before {} for long_ma_days={}",
|
||||||
symbol, ctx.decision_date
|
ctx.decision_date, self.config.long_ma_days
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let close = ctx
|
let close = ctx
|
||||||
.data
|
.data
|
||||||
.price(ctx.decision_date, symbol, PriceField::Close)
|
.benchmark(ctx.decision_date)
|
||||||
.ok_or_else(|| BacktestError::MissingPrice {
|
.ok_or(BacktestError::MissingBenchmark {
|
||||||
date: ctx.decision_date,
|
date: ctx.decision_date,
|
||||||
symbol: symbol.to_string(),
|
})?
|
||||||
field: "close",
|
.close;
|
||||||
})?;
|
Ok((ctx.data.benchmark_code().to_string(), closes, close))
|
||||||
Ok((symbol.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,12 +274,12 @@ 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,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if self.config.in_skip_window(ctx.execution_date) {
|
if self.config.in_skip_window(ctx.execution_date) {
|
||||||
self.last_gross_exposure = Some(0.0);
|
self.last_gross_exposure = Some(0.0);
|
||||||
@@ -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,16 +345,19 @@ 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) =
|
||||||
decision_date: ctx.decision_date,
|
self.selector.select_with_diagnostics(&SelectionContext {
|
||||||
benchmark,
|
decision_date: ctx.decision_date,
|
||||||
reference_level: signal_level,
|
benchmark,
|
||||||
data: ctx.data,
|
reference_level: signal_level,
|
||||||
});
|
data: ctx.data,
|
||||||
|
});
|
||||||
let before_ma_count = selected_before_ma.len();
|
let before_ma_count = selected_before_ma.len();
|
||||||
let mut ma_rejects = Vec::new();
|
let mut ma_rejects = Vec::new();
|
||||||
let selected = selected_before_ma
|
let selected = selected_before_ma
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
diagnostics.rejection_examples.push(format!("{}: market_cap missing_or_non_positive", factor.symbol));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
selected.push(to_universe_candidate(factor, min_cap, max_cap));
|
||||||
let Some(candidate) = ctx.data.candidate(ctx.decision_date, &factor.symbol) else {
|
|
||||||
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| {
|
diagnostics.out_of_band_count = eligible.len().saturating_sub(selected.len());
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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!(
|
||||||
.reason
|
buy_check
|
||||||
.as_deref()
|
.reason
|
||||||
.unwrap_or_default()
|
.as_deref()
|
||||||
.contains("upper limit"));
|
.unwrap_or_default()
|
||||||
|
.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
|
||||||
|
.reason
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.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);
|
assert!(!sell_check.allowed);
|
||||||
assert!(sell_check
|
|
||||||
.reason
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.contains("lower limit"));
|
|
||||||
}
|
}
|
||||||
|
|||||||
54
crates/fidc-core/tests/corporate_actions.rs
Normal file
54
crates/fidc-core/tests/corporate_actions.rs
Normal 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());
|
||||||
|
}
|
||||||
249
crates/fidc-core/tests/delisting.rs
Normal file
249
crates/fidc-core/tests/delisting.rs
Normal 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"));
|
||||||
|
}
|
||||||
338
crates/fidc-core/tests/explicit_order_flow.rs
Normal file
338
crates/fidc-core/tests/explicit_order_flow.rs
Normal 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(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!(
|
||||||
.diagnostics
|
decision
|
||||||
.iter()
|
.diagnostics
|
||||||
.any(|line| line.contains("signal_symbol=")));
|
.iter()
|
||||||
|
.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="))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
36
data/demo/聚宽微盘股策略.engine_spec.json
Normal file
36
data/demo/聚宽微盘股策略.engine_spec.json
Normal 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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
21
data/demo/聚宽微盘股策略.engine_spec.md
Normal file
21
data/demo/聚宽微盘股策略.engine_spec.md
Normal 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
654
聚宽微盘股策略.py
Normal 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
|
||||||
Reference in New Issue
Block a user