Compare commits
289 Commits
v2026.4.30
...
203e17ce87
| Author | SHA1 | Date | |
|---|---|---|---|
| 203e17ce87 | |||
| 32a417d6d1 | |||
| 2de7127f02 | |||
| a5a9688599 | |||
| ad49fc89d3 | |||
| 6329a8a0da | |||
| 69b793cbb8 | |||
| f8f01a0987 | |||
| 4ce52a7af6 | |||
| ca0471799b | |||
| 0febd3d644 | |||
| c085730ca5 | |||
| 538edb907d | |||
| a854a4ec02 | |||
| ec098c6d39 | |||
| 17bac07a86 | |||
| 63da6bb1bd | |||
| e847ecd54c | |||
| 51ee4a3f54 | |||
| 8c4156948e | |||
| 89f8bb32d0 | |||
| e0a7eb8972 | |||
| 12da5a4704 | |||
| 749b5e3b9c | |||
| e74e2226d5 | |||
| 5a1534e51e | |||
| 22451300b1 | |||
| a50e59ab1d | |||
| 6d86eab021 | |||
| 6b306eecf2 | |||
| 90857fae0a | |||
| b37ebb81f1 | |||
| e37b8e1265 | |||
| 3ef4029c4a | |||
| cb189e3de4 | |||
| 4fee8e1d07 | |||
| 64298f09c1 | |||
| 60bfa28ef0 | |||
| 8c5a2ef611 | |||
| db1ffb5918 | |||
| fe39a75e6e | |||
| 1d33b29c27 | |||
| 831edfc8c6 | |||
| c3ab279d7d | |||
| 6820b63d56 | |||
| fdc099c960 | |||
| afb531da59 | |||
| 7c867f1788 | |||
| a3415095a7 | |||
| 3e3bebf3e0 | |||
| 99a21324db | |||
| 92d5801f63 | |||
| 551421818b | |||
| de1373deae | |||
| f9f9706900 | |||
| 74105f0fde | |||
| 72c667d790 | |||
| 82ed5b3e0b | |||
| b489abba6b | |||
| 203a20592a | |||
| 7059d3a8d0 | |||
| 289448d196 | |||
| c557656040 | |||
| 7189998699 | |||
| 00c9042c15 | |||
| d3c986e1f2 | |||
| 2f61bd8e57 | |||
| 61ad4119cf | |||
| ab31006d01 | |||
| 584a38c7a7 | |||
| 1d817c7f50 | |||
| b327bb074e | |||
| 8ed2b0df7f | |||
| a21ac83f21 | |||
| 13e15cc7c4 | |||
| ad6e168303 | |||
| 73627b1b2d | |||
| 1219b42046 | |||
| ba2470aefe | |||
| 8543c3ab6d | |||
| 9a16ceefbb | |||
| 549595c1c6 | |||
| 8125ea2e3b | |||
| 3e4270729b | |||
| 9aa156eb2a | |||
| 4e3ae3b378 | |||
| 339f85c27b | |||
| aff7fa309c | |||
| cb02041b3b | |||
| e8ecc037c9 | |||
| 652531ac63 | |||
| a8ffd36150 | |||
| 143a021067 | |||
| 487e1a38aa | |||
| e70d637ade | |||
| 8fa4ab24fb | |||
| 14810708f0 | |||
| 3e907d8e43 | |||
| bf457d94ce | |||
| e045ca5a49 | |||
| 84a50111c0 | |||
| 3fae717912 | |||
| 995dd96117 | |||
| c911e79d88 | |||
| f507c63069 | |||
| 723d2c8354 | |||
| 9ab813e74d | |||
| 69576f7e5b | |||
| 3a66c90f34 | |||
| 5481db63df | |||
| 5f2697540a | |||
| 8e4b3d15a4 | |||
| cbe135ed0d | |||
| 73dd006bb2 | |||
| fea09ce93c | |||
| 9fa588fef8 | |||
| f45a5fd0a7 | |||
| 25001fd3e4 | |||
| 3d98ec35e7 | |||
| 5bbe8959f4 | |||
| 3bb001c374 | |||
| c32926cc34 | |||
| 54fb92a780 | |||
| 27d6740dc5 | |||
| cab7c605dc | |||
| e77baffa10 | |||
| 179c4eaff5 | |||
| 32b6da5aca | |||
| 564a2fb9b2 | |||
| 25cc643f34 | |||
| daa0a9b4e6 | |||
| 97931c3766 | |||
| f796a85617 | |||
| 50120e0f9b | |||
| 8715a6171a | |||
| baa77c68e0 | |||
| b176d2ff6f | |||
| 7db0e8da1d | |||
| 754fc91376 | |||
| fb9d8f3b9a | |||
| cad8877b7a | |||
| eae82128ee | |||
| 9f188f6313 | |||
| 6b1afc975e | |||
| 49e883827e | |||
| 2900a40b38 | |||
| a59b687b62 | |||
| 8ba4b4d2c1 | |||
| eeaf061932 | |||
| c3101aa995 | |||
| ec3ec7a26f | |||
| 19b7a0c00c | |||
| 9e6eac557f | |||
| 1623994287 | |||
| fbc6da1a8f | |||
| 49981f2f3e | |||
| 4009fe0899 | |||
| dd8783c8c1 | |||
| 9562b8a280 | |||
| e83856baa9 | |||
| bb690e12c2 | |||
| 41237dccfd | |||
| 6067adc120 | |||
| 275dde61ae | |||
| ab36e6b613 | |||
| a131c761e5 | |||
| 380c34aa66 | |||
| 7f40cfdab0 | |||
| 6db480b91d | |||
| 02e2a20aff | |||
| 1bcedcee0f | |||
| ad405d130e | |||
| c83526a6a4 | |||
| 9bd19aa042 | |||
| 2f62d82420 | |||
| 7f809fd875 | |||
| 8495bf6ad8 | |||
| 9d41971d3f | |||
| c409d500b3 | |||
| d0ab59669f | |||
| d264e39285 | |||
| 5b34f3b55b | |||
| 192ac3f843 | |||
| 581d4e32d0 | |||
| bb87d69224 | |||
| 0714d1f77b | |||
| 78af8c3219 | |||
| fd27429713 | |||
| a270e368c8 | |||
| a368fd5d7f | |||
| 0f982887a3 | |||
| beebc5fa58 | |||
| f8bc0679ee | |||
| cdca7984ed | |||
| 816fc48077 | |||
| 144483be4c | |||
| eb4e77f8c5 | |||
| 6ddbdac9cd | |||
| ecb9a1cfaf | |||
| 61fc93abf1 | |||
| 7ce28e6d0f | |||
| 1a4936d250 | |||
| 9692557746 | |||
| 8df6bfd19c | |||
| 5e66e9799c | |||
| 5ecb0e7986 | |||
| c3a5161db1 | |||
| 57aebe97ec | |||
| 651174dc57 | |||
| 4b6301cb37 | |||
| 1db80e1e13 | |||
| 938f4fec13 | |||
| daa505152a | |||
| 02d4ea9ca7 | |||
| 3633905459 | |||
| 2265a5dc67 | |||
| 616d9cdce2 | |||
| 213deb6e99 | |||
| 7ff443898c | |||
| d7c1674c6c | |||
| 4f39ac7dfe | |||
| 8d24badcf2 | |||
| 6c7f7130cf | |||
| d8b6130428 | |||
| dae573e318 | |||
| 674e4b0b14 | |||
| 828b55c747 | |||
| 596d64280b | |||
| 1683d875a0 | |||
| ed4658ccd0 | |||
| bc39df0ee5 | |||
| 70695d8c92 | |||
| 0533e2db3a | |||
| 716149c06c | |||
| 0628dd528a | |||
| e146ad6e7d | |||
| cf2c4fd179 | |||
| 6ba61ef80b | |||
| e45f990487 | |||
| 8e6c912a07 | |||
| 9a411f2403 | |||
| d2c65c91b7 | |||
| 5078aec840 | |||
| df949ab8ee | |||
| 2e036783bf | |||
| ff145300b4 | |||
| c2de9d8e83 | |||
| baeda3773d | |||
| 725f1845d9 | |||
| e0949a0eaa | |||
| 5d2bcd8366 | |||
| 5181d0e403 | |||
| 1c31fa80d2 | |||
| d3d08276ae | |||
| 80b34280c2 | |||
| 0cfb7625bf | |||
| 4c3653e009 | |||
| 9512a5dd2f | |||
| 4f5e3f7162 | |||
| 89c2ff58f8 | |||
| 0813ce3ffb | |||
| a030554ab6 | |||
| e1d36fc0c7 | |||
| 0dca8e0eff | |||
| 4cf90d83a3 | |||
| 9b4462f880 | |||
| 87b7b2642d | |||
| 5eee5c7c63 | |||
| c6dc1d1474 | |||
| 8c86918970 | |||
| 200d5d1f41 | |||
| 3499d4aa74 | |||
| 7dbd66b467 | |||
| db8b0bf142 | |||
| 6e54471e57 | |||
| 3f383c1a88 | |||
| 4577657c90 | |||
| 94662b6e75 | |||
| 616cab0e7e | |||
| db72f6f515 | |||
| 2165831708 | |||
| 1a402f2048 | |||
| bbe60537ff | |||
| 3b033fd294 | |||
| d9de9715ef | |||
| 65742d4d5e | |||
| a47c7c3e49 | |||
| adc2f12ddf | |||
| e06a1e88e5 |
Generated
-10
@@ -37,16 +37,6 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "bt-demo"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fidc-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/fidc-core",
|
||||
"crates/bt-demo",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 日频、分钟、tick 级策略生命周期与确定性回放。
|
||||
- 日频和分钟执行价策略生命周期与确定性回放。
|
||||
- A 股行情、估值、因子、基准、候选资格、涨跌停触达、停牌和 ST 标记。
|
||||
- 平台策略 DSL 与 `StrategyContext` 数据 API,不暴露非平台脚本语法。
|
||||
- `BacktestConfig` 支持起止日期、初始资金、决策滞后、执行价格字段、基准代码。
|
||||
@@ -22,7 +22,6 @@
|
||||
.
|
||||
├── Cargo.toml
|
||||
├── crates
|
||||
│ ├── bt-demo
|
||||
│ └── fidc-core
|
||||
│ └── src
|
||||
│ ├── broker.rs
|
||||
@@ -37,7 +36,6 @@
|
||||
│ ├── scheduler.rs
|
||||
│ ├── strategy.rs
|
||||
│ └── strategy_ai.rs
|
||||
├── data/demo
|
||||
└── docs
|
||||
```
|
||||
|
||||
@@ -51,7 +49,7 @@
|
||||
- `futures`: 期货账户、合约参数、保证金、手续费和多空持仓。
|
||||
- `rules`: 中国市场交易规则和风控校验。
|
||||
- `broker`: 股票撮合、订单簿、滑点、成交量约束、限价和显式订单执行。
|
||||
- `scheduler`: 日、周、月、分钟、tick 调度规则。
|
||||
- `scheduler`: 日、周、月和分钟调度规则。
|
||||
- `platform_expr_strategy`: 平台 DSL 解析后的表达式策略执行模型。
|
||||
- `strategy`: 策略 trait、内置策略和运行时视图。
|
||||
- `strategy_ai`: 策略 AI 手册、提示词生成和数据库字段目录合并。
|
||||
@@ -96,45 +94,9 @@
|
||||
|
||||
## 运行方式
|
||||
|
||||
默认运行仓库 demo 数据:
|
||||
`fidc-backtest-engine` 不再维护本地 CSV demo、partitioned snapshot 目录或导出融合表作为运行入口。生产和集成回测由 `fidc-backtest-service` runner 创建 `DataSet`,数据来自 Strategy Factory Source Lake 的 Arrow/Parquet、manifest/data_epoch 缓存和运行时逻辑视图。
|
||||
|
||||
```bash
|
||||
cargo run --bin bt-demo
|
||||
```
|
||||
|
||||
运行平台内置微盘策略:
|
||||
|
||||
```bash
|
||||
FIDC_BT_STRATEGY=omni-microcap \
|
||||
FIDC_BT_SIGNAL_SYMBOL=000001.SH \
|
||||
cargo run --release --bin bt-demo
|
||||
```
|
||||
|
||||
接入真实分区 snapshot 目录:
|
||||
|
||||
```bash
|
||||
FIDC_BT_DATA_LAYOUT=partitioned \
|
||||
FIDC_BT_DATA_DIR=/path/to/snapshots \
|
||||
FIDC_BT_SIGNAL_SYMBOL=000001.SH \
|
||||
cargo run --bin bt-demo
|
||||
```
|
||||
|
||||
约定目录结构:
|
||||
|
||||
```text
|
||||
snapshots/
|
||||
├── instruments.csv
|
||||
├── benchmark/YYYY/MM/*.csv
|
||||
├── market/YYYY/MM/*.csv
|
||||
├── factors/YYYY/MM/*.csv
|
||||
└── candidates/YYYY/MM/*.csv
|
||||
```
|
||||
|
||||
运行后默认生成:
|
||||
|
||||
- `output/demo/equity_curve.csv`
|
||||
- `output/demo/trades.csv`
|
||||
- `output/demo/holdings_summary.csv`
|
||||
本仓库只保留核心库构建和测试入口:
|
||||
|
||||
## 测试与构建
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "bt-demo"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true }
|
||||
fidc-core = { path = "../fidc-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
@@ -1,516 +0,0 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, ChinaAShareCostModel,
|
||||
ChinaEquityRuleHooks, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DailyEquityPoint,
|
||||
DataSet, FillEvent, HoldingSummary, OmniMicroCapConfig, OmniMicroCapStrategy, PortfolioState,
|
||||
PriceField, Strategy, StrategyContext,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let root = workspace_root();
|
||||
let data_dir = std::env::var("FIDC_BT_DATA_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| root.join("data/demo"));
|
||||
let data_layout = std::env::var("FIDC_BT_DATA_LAYOUT").unwrap_or_else(|_| "flat".to_string());
|
||||
let output_dir = std::env::var("FIDC_BT_OUTPUT_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| root.join("output/demo"));
|
||||
let json_output = std::env::var("FIDC_BT_JSON")
|
||||
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
|
||||
let data = if data_layout == "partitioned" {
|
||||
DataSet::from_partitioned_dir(&data_dir)?
|
||||
} else {
|
||||
DataSet::from_csv_dir(&data_dir)?
|
||||
};
|
||||
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()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
|
||||
.transpose()?;
|
||||
let decision_lag = std::env::var("FIDC_BT_DECISION_LAG")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok());
|
||||
let execution_price =
|
||||
std::env::var("FIDC_BT_EXECUTION_PRICE")
|
||||
.ok()
|
||||
.map(|value| match value.as_str() {
|
||||
"close" => PriceField::Close,
|
||||
"last" => PriceField::Last,
|
||||
_ => PriceField::Open,
|
||||
});
|
||||
let initial_cash = std::env::var("FIDC_BT_INITIAL_CASH")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<f64>().ok());
|
||||
let start_date = std::env::var("FIDC_BT_START_DATE")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
|
||||
.transpose()?;
|
||||
let end_date = std::env::var("FIDC_BT_END_DATE")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
|
||||
.transpose()?;
|
||||
let mut config = BacktestConfig {
|
||||
initial_cash: initial_cash.unwrap_or(1_000_000.0),
|
||||
benchmark_code: data.benchmark_code().to_string(),
|
||||
start_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 = OmniMicroCapConfig::omni_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 = OmniMicroCapStrategy::new(strategy_cfg.clone());
|
||||
let debug_subscriptions = BTreeSet::new();
|
||||
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),
|
||||
futures_account: None,
|
||||
open_orders: &[],
|
||||
dynamic_universe: None,
|
||||
subscriptions: &debug_subscriptions,
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
active_datetime: None,
|
||||
order_events: &[],
|
||||
fills: &[],
|
||||
})?;
|
||||
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 = OmniMicroCapStrategy::new(strategy_cfg);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
config.execution_price_field,
|
||||
)
|
||||
.with_intraday_execution_start_time(
|
||||
NaiveTime::parse_from_str("10:18:00", "%H:%M:%S").expect("valid 10:18:00"),
|
||||
)
|
||||
.with_volume_limit(false)
|
||||
.with_inactive_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let mut engine = BacktestEngine::new(data, strategy, broker, config);
|
||||
engine.run()?
|
||||
}
|
||||
};
|
||||
|
||||
write_equity_curve_csv(&output_dir.join("equity_curve.csv"), &result.equity_curve)?;
|
||||
write_trades_csv(&output_dir.join("trades.csv"), &result.fills)?;
|
||||
write_holdings_csv(
|
||||
&output_dir.join("holdings_summary.csv"),
|
||||
&result.holdings_summary,
|
||||
)?;
|
||||
|
||||
let summary = build_summary(
|
||||
&result.strategy_name,
|
||||
&result.equity_curve,
|
||||
&result.fills,
|
||||
&result.holdings_summary,
|
||||
result.benchmark_series.last(),
|
||||
&output_dir,
|
||||
);
|
||||
|
||||
print_summary(&summary, &result.equity_curve, &result.holdings_summary);
|
||||
println!("Artifacts written under {}", output_dir.display());
|
||||
if json_output {
|
||||
println!("{}", serde_json::to_string(&summary)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.expect("workspace root")
|
||||
}
|
||||
|
||||
fn write_equity_curve_csv(path: &Path, rows: &[DailyEquityPoint]) -> Result<(), Box<dyn Error>> {
|
||||
let mut file = fs::File::create(path)?;
|
||||
writeln!(
|
||||
file,
|
||||
"date,cash,market_value,total_equity,benchmark_close,benchmark_prev_close,notes,diagnostics"
|
||||
)?;
|
||||
for row in rows {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{}",
|
||||
row.date,
|
||||
row.cash,
|
||||
row.market_value,
|
||||
row.total_equity,
|
||||
row.benchmark_close,
|
||||
row.benchmark_prev_close,
|
||||
sanitize_csv_field(&row.notes),
|
||||
sanitize_csv_field(&row.diagnostics),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_trades_csv(path: &Path, rows: &[FillEvent]) -> Result<(), Box<dyn Error>> {
|
||||
let mut file = fs::File::create(path)?;
|
||||
writeln!(
|
||||
file,
|
||||
"date,symbol,side,quantity,price,gross_amount,commission,stamp_tax,net_cash_flow,reason"
|
||||
)?;
|
||||
for row in rows {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{},{:?},{},{:.2},{:.2},{:.2},{:.2},{:.2},{}",
|
||||
row.date,
|
||||
row.symbol,
|
||||
row.side,
|
||||
row.quantity,
|
||||
row.price,
|
||||
row.gross_amount,
|
||||
row.commission,
|
||||
row.stamp_tax,
|
||||
row.net_cash_flow,
|
||||
sanitize_csv_field(&row.reason),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_holdings_csv(path: &Path, rows: &[HoldingSummary]) -> Result<(), Box<dyn Error>> {
|
||||
let mut file = fs::File::create(path)?;
|
||||
writeln!(
|
||||
file,
|
||||
"date,symbol,quantity,average_cost,last_price,market_value,unrealized_pnl,realized_pnl"
|
||||
)?;
|
||||
for row in rows {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{},{},{:.2},{:.2},{:.2},{:.2},{:.2}",
|
||||
row.date,
|
||||
row.symbol,
|
||||
row.quantity,
|
||||
row.average_cost,
|
||||
row.last_price,
|
||||
row.market_value,
|
||||
row.unrealized_pnl,
|
||||
row.realized_pnl,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_csv_field(text: &str) -> String {
|
||||
text.replace(',', ";")
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct RunSummary {
|
||||
strategy: String,
|
||||
start_date: String,
|
||||
end_date: String,
|
||||
start_equity: f64,
|
||||
final_equity: f64,
|
||||
total_return: f64,
|
||||
trade_count: usize,
|
||||
holding_count: usize,
|
||||
benchmark_code: Option<String>,
|
||||
benchmark_last_close: Option<f64>,
|
||||
output_dir: String,
|
||||
diagnostics: serde_json::Value,
|
||||
warnings: Vec<String>,
|
||||
equity_preview: Vec<serde_json::Value>,
|
||||
trades_preview: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn build_summary(
|
||||
strategy_name: &str,
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
fills: &[FillEvent],
|
||||
holdings: &[HoldingSummary],
|
||||
benchmark_last: Option<&BenchmarkSnapshot>,
|
||||
output_dir: &Path,
|
||||
) -> RunSummary {
|
||||
let first = equity_curve.first();
|
||||
let last = equity_curve.last();
|
||||
let start_equity = first.map(|row| row.total_equity).unwrap_or_default();
|
||||
let final_equity = last.map(|row| row.total_equity).unwrap_or_default();
|
||||
let total_return = if start_equity.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(final_equity / start_equity) - 1.0
|
||||
};
|
||||
|
||||
let diagnostics = extract_diagnostics(equity_curve);
|
||||
let warnings = build_warnings(fills, holdings, &diagnostics);
|
||||
let equity_preview = equity_curve
|
||||
.iter()
|
||||
.rev()
|
||||
.take(5)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"date": row.date.to_string(),
|
||||
"cash": row.cash,
|
||||
"marketValue": row.market_value,
|
||||
"totalEquity": row.total_equity,
|
||||
"benchmarkClose": row.benchmark_close,
|
||||
"benchmarkPrevClose": row.benchmark_prev_close,
|
||||
"notes": row.notes,
|
||||
"diagnostics": row.diagnostics,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let trades_preview = fills
|
||||
.iter()
|
||||
.rev()
|
||||
.take(10)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"date": row.date.to_string(),
|
||||
"symbol": row.symbol,
|
||||
"side": format!("{:?}", row.side),
|
||||
"quantity": row.quantity,
|
||||
"price": row.price,
|
||||
"grossAmount": row.gross_amount,
|
||||
"netCashFlow": row.net_cash_flow,
|
||||
"reason": row.reason,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
RunSummary {
|
||||
strategy: strategy_name.to_string(),
|
||||
start_date: first.map(|row| row.date.to_string()).unwrap_or_default(),
|
||||
end_date: last.map(|row| row.date.to_string()).unwrap_or_default(),
|
||||
start_equity,
|
||||
final_equity,
|
||||
total_return,
|
||||
trade_count: fills.len(),
|
||||
holding_count: holdings.len(),
|
||||
benchmark_code: benchmark_last.map(|row| row.benchmark.clone()),
|
||||
benchmark_last_close: benchmark_last.map(|row| row.close),
|
||||
output_dir: output_dir.display().to_string(),
|
||||
diagnostics,
|
||||
warnings,
|
||||
equity_preview,
|
||||
trades_preview,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_diagnostics(equity_curve: &[DailyEquityPoint]) -> serde_json::Value {
|
||||
let last = equity_curve.last();
|
||||
let text = last.map(|row| row.diagnostics.as_str()).unwrap_or("");
|
||||
let notes = last.map(|row| row.notes.as_str()).unwrap_or("");
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("latestText".to_string(), json!(text));
|
||||
map.insert("latestNotes".to_string(), json!(notes));
|
||||
map.insert("equityPointCount".to_string(), json!(equity_curve.len()));
|
||||
|
||||
for part in text.split(" | ") {
|
||||
let part = part.trim();
|
||||
if let Some(rest) = part.strip_prefix("selection_diag ") {
|
||||
for token in rest.split_whitespace() {
|
||||
if let Some((k, v)) = token.split_once('=') {
|
||||
map.insert(k.to_string(), parse_diag_value(v));
|
||||
}
|
||||
}
|
||||
} else if let Some(rest) = part.strip_prefix("selection_band ") {
|
||||
for token in rest.split_whitespace() {
|
||||
if let Some((k, v)) = token.split_once('=') {
|
||||
map.insert(k.to_string(), parse_diag_value(v));
|
||||
}
|
||||
}
|
||||
} else if let Some(rest) =
|
||||
part.strip_prefix("market_cap_missing likely blocks selection; sample=")
|
||||
{
|
||||
map.insert(
|
||||
"marketCapMissingSample".to_string(),
|
||||
json!(
|
||||
rest.split('|')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
),
|
||||
);
|
||||
} else if let Some(rest) = part.strip_prefix("selection_rejections sample=") {
|
||||
map.insert(
|
||||
"selectionRejectionsSample".to_string(),
|
||||
json!(
|
||||
rest.split(" | ")
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
),
|
||||
);
|
||||
} else if let Some(rest) = part.strip_prefix("ma_filter_rejections sample=") {
|
||||
map.insert(
|
||||
"maFilterRejectionsSample".to_string(),
|
||||
json!(
|
||||
rest.split('|')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
),
|
||||
);
|
||||
} else if let Some(rest) = part.strip_prefix("selected=") {
|
||||
map.insert("selectedLine".to_string(), json!(rest));
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
|
||||
fn parse_diag_value(value: &str) -> serde_json::Value {
|
||||
if let Ok(v) = value.parse::<i64>() {
|
||||
return json!(v);
|
||||
}
|
||||
if let Ok(v) = value.parse::<f64>() {
|
||||
return json!(v);
|
||||
}
|
||||
json!(value)
|
||||
}
|
||||
|
||||
fn build_warnings(
|
||||
fills: &[FillEvent],
|
||||
holdings: &[HoldingSummary],
|
||||
diagnostics: &serde_json::Value,
|
||||
) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
if fills.is_empty() {
|
||||
warnings.push("本次回测没有产生任何成交。".to_string());
|
||||
}
|
||||
if holdings.is_empty() {
|
||||
warnings.push("期末没有持仓。".to_string());
|
||||
}
|
||||
let selected_after_ma_is_empty = diagnostics
|
||||
.get("selected_after_ma")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
== 0;
|
||||
if selected_after_ma_is_empty && fills.is_empty() && holdings.is_empty() {
|
||||
warnings
|
||||
.push("最终没有股票通过完整选股链路,结果为空时请优先查看 diagnostics。".to_string());
|
||||
}
|
||||
if diagnostics
|
||||
.get("market_cap_missing_count")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
> 0
|
||||
{
|
||||
warnings.push("存在 market_cap 缺失或非正值,当前会直接阻断该股票进入候选池。".to_string());
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
fn print_summary(
|
||||
summary: &RunSummary,
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
holdings: &[HoldingSummary],
|
||||
) {
|
||||
if equity_curve.is_empty() {
|
||||
println!("No equity curve points generated.");
|
||||
return;
|
||||
}
|
||||
|
||||
println!("Strategy: {}", summary.strategy);
|
||||
println!("Start equity: {:.2}", summary.start_equity);
|
||||
println!("Final equity: {:.2}", summary.final_equity);
|
||||
println!("Total return: {:.2}%", summary.total_return * 100.0);
|
||||
println!("Trades: {}", summary.trade_count);
|
||||
println!("Final holdings: {}", summary.holding_count);
|
||||
|
||||
if let (Some(code), Some(close)) = (&summary.benchmark_code, summary.benchmark_last_close) {
|
||||
println!("Benchmark last close: {} {:.2}", code, close);
|
||||
}
|
||||
|
||||
println!("Recent equity points:");
|
||||
for point in equity_curve
|
||||
.iter()
|
||||
.rev()
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
{
|
||||
println!(
|
||||
" {} equity {:.2} cash {:.2} mv {:.2}",
|
||||
point.date, point.total_equity, point.cash, point.market_value
|
||||
);
|
||||
}
|
||||
|
||||
if holdings.is_empty() {
|
||||
println!("No holdings at the end of the demo run.");
|
||||
} else {
|
||||
println!("Ending holdings:");
|
||||
for holding in holdings {
|
||||
println!(
|
||||
" {} qty {} mv {:.2} pnl {:.2}",
|
||||
holding.symbol, holding.quantity, holding.market_value, holding.unrealized_pnl
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! 把 DSP 运行时 schema 序列化为 JSON 输出到 stdout。
|
||||
//!
|
||||
//! 用法(在 fidc-backtest-engine 仓库根):
|
||||
//! cargo run -p fidc-core --bin dump_platform_runtime_schema \
|
||||
//! > ../omniquant/src/generated/platformRuntimeSchema.json
|
||||
//!
|
||||
//! 这是 omniquant 前端编译期校验表达式标识符的事实源;任何对
|
||||
//! reserved_scope_names / is_runtime_helper / register_fn 清单的修改,记得
|
||||
//! 重新跑这个命令并把生成文件提交到 omniquant。
|
||||
|
||||
use fidc_core::runtime_schema_json;
|
||||
|
||||
fn main() {
|
||||
let schema = runtime_schema_json();
|
||||
let output = serde_json::to_string_pretty(&schema).expect("serialize schema");
|
||||
println!("{output}");
|
||||
}
|
||||
+3791
-187
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use crate::events::OrderSide;
|
||||
use crate::risk_control::TradingConstraintConfig;
|
||||
|
||||
pub const STOCK_PIT_TAX_CHANGE_DATE: (i32, u32, u32) = (2023, 8, 28);
|
||||
|
||||
@@ -38,21 +39,42 @@ pub struct ChinaAShareCostModel {
|
||||
pub commission_rate: f64,
|
||||
pub stamp_tax_rate_before_change: f64,
|
||||
pub stamp_tax_rate_after_change: f64,
|
||||
pub stamp_tax_change_date: NaiveDate,
|
||||
pub minimum_commission: f64,
|
||||
}
|
||||
|
||||
impl Default for ChinaAShareCostModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
commission_rate: 0.0003,
|
||||
commission_rate: 0.0008,
|
||||
stamp_tax_rate_before_change: 0.001,
|
||||
stamp_tax_rate_after_change: 0.0005,
|
||||
stamp_tax_change_date: default_stamp_tax_change_date(),
|
||||
minimum_commission: 5.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChinaAShareCostModel {
|
||||
pub fn aiquant_default() -> Self {
|
||||
Self {
|
||||
commission_rate: 0.0003,
|
||||
stamp_tax_rate_before_change: 0.0005,
|
||||
stamp_tax_rate_after_change: 0.0005,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self {
|
||||
Self {
|
||||
commission_rate: config.commission_rate,
|
||||
stamp_tax_rate_before_change: config.stamp_tax_rate_before_change,
|
||||
stamp_tax_rate_after_change: config.stamp_tax_rate_after_change,
|
||||
stamp_tax_change_date: config.stamp_tax_change_date,
|
||||
minimum_commission: config.minimum_commission,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commission_for(&self, gross_amount: f64) -> f64 {
|
||||
if gross_amount <= 0.0 {
|
||||
return 0.0;
|
||||
@@ -61,13 +83,7 @@ impl ChinaAShareCostModel {
|
||||
}
|
||||
|
||||
pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 {
|
||||
let change_date = NaiveDate::from_ymd_opt(
|
||||
STOCK_PIT_TAX_CHANGE_DATE.0,
|
||||
STOCK_PIT_TAX_CHANGE_DATE.1,
|
||||
STOCK_PIT_TAX_CHANGE_DATE.2,
|
||||
)
|
||||
.expect("valid pit tax change date");
|
||||
if date < change_date {
|
||||
if date < self.stamp_tax_change_date {
|
||||
self.stamp_tax_rate_before_change
|
||||
} else {
|
||||
self.stamp_tax_rate_after_change
|
||||
@@ -119,6 +135,15 @@ impl ChinaAShareCostModel {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_stamp_tax_change_date() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(
|
||||
STOCK_PIT_TAX_CHANGE_DATE.0,
|
||||
STOCK_PIT_TAX_CHANGE_DATE.1,
|
||||
STOCK_PIT_TAX_CHANGE_DATE.2,
|
||||
)
|
||||
.expect("valid pit tax change date")
|
||||
}
|
||||
|
||||
impl CostModel for ChinaAShareCostModel {
|
||||
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost {
|
||||
if gross_amount <= 0.0 {
|
||||
@@ -161,3 +186,51 @@ impl CostModel for ChinaAShareCostModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aiquant_default_matches_current_backtest_fee_model() {
|
||||
let model = ChinaAShareCostModel::aiquant_default();
|
||||
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
|
||||
|
||||
assert!((model.commission_for(248_059.812) - 74.4179436).abs() < 1e-9);
|
||||
assert!(
|
||||
(model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.8735035).abs() < 1e-9
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_model_can_use_configurable_stamp_tax_change_date() {
|
||||
let config = TradingConstraintConfig {
|
||||
commission_rate: 0.0003,
|
||||
minimum_commission: 5.0,
|
||||
stamp_tax_rate_before_change: 0.002,
|
||||
stamp_tax_rate_after_change: 0.001,
|
||||
stamp_tax_change_date: NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"),
|
||||
..TradingConstraintConfig::default()
|
||||
};
|
||||
let model = ChinaAShareCostModel::from_trading_constraints(config);
|
||||
|
||||
assert!(
|
||||
(model.stamp_tax_for(
|
||||
NaiveDate::from_ymd_opt(2025, 1, 9).expect("valid date"),
|
||||
OrderSide::Sell,
|
||||
10_000.0
|
||||
) - 20.0)
|
||||
.abs()
|
||||
< 1e-9
|
||||
);
|
||||
assert!(
|
||||
(model.stamp_tax_for(
|
||||
NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"),
|
||||
OrderSide::Sell,
|
||||
10_000.0
|
||||
) - 10.0)
|
||||
.abs()
|
||||
< 1e-9
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1195
-845
File diff suppressed because it is too large
Load Diff
+2265
-107
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,33 @@ mod date_format {
|
||||
}
|
||||
}
|
||||
|
||||
mod optional_date_format {
|
||||
use chrono::NaiveDate;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
const FORMAT: &str = "%Y-%m-%d";
|
||||
|
||||
pub fn serialize<S>(date: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match date {
|
||||
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)?;
|
||||
value
|
||||
.map(|text| NaiveDate::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum OrderSide {
|
||||
Buy,
|
||||
@@ -63,6 +90,12 @@ impl OrderStatus {
|
||||
pub struct OrderEvent {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub decision_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub order_created_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub execution_date: Option<NaiveDate>,
|
||||
#[serde(default)]
|
||||
pub order_id: Option<u64>,
|
||||
pub symbol: String,
|
||||
@@ -77,6 +110,12 @@ pub struct OrderEvent {
|
||||
pub struct FillEvent {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub decision_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub order_created_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub execution_date: Option<NaiveDate>,
|
||||
#[serde(default)]
|
||||
pub order_id: Option<u64>,
|
||||
pub symbol: String,
|
||||
@@ -123,9 +162,9 @@ pub enum ProcessEventKind {
|
||||
PreBar,
|
||||
Bar,
|
||||
PostBar,
|
||||
PreTick,
|
||||
Tick,
|
||||
PostTick,
|
||||
PreMinute,
|
||||
Minute,
|
||||
PostMinute,
|
||||
PreScheduled,
|
||||
PostScheduled,
|
||||
PreOnDay,
|
||||
@@ -165,9 +204,9 @@ impl ProcessEventKind {
|
||||
Self::PreBar => "pre_bar",
|
||||
Self::Bar => "bar",
|
||||
Self::PostBar => "post_bar",
|
||||
Self::PreTick => "pre_tick",
|
||||
Self::Tick => "tick",
|
||||
Self::PostTick => "post_tick",
|
||||
Self::PreMinute => "pre_minute",
|
||||
Self::Minute => "minute",
|
||||
Self::PostMinute => "post_minute",
|
||||
Self::PreScheduled => "pre_scheduled",
|
||||
Self::PostScheduled => "post_scheduled",
|
||||
Self::PreOnDay => "pre_on_day",
|
||||
|
||||
@@ -746,6 +746,9 @@ impl FuturesAccountState {
|
||||
);
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
@@ -823,6 +826,9 @@ impl FuturesAccountState {
|
||||
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier;
|
||||
report.fill_events.push(FillEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol.clone(),
|
||||
side,
|
||||
@@ -889,6 +895,9 @@ impl FuturesAccountState {
|
||||
});
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
@@ -915,6 +924,9 @@ impl FuturesAccountState {
|
||||
);
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
|
||||
@@ -40,10 +40,15 @@ impl Instrument {
|
||||
.is_some_and(|delisted_at| delisted_at < date)
|
||||
}
|
||||
|
||||
pub fn is_delisted_on_or_before(&self, date: NaiveDate) -> bool {
|
||||
self.delisted_at
|
||||
.is_some_and(|delisted_at| delisted_at <= date)
|
||||
}
|
||||
|
||||
pub fn is_active_on(&self, date: NaiveDate) -> bool {
|
||||
self.listed_at.is_none_or(|listed_at| listed_at <= date)
|
||||
&& !self.is_delisted_before(date)
|
||||
&& !self.status.eq_ignore_ascii_case("inactive")
|
||||
&& !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,20 @@ pub mod futures;
|
||||
pub mod instrument;
|
||||
pub mod metrics;
|
||||
pub mod platform_expr_strategy;
|
||||
pub mod platform_runtime_schema;
|
||||
pub mod platform_strategy_spec;
|
||||
pub mod portfolio;
|
||||
pub mod risk_control;
|
||||
pub mod rules;
|
||||
pub mod scheduler;
|
||||
pub mod strategy;
|
||||
pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
|
||||
pub use broker::{BrokerExecutionReport, BrokerSimulator, MatchingType, SlippageModel};
|
||||
pub use broker::{
|
||||
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, RebalanceCashMode,
|
||||
SlippageModel,
|
||||
};
|
||||
pub use calendar::TradingCalendar;
|
||||
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
|
||||
pub use data::{
|
||||
@@ -30,7 +35,7 @@ pub use data::{
|
||||
pub use engine::{
|
||||
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
||||
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
|
||||
BacktestResult, DailyEquityPoint, FuturesValidationConfig,
|
||||
BacktestResult, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
|
||||
};
|
||||
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
|
||||
pub use events::{
|
||||
@@ -47,8 +52,13 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||
pub use platform_expr_strategy::{
|
||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
||||
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
|
||||
PlatformUniverseActionKind,
|
||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformSelectionQuotePlan,
|
||||
PlatformTradeAction, PlatformUniverseActionKind,
|
||||
};
|
||||
pub use platform_runtime_schema::{
|
||||
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
|
||||
rhai_builtin_functions, rhai_keywords, runtime_helper_functions, runtime_schema,
|
||||
runtime_schema_json,
|
||||
};
|
||||
pub use platform_strategy_spec::{
|
||||
DynamicRangeConfig, IndexThrottleConfig, MovingAverageFilterConfig, SkipWindowConfig,
|
||||
@@ -60,6 +70,10 @@ pub use platform_strategy_spec::{
|
||||
StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value,
|
||||
};
|
||||
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
||||
pub use risk_control::{
|
||||
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
|
||||
StaticRiskRuleConfig, TradingConstraintConfig,
|
||||
};
|
||||
pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck};
|
||||
pub use scheduler::{
|
||||
ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
|
||||
@@ -71,9 +85,10 @@ pub use strategy::{
|
||||
};
|
||||
pub use strategy_ai::{
|
||||
ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction,
|
||||
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiManual,
|
||||
StrategyAiOptimizeRequest, build_generation_prompt, build_optimization_prompt,
|
||||
built_in_strategy_manual, merge_catalog_into_manual, render_manual_markdown,
|
||||
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiHoldingCountContract,
|
||||
StrategyAiManual, StrategyAiOptimizeRequest, build_generation_prompt,
|
||||
build_optimization_prompt, built_in_strategy_manual, merge_catalog_into_manual,
|
||||
render_manual_markdown,
|
||||
};
|
||||
pub use universe::{
|
||||
BandRegime, DynamicMarketCapBandSelector, SelectionContext, SelectionDiagnostics,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,350 @@
|
||||
//! DSP 运行时变量与函数 schema 导出。
|
||||
//!
|
||||
//! 这是前后端共享的"事实源":把引擎里 reserved_scope_names 和 is_runtime_helper
|
||||
//! 等清单按 JSON Schema 暴露出来,供 omniquant 前端在编译期做表达式标识符校验。
|
||||
//!
|
||||
//! 维护原则:
|
||||
//! - 任何对 platform_expr_strategy.rs 中变量名 / 函数名清单的修改都必须在这里
|
||||
//! 同步一份。两侧一致由 unit test `runtime_schema_matches_strategy_runtime`
|
||||
//! 守住。
|
||||
//! - 该 schema 的 version 字段需要与 omniquant/src/platformSchema.ts 里
|
||||
//! PLATFORM_RUNTIME_SCHEMA_VERSION 保持一致。前端读到不同版本时应给出诊断。
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 当前 schema 版本号。每次 reserved/runtime 列表的破坏性变更需要 +1。
|
||||
pub const PLATFORM_RUNTIME_SCHEMA_VERSION: &str = "1";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlatformRuntimeSchema {
|
||||
pub version: &'static str,
|
||||
pub reserved_scope_names: Vec<&'static str>,
|
||||
pub runtime_helper_functions: Vec<&'static str>,
|
||||
pub rhai_builtin_functions: Vec<&'static str>,
|
||||
pub rhai_keywords: Vec<&'static str>,
|
||||
}
|
||||
|
||||
/// reserved scope names 列表。镜像 PlatformExprStrategy::reserved_scope_names。
|
||||
pub fn reserved_scope_names() -> &'static [&'static str] {
|
||||
RESERVED_SCOPE_NAMES
|
||||
}
|
||||
|
||||
/// runtime helper functions 列表。镜像 PlatformExprStrategy::is_runtime_helper。
|
||||
pub fn runtime_helper_functions() -> &'static [&'static str] {
|
||||
RUNTIME_HELPER_FUNCTIONS
|
||||
}
|
||||
|
||||
/// rhai engine 注册的内置函数列表。镜像 PlatformExprStrategy::new 中 register_fn
|
||||
/// 的清单。
|
||||
pub fn rhai_builtin_functions() -> &'static [&'static str] {
|
||||
RHAI_BUILTIN_FUNCTIONS
|
||||
}
|
||||
|
||||
/// rhai 控制流关键字(避免被前端校验视为未知)。
|
||||
pub fn rhai_keywords() -> &'static [&'static str] {
|
||||
RHAI_KEYWORDS
|
||||
}
|
||||
|
||||
/// 构造完整 schema。
|
||||
pub fn runtime_schema() -> PlatformRuntimeSchema {
|
||||
PlatformRuntimeSchema {
|
||||
version: PLATFORM_RUNTIME_SCHEMA_VERSION,
|
||||
reserved_scope_names: RESERVED_SCOPE_NAMES.to_vec(),
|
||||
runtime_helper_functions: RUNTIME_HELPER_FUNCTIONS.to_vec(),
|
||||
rhai_builtin_functions: RHAI_BUILTIN_FUNCTIONS.to_vec(),
|
||||
rhai_keywords: RHAI_KEYWORDS.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 schema 序列化为 JSON Value。给 fidc-data-center / strategy-runtime 接口使用。
|
||||
pub fn runtime_schema_json() -> Value {
|
||||
serde_json::to_value(runtime_schema()).expect("runtime schema serialization is infallible")
|
||||
}
|
||||
|
||||
const RESERVED_SCOPE_NAMES: &[&str] = &[
|
||||
// day-level
|
||||
"signal_close",
|
||||
"benchmark_close",
|
||||
"signal_ma5",
|
||||
"signal_ma10",
|
||||
"signal_ma20",
|
||||
"signal_ma30",
|
||||
"benchmark_ma5",
|
||||
"benchmark_ma10",
|
||||
"benchmark_ma20",
|
||||
"benchmark_ma30",
|
||||
"benchmark_ma_short",
|
||||
"benchmark_ma_long",
|
||||
"cash",
|
||||
"available_cash",
|
||||
"frozen_cash",
|
||||
"market_value",
|
||||
"total_equity",
|
||||
"total_value",
|
||||
"portfolio_value",
|
||||
"starting_cash",
|
||||
"unit_net_value",
|
||||
"static_unit_net_value",
|
||||
"daily_pnl",
|
||||
"daily_returns",
|
||||
"total_returns",
|
||||
"cash_liabilities",
|
||||
"management_fee_rate",
|
||||
"management_fees",
|
||||
"current_exposure",
|
||||
"position_count",
|
||||
"max_positions",
|
||||
"refresh_rate",
|
||||
"year",
|
||||
"month",
|
||||
"quarter",
|
||||
"day_of_month",
|
||||
"day_of_year",
|
||||
"week_of_year",
|
||||
"weekday",
|
||||
"is_month_start",
|
||||
"is_month_end",
|
||||
"has_open_orders",
|
||||
"open_order_count",
|
||||
"open_buy_order_count",
|
||||
"open_sell_order_count",
|
||||
"open_buy_qty",
|
||||
"open_sell_qty",
|
||||
"latest_open_order_id",
|
||||
"latest_open_order_status",
|
||||
"latest_open_order_unfilled_qty",
|
||||
"has_process_events",
|
||||
"process_event_count",
|
||||
"current_process_kind",
|
||||
"current_process_order_id",
|
||||
"current_process_symbol",
|
||||
"current_process_side",
|
||||
"current_process_detail",
|
||||
"latest_process_kind",
|
||||
"latest_process_order_id",
|
||||
"latest_process_symbol",
|
||||
"latest_process_side",
|
||||
"latest_process_detail",
|
||||
"process_event_counts",
|
||||
"day_factors",
|
||||
// stock-level
|
||||
"symbol",
|
||||
"market_cap",
|
||||
"free_float_cap",
|
||||
"pe_ttm",
|
||||
"volume",
|
||||
"minute_volume",
|
||||
"bid1_volume",
|
||||
"ask1_volume",
|
||||
"turnover_ratio",
|
||||
"effective_turnover_ratio",
|
||||
"up_days_stock",
|
||||
"open",
|
||||
"high",
|
||||
"low",
|
||||
"close",
|
||||
"last",
|
||||
"last_price",
|
||||
"prev_close",
|
||||
"amount",
|
||||
"upper_limit",
|
||||
"lower_limit",
|
||||
"price_tick",
|
||||
"round_lot",
|
||||
"paused",
|
||||
"is_st",
|
||||
"is_star_st",
|
||||
"is_kcb",
|
||||
"is_bjse",
|
||||
"is_one_yuan",
|
||||
"is_new_listing",
|
||||
"allow_buy",
|
||||
"allow_sell",
|
||||
"touched_upper_limit",
|
||||
"touched_lower_limit",
|
||||
"hit_upper_limit",
|
||||
"hit_lower_limit",
|
||||
"listed_days",
|
||||
"symbol_open_order_count",
|
||||
"symbol_open_buy_qty",
|
||||
"symbol_open_sell_qty",
|
||||
"latest_symbol_open_order_id",
|
||||
"latest_symbol_open_order_status",
|
||||
"latest_symbol_open_order_unfilled_qty",
|
||||
"stock_ma_short",
|
||||
"stock_ma_mid",
|
||||
"stock_ma_long",
|
||||
"stock_ma5",
|
||||
"stock_ma10",
|
||||
"stock_ma20",
|
||||
"stock_ma30",
|
||||
"ma5",
|
||||
"ma10",
|
||||
"ma20",
|
||||
"ma30",
|
||||
"factors",
|
||||
"order_book_id",
|
||||
// position-level
|
||||
"avg_cost",
|
||||
"avg_price",
|
||||
"current_price",
|
||||
"position_prev_close",
|
||||
"prev_position_close",
|
||||
"holding_return",
|
||||
"quantity",
|
||||
"sellable_qty",
|
||||
"sellable",
|
||||
"closable",
|
||||
"old_quantity",
|
||||
"buy_quantity",
|
||||
"sell_quantity",
|
||||
"bought_quantity",
|
||||
"sold_quantity",
|
||||
"buy_avg_price",
|
||||
"sell_avg_price",
|
||||
"bought_value",
|
||||
"sold_value",
|
||||
"transaction_cost",
|
||||
"position_market_value",
|
||||
"equity",
|
||||
"value_percent",
|
||||
"unrealized_pnl",
|
||||
"realized_pnl",
|
||||
"pnl",
|
||||
"day_trade_quantity_delta",
|
||||
"profit_pct",
|
||||
"trading_pnl",
|
||||
"position_pnl",
|
||||
"dividend_receivable",
|
||||
"at_upper_limit",
|
||||
"at_lower_limit",
|
||||
];
|
||||
|
||||
const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
|
||||
"factor",
|
||||
"day_factor",
|
||||
"rolling_mean",
|
||||
"rolling_mean_current",
|
||||
"ma",
|
||||
"sma",
|
||||
"vma",
|
||||
"rolling_sum",
|
||||
"rolling_min",
|
||||
"rolling_max",
|
||||
"rolling_stddev",
|
||||
"stddev",
|
||||
"rolling_zscore",
|
||||
"pct_change",
|
||||
"factor_value",
|
||||
"get_factor_value",
|
||||
"factor_text",
|
||||
"get_factor_text",
|
||||
"dividend_cash",
|
||||
"has_dividend",
|
||||
"split_ratio",
|
||||
"has_split",
|
||||
"securities_margin",
|
||||
"get_securities_margin_value",
|
||||
"shares",
|
||||
"get_shares_value",
|
||||
"turnover_rate",
|
||||
"get_turnover_rate_value",
|
||||
"price_change_rate",
|
||||
"get_price_change_rate_value",
|
||||
"stock_connect",
|
||||
"get_stock_connect_value",
|
||||
"current_performance",
|
||||
"fundamental",
|
||||
"get_fundamentals_value",
|
||||
"financial",
|
||||
"get_financials_value",
|
||||
"pit_financial",
|
||||
"get_pit_financials_value",
|
||||
"industry_code",
|
||||
"get_industry_code",
|
||||
"industry_name",
|
||||
"get_industry_name",
|
||||
"yield_curve",
|
||||
"get_yield_curve_value",
|
||||
"is_margin_stock",
|
||||
"dominant_future",
|
||||
"get_dominant_future",
|
||||
"dominant_future_price",
|
||||
"get_dominant_future_price_value",
|
||||
];
|
||||
|
||||
const RHAI_BUILTIN_FUNCTIONS: &[&str] = &[
|
||||
"round",
|
||||
"floor",
|
||||
"ceil",
|
||||
"abs",
|
||||
"min",
|
||||
"max",
|
||||
"sqrt",
|
||||
"pow",
|
||||
"log",
|
||||
"exp",
|
||||
"clamp",
|
||||
"between",
|
||||
"nz",
|
||||
"safe_div",
|
||||
"iff",
|
||||
"contains",
|
||||
"starts_with",
|
||||
"ends_with",
|
||||
"lower",
|
||||
"upper",
|
||||
"trim",
|
||||
"strlen",
|
||||
];
|
||||
|
||||
const RHAI_KEYWORDS: &[&str] = &[
|
||||
"if", "else", "while", "loop", "for", "in", "break", "continue", "return", "fn", "let",
|
||||
"const", "true", "false", "switch", "do",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn runtime_schema_serializes_to_json_object() {
|
||||
let value = runtime_schema_json();
|
||||
assert!(value.is_object());
|
||||
assert_eq!(value["version"], "1");
|
||||
assert!(value["reservedScopeNames"].is_array());
|
||||
assert!(value["runtimeHelperFunctions"].is_array());
|
||||
assert!(value["rhaiBuiltinFunctions"].is_array());
|
||||
assert!(value["rhaiKeywords"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_schema_includes_known_identifiers() {
|
||||
let names: std::collections::HashSet<&str> = RESERVED_SCOPE_NAMES.iter().copied().collect();
|
||||
for required in [
|
||||
"signal_close",
|
||||
"benchmark_close",
|
||||
"close",
|
||||
"avg_cost",
|
||||
"current_price",
|
||||
"stock_ma_short",
|
||||
"up_days_stock",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(required),
|
||||
"missing reserved name: {required}"
|
||||
);
|
||||
}
|
||||
|
||||
let helpers: std::collections::HashSet<&str> =
|
||||
RUNTIME_HELPER_FUNCTIONS.iter().copied().collect();
|
||||
for required in ["rolling_mean", "factor", "pct_change"] {
|
||||
assert!(
|
||||
helpers.contains(required),
|
||||
"missing helper function: {required}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
use chrono::NaiveDate;
|
||||
use indexmap::IndexMap;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::data::{DataSet, DataSetError, PriceField};
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::data::{DataSet, DataSetError, PriceField};
|
||||
pub struct PositionLot {
|
||||
pub acquired_date: NaiveDate,
|
||||
pub quantity: u32,
|
||||
pub entry_price: f64,
|
||||
pub price: f64,
|
||||
}
|
||||
|
||||
@@ -19,6 +20,7 @@ pub struct Position {
|
||||
pub average_cost: f64,
|
||||
pub last_price: f64,
|
||||
pub realized_pnl: f64,
|
||||
realized_entry_pnl: f64,
|
||||
pub trading_pnl: f64,
|
||||
pub position_pnl: f64,
|
||||
pub dividend_receivable: f64,
|
||||
@@ -43,6 +45,7 @@ impl Position {
|
||||
average_cost: 0.0,
|
||||
last_price: 0.0,
|
||||
realized_pnl: 0.0,
|
||||
realized_entry_pnl: 0.0,
|
||||
trading_pnl: 0.0,
|
||||
position_pnl: 0.0,
|
||||
dividend_receivable: 0.0,
|
||||
@@ -65,6 +68,16 @@ impl Position {
|
||||
}
|
||||
|
||||
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
|
||||
self.buy_with_mark_price(date, quantity, price, price);
|
||||
}
|
||||
|
||||
pub fn buy_with_mark_price(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
quantity: u32,
|
||||
execution_price: f64,
|
||||
mark_price: f64,
|
||||
) {
|
||||
if quantity == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -72,18 +85,28 @@ impl Position {
|
||||
self.lots.push(PositionLot {
|
||||
acquired_date: date,
|
||||
quantity,
|
||||
price,
|
||||
entry_price: execution_price,
|
||||
price: execution_price,
|
||||
});
|
||||
self.quantity += quantity;
|
||||
self.last_price = price;
|
||||
self.last_price = normalized_mark_price(mark_price, execution_price);
|
||||
self.day_trade_quantity_delta += quantity as i32;
|
||||
self.day_buy_quantity += quantity;
|
||||
self.day_buy_value += price * quantity as f64;
|
||||
self.day_buy_value += execution_price * quantity as f64;
|
||||
self.recalculate_average_cost();
|
||||
self.refresh_day_pnl();
|
||||
}
|
||||
|
||||
pub fn sell(&mut self, quantity: u32, price: f64) -> Result<f64, String> {
|
||||
self.sell_with_mark_price(quantity, price, price)
|
||||
}
|
||||
|
||||
pub fn sell_with_mark_price(
|
||||
&mut self,
|
||||
quantity: u32,
|
||||
execution_price: f64,
|
||||
mark_price: f64,
|
||||
) -> Result<f64, String> {
|
||||
if quantity > self.quantity {
|
||||
return Err(format!(
|
||||
"sell quantity {} exceeds current quantity {} for {}",
|
||||
@@ -93,6 +116,8 @@ impl Position {
|
||||
|
||||
let mut remaining = quantity;
|
||||
let mut realized = 0.0;
|
||||
let mut realized_entry = 0.0;
|
||||
let average_cost_before_sell = self.average_cost;
|
||||
|
||||
while remaining > 0 {
|
||||
let Some(first_lot) = self.lots.first_mut() else {
|
||||
@@ -100,7 +125,8 @@ impl Position {
|
||||
};
|
||||
|
||||
let lot_sell = remaining.min(first_lot.quantity);
|
||||
realized += (price - first_lot.price) * lot_sell as f64;
|
||||
realized += (execution_price - first_lot.price) * lot_sell as f64;
|
||||
realized_entry += (execution_price - first_lot.entry_price) * lot_sell as f64;
|
||||
first_lot.quantity -= lot_sell;
|
||||
remaining -= lot_sell;
|
||||
|
||||
@@ -110,12 +136,19 @@ impl Position {
|
||||
}
|
||||
|
||||
self.quantity -= quantity;
|
||||
self.last_price = price;
|
||||
self.last_price = normalized_mark_price(mark_price, execution_price);
|
||||
self.realized_pnl += realized;
|
||||
self.realized_entry_pnl += realized_entry;
|
||||
self.day_trade_quantity_delta -= quantity as i32;
|
||||
self.day_sell_quantity += quantity;
|
||||
self.day_sell_value += price * quantity as f64;
|
||||
self.day_sell_value += execution_price * quantity as f64;
|
||||
if self.quantity == 0 {
|
||||
self.recalculate_average_cost();
|
||||
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
|
||||
self.average_cost = average_cost_before_sell;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
self.refresh_day_pnl();
|
||||
Ok(realized)
|
||||
}
|
||||
@@ -136,10 +169,21 @@ impl Position {
|
||||
(self.last_price - self.average_cost) * self.quantity as f64
|
||||
}
|
||||
|
||||
pub fn unrealized_entry_pnl(&self) -> f64 {
|
||||
let Some(avg_price) = self.average_entry_price() else {
|
||||
return 0.0;
|
||||
};
|
||||
(self.last_price - avg_price) * self.quantity as f64
|
||||
}
|
||||
|
||||
pub fn pnl(&self) -> f64 {
|
||||
self.realized_pnl + self.unrealized_pnl()
|
||||
}
|
||||
|
||||
pub fn entry_pnl(&self) -> f64 {
|
||||
self.realized_entry_pnl + self.unrealized_entry_pnl()
|
||||
}
|
||||
|
||||
pub fn day_start_quantity(&self) -> u32 {
|
||||
self.day_start_quantity
|
||||
}
|
||||
@@ -205,6 +249,22 @@ impl Position {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_buy_trade_cost(&mut self, quantity: u32, value: f64) {
|
||||
if quantity == 0 || !value.is_finite() {
|
||||
return;
|
||||
}
|
||||
let cost = value.max(0.0);
|
||||
if cost <= 0.0 {
|
||||
return;
|
||||
}
|
||||
if let Some(lot) = self.lots.last_mut() {
|
||||
lot.price += cost / quantity as f64;
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
self.day_trade_cost += cost;
|
||||
self.refresh_day_pnl();
|
||||
}
|
||||
|
||||
pub fn set_dividend_receivable(&mut self, value: f64) {
|
||||
self.dividend_receivable = if value.is_finite() {
|
||||
value.max(0.0)
|
||||
@@ -214,13 +274,28 @@ impl Position {
|
||||
}
|
||||
|
||||
pub fn holding_return(&self, price: f64) -> Option<f64> {
|
||||
if self.quantity == 0 || self.average_cost <= 0.0 {
|
||||
let Some(avg_price) = self.average_entry_price() else {
|
||||
return None;
|
||||
};
|
||||
if avg_price <= 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some((price / self.average_cost) - 1.0)
|
||||
Some((price / avg_price) - 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn average_entry_price(&self) -> Option<f64> {
|
||||
if self.quantity == 0 {
|
||||
return None;
|
||||
}
|
||||
let total = self
|
||||
.lots
|
||||
.iter()
|
||||
.map(|lot| lot.entry_price * lot.quantity as f64)
|
||||
.sum::<f64>();
|
||||
Some(total / self.quantity as f64)
|
||||
}
|
||||
|
||||
fn recalculate_average_cost(&mut self) {
|
||||
if self.quantity == 0 {
|
||||
self.average_cost = 0.0;
|
||||
@@ -237,14 +312,31 @@ impl Position {
|
||||
}
|
||||
|
||||
pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 {
|
||||
self.apply_cash_dividend_internal(dividend_per_share, true)
|
||||
}
|
||||
|
||||
pub fn apply_cash_dividend_preserve_cost_basis(&mut self, dividend_per_share: f64) -> f64 {
|
||||
self.apply_cash_dividend_internal(dividend_per_share, false)
|
||||
}
|
||||
|
||||
fn apply_cash_dividend_internal(
|
||||
&mut self,
|
||||
dividend_per_share: f64,
|
||||
adjust_cost_basis: bool,
|
||||
) -> 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.entry_price -= dividend_per_share;
|
||||
if adjust_cost_basis {
|
||||
lot.price -= dividend_per_share;
|
||||
}
|
||||
}
|
||||
if adjust_cost_basis {
|
||||
self.average_cost -= dividend_per_share;
|
||||
}
|
||||
self.last_price -= dividend_per_share;
|
||||
let cash_delta = self.quantity as f64 * dividend_per_share;
|
||||
self.day_dividend_cash += cash_delta;
|
||||
@@ -264,6 +356,7 @@ impl Position {
|
||||
.map(|lot| PositionLot {
|
||||
acquired_date: lot.acquired_date,
|
||||
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
||||
entry_price: lot.entry_price / ratio,
|
||||
price: lot.price / ratio,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -305,6 +398,14 @@ impl Position {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_mark_price(mark_price: f64, fallback: f64) -> f64 {
|
||||
if mark_price.is_finite() && mark_price > 0.0 {
|
||||
mark_price
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortfolioState {
|
||||
initial_cash: f64,
|
||||
@@ -316,6 +417,7 @@ pub struct PortfolioState {
|
||||
positions: IndexMap<String, Position>,
|
||||
cash_receivables: Vec<CashReceivable>,
|
||||
pending_cash_flows: Vec<PendingCashFlow>,
|
||||
day_sold_symbols: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -348,6 +450,7 @@ impl PortfolioState {
|
||||
positions: IndexMap::new(),
|
||||
cash_receivables: Vec::new(),
|
||||
pending_cash_flows: Vec::new(),
|
||||
day_sold_symbols: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +505,18 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn prune_flat_positions(&mut self) {
|
||||
self.positions.retain(|_, position| !position.is_flat());
|
||||
let mut sold_symbols = Vec::new();
|
||||
self.positions.retain(|symbol, position| {
|
||||
if position.is_flat() {
|
||||
if position.sold_quantity() > 0 {
|
||||
sold_symbols.push(symbol.clone());
|
||||
}
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
self.day_sold_symbols.extend(sold_symbols);
|
||||
}
|
||||
|
||||
pub fn add_cash_receivable(&mut self, receivable: CashReceivable) {
|
||||
@@ -538,6 +652,7 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn begin_trading_day(&mut self) {
|
||||
self.day_sold_symbols.clear();
|
||||
for position in self.positions.values_mut() {
|
||||
position.begin_trading_day();
|
||||
}
|
||||
@@ -550,7 +665,31 @@ impl PortfolioState {
|
||||
data: &DataSet,
|
||||
field: PriceField,
|
||||
) -> Result<(), DataSetError> {
|
||||
self.update_prices_with_options(date, data, field, false)
|
||||
}
|
||||
|
||||
pub fn update_prices_with_options(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
field: PriceField,
|
||||
same_day_buy_close_mark_at_fill: bool,
|
||||
) -> Result<(), DataSetError> {
|
||||
let day_sold_symbols = self.day_sold_symbols.clone();
|
||||
for position in self.positions.values_mut() {
|
||||
let sold_today =
|
||||
position.sold_quantity() > 0 || day_sold_symbols.contains(&position.symbol);
|
||||
if same_day_buy_close_mark_at_fill
|
||||
&& field == PriceField::Close
|
||||
&& position.day_buy_quantity > 0
|
||||
&& !sold_today
|
||||
&& position.sellable_qty(date) == 0
|
||||
&& position.last_price.is_finite()
|
||||
&& position.last_price > 0.0
|
||||
{
|
||||
position.refresh_day_pnl();
|
||||
continue;
|
||||
}
|
||||
let price = data
|
||||
.price(date, &position.symbol, field)
|
||||
.or_else(|| data.price_on_or_before(date, &position.symbol, field))
|
||||
@@ -649,21 +788,27 @@ impl PortfolioState {
|
||||
self.positions
|
||||
.values()
|
||||
.filter(|position| position.quantity > 0)
|
||||
.map(|position| HoldingSummary {
|
||||
.map(|position| {
|
||||
let market_value = position.market_value();
|
||||
let entry_average_cost = position
|
||||
.average_entry_price()
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.unwrap_or(position.average_cost);
|
||||
HoldingSummary {
|
||||
date,
|
||||
symbol: position.symbol.clone(),
|
||||
quantity: position.quantity,
|
||||
average_cost: position.average_cost,
|
||||
average_cost: entry_average_cost,
|
||||
last_price: position.last_price,
|
||||
market_value: position.market_value(),
|
||||
market_value,
|
||||
value_percent: if total_equity > 0.0 {
|
||||
position.market_value() / total_equity
|
||||
market_value / total_equity
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
unrealized_pnl: position.unrealized_pnl(),
|
||||
realized_pnl: position.realized_pnl,
|
||||
pnl: position.pnl(),
|
||||
unrealized_pnl: position.unrealized_entry_pnl(),
|
||||
realized_pnl: position.realized_entry_pnl,
|
||||
pnl: position.entry_pnl(),
|
||||
trading_pnl: position.trading_pnl,
|
||||
position_pnl: position.position_pnl,
|
||||
dividend_receivable: position.dividend_receivable,
|
||||
@@ -676,6 +821,7 @@ impl PortfolioState {
|
||||
sold_value: position.sold_value(),
|
||||
transaction_cost: position.transaction_cost(),
|
||||
day_trade_quantity_delta: position.day_trade_quantity_delta(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -699,12 +845,14 @@ impl PortfolioState {
|
||||
let old_quantity = old_position.quantity;
|
||||
let last_price = old_position.last_price;
|
||||
let realized_pnl = old_position.realized_pnl;
|
||||
let realized_entry_pnl = old_position.realized_entry_pnl;
|
||||
let mut converted_lots = old_position
|
||||
.lots
|
||||
.into_iter()
|
||||
.map(|lot| PositionLot {
|
||||
acquired_date: lot.acquired_date,
|
||||
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
||||
entry_price: lot.entry_price / ratio,
|
||||
price: lot.price / ratio,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -734,6 +882,7 @@ impl PortfolioState {
|
||||
successor.lots.extend(converted_lots);
|
||||
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
||||
successor.realized_pnl += realized_pnl;
|
||||
successor.realized_entry_pnl += realized_entry_pnl;
|
||||
if converted_last_price > 0.0 {
|
||||
successor.last_price = converted_last_price;
|
||||
}
|
||||
@@ -801,6 +950,80 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_entry_price_excludes_buy_commission_cost_basis() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut position = Position::new("600561.SH");
|
||||
position.buy(date, 22_200, 5.66);
|
||||
position.record_buy_trade_cost(22_200, 100.0);
|
||||
|
||||
assert!(position.average_cost > 5.66);
|
||||
assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12);
|
||||
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_sell_preserves_remaining_average_cost() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut position = Position::new("603958.SH");
|
||||
position.buy(date, 800, 18.0981);
|
||||
position.record_buy_trade_cost(800, 5.0);
|
||||
position.buy(date, 1700, 19.4694);
|
||||
position.record_buy_trade_cost(1700, 8.27451625);
|
||||
position.buy(date, 200, 18.4584);
|
||||
position.record_buy_trade_cost(200, 5.0);
|
||||
position.buy(date, 100, 17.8378);
|
||||
position.record_buy_trade_cost(100, 5.0);
|
||||
let average_cost_before = position.average_cost;
|
||||
|
||||
position.sell(2700, 16.8331).expect("partial sell");
|
||||
|
||||
assert_eq!(position.quantity, 100);
|
||||
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut portfolio = PortfolioState::new(10_000.0);
|
||||
{
|
||||
let position = portfolio.position_mut("600561.SH");
|
||||
position.buy(date, 100, 10.0);
|
||||
position.record_buy_trade_cost(100, 5.0);
|
||||
position.last_price = 10.5;
|
||||
}
|
||||
|
||||
let summary = portfolio.holdings_summary(date);
|
||||
assert_eq!(summary.len(), 1);
|
||||
assert!((summary[0].average_cost - 10.0).abs() < 1e-12);
|
||||
assert!((summary[0].unrealized_pnl - 50.0).abs() < 1e-12);
|
||||
assert!((summary[0].realized_pnl - 0.0).abs() < 1e-12);
|
||||
assert!(
|
||||
portfolio
|
||||
.position("600561.SH")
|
||||
.expect("position")
|
||||
.average_cost
|
||||
> summary[0].average_cost
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_dividend_can_preserve_avg_cost_for_aiquant_rules() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut position = Position::new("603102.SH");
|
||||
position.buy(date, 1000, 46.45);
|
||||
position.record_buy_trade_cost(1000, 37.16);
|
||||
|
||||
let cost_before = position.average_cost;
|
||||
let entry_before = position.average_entry_price().unwrap();
|
||||
let cash = position.apply_cash_dividend_preserve_cost_basis(0.6);
|
||||
|
||||
assert!((cash - 600.0).abs() < 1e-12);
|
||||
assert!((position.average_cost - cost_before).abs() < 1e-12);
|
||||
assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12);
|
||||
assert!((position.last_price - 45.85).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portfolio_tracks_dividend_receivable_and_day_pnl() {
|
||||
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
@@ -837,7 +1060,7 @@ mod tests {
|
||||
ask1: 10.01,
|
||||
prev_close: 9.8,
|
||||
volume: 1000,
|
||||
tick_volume: 1000,
|
||||
minute_volume: 1000,
|
||||
bid1_volume: 1000,
|
||||
ask1_volume: 1000,
|
||||
trading_phase: None,
|
||||
@@ -860,7 +1083,7 @@ mod tests {
|
||||
ask1: 10.51,
|
||||
prev_close: 10.0,
|
||||
volume: 1000,
|
||||
tick_volume: 1000,
|
||||
minute_volume: 1000,
|
||||
bid1_volume: 1000,
|
||||
ask1_volume: 1000,
|
||||
trading_phase: None,
|
||||
@@ -884,12 +1107,14 @@ mod tests {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
@@ -947,7 +1172,7 @@ mod tests {
|
||||
ask1: 10.51,
|
||||
prev_close: 10.0,
|
||||
volume: 1000,
|
||||
tick_volume: 1000,
|
||||
minute_volume: 1000,
|
||||
bid1_volume: 1000,
|
||||
ask1_volume: 1000,
|
||||
trading_phase: None,
|
||||
@@ -970,12 +1195,14 @@ mod tests {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
@@ -1030,7 +1257,7 @@ mod tests {
|
||||
ask1: 10.31,
|
||||
prev_close: 10.0,
|
||||
volume: 1000,
|
||||
tick_volume: 1000,
|
||||
minute_volume: 1000,
|
||||
bid1_volume: 1000,
|
||||
ask1_volume: 1000,
|
||||
trading_phase: None,
|
||||
@@ -1066,6 +1293,132 @@ mod tests {
|
||||
assert!(position.position_pnl.abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portfolio_marks_same_day_buy_at_fill_until_next_trading_day() {
|
||||
let buy_date = NaiveDate::from_ymd_opt(2025, 2, 10).unwrap();
|
||||
let next_date = NaiveDate::from_ymd_opt(2025, 2, 11).unwrap();
|
||||
let symbol = "002652.SZ";
|
||||
let mut portfolio = PortfolioState::new(20_000.0);
|
||||
portfolio.position_mut(symbol).buy(buy_date, 1300, 3.01);
|
||||
|
||||
let dataset = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: "Same Day Buy Test".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: buy_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: None,
|
||||
day_open: 2.99,
|
||||
open: 2.99,
|
||||
high: 3.06,
|
||||
low: 2.98,
|
||||
close: 3.06,
|
||||
last_price: 3.06,
|
||||
bid1: 3.01,
|
||||
ask1: 3.02,
|
||||
prev_close: 2.98,
|
||||
volume: 152_975,
|
||||
minute_volume: 152_975,
|
||||
bid1_volume: 338,
|
||||
ask1_volume: 2476,
|
||||
trading_phase: None,
|
||||
paused: false,
|
||||
upper_limit: 3.28,
|
||||
lower_limit: 2.68,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: next_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: None,
|
||||
day_open: 3.03,
|
||||
open: 3.03,
|
||||
high: 3.08,
|
||||
low: 3.00,
|
||||
close: 3.07,
|
||||
last_price: 3.07,
|
||||
bid1: 3.06,
|
||||
ask1: 3.07,
|
||||
prev_close: 3.06,
|
||||
volume: 160_000,
|
||||
minute_volume: 160_000,
|
||||
bid1_volume: 1000,
|
||||
ask1_volume: 1000,
|
||||
trading_phase: None,
|
||||
paused: false,
|
||||
upper_limit: 3.37,
|
||||
lower_limit: 2.75,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![
|
||||
BenchmarkSnapshot {
|
||||
date: buy_date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1000.0,
|
||||
prev_close: 999.0,
|
||||
volume: 1000,
|
||||
},
|
||||
BenchmarkSnapshot {
|
||||
date: next_date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1001.0,
|
||||
close: 1001.0,
|
||||
prev_close: 1000.0,
|
||||
volume: 1000,
|
||||
},
|
||||
],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
portfolio
|
||||
.update_prices_with_options(buy_date, &dataset, PriceField::Close, true)
|
||||
.expect("same day close");
|
||||
let position = portfolio.position(symbol).expect("position");
|
||||
assert!((position.last_price - 3.01).abs() < 1e-9);
|
||||
assert!((position.market_value() - 3913.0).abs() < 1e-6);
|
||||
|
||||
portfolio.begin_trading_day();
|
||||
portfolio
|
||||
.update_prices(next_date, &dataset, PriceField::Close)
|
||||
.expect("next day close");
|
||||
let position = portfolio.position(symbol).expect("position");
|
||||
assert!((position.last_price - 3.07).abs() < 1e-9);
|
||||
assert!((position.market_value() - 3991.0).abs() < 1e-6);
|
||||
|
||||
let prev_date = NaiveDate::from_ymd_opt(2025, 2, 7).unwrap();
|
||||
let mut roundtrip_portfolio = PortfolioState::new(20_000.0);
|
||||
roundtrip_portfolio
|
||||
.position_mut(symbol)
|
||||
.buy(prev_date, 2000, 2.90);
|
||||
roundtrip_portfolio.begin_trading_day();
|
||||
roundtrip_portfolio
|
||||
.position_mut(symbol)
|
||||
.sell(2000, 3.01)
|
||||
.expect("same day sell");
|
||||
roundtrip_portfolio.prune_flat_positions();
|
||||
roundtrip_portfolio
|
||||
.position_mut(symbol)
|
||||
.buy(buy_date, 1800, 3.01);
|
||||
roundtrip_portfolio
|
||||
.update_prices(buy_date, &dataset, PriceField::Close)
|
||||
.expect("same day roundtrip close");
|
||||
let position = roundtrip_portfolio.position(symbol).expect("position");
|
||||
assert!((position.last_price - 3.06).abs() < 1e-9);
|
||||
assert!((position.market_value() - 5508.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_tracks_day_lifecycle_fields() {
|
||||
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ use chrono::NaiveDate;
|
||||
|
||||
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
|
||||
use crate::portfolio::Position;
|
||||
use crate::risk_control::ChinaAShareRiskControl;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuleCheck {
|
||||
@@ -26,6 +27,10 @@ impl RuleCheck {
|
||||
}
|
||||
|
||||
pub trait EquityRuleHooks {
|
||||
fn duplicates_standard_china_risk(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn can_buy(
|
||||
&self,
|
||||
execution_date: NaiveDate,
|
||||
@@ -47,21 +52,11 @@ pub trait EquityRuleHooks {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ChinaEquityRuleHooks;
|
||||
|
||||
impl ChinaEquityRuleHooks {
|
||||
fn at_upper_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool {
|
||||
snapshot.is_at_upper_limit_price(snapshot.buy_price(price_field))
|
||||
}
|
||||
|
||||
fn at_lower_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool {
|
||||
let check_price = match price_field {
|
||||
PriceField::Last => snapshot.price(PriceField::Last),
|
||||
_ => snapshot.sell_price(price_field),
|
||||
};
|
||||
snapshot.is_at_lower_limit_price(check_price)
|
||||
}
|
||||
}
|
||||
|
||||
impl EquityRuleHooks for ChinaEquityRuleHooks {
|
||||
fn duplicates_standard_china_risk(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn can_buy(
|
||||
&self,
|
||||
_execution_date: NaiveDate,
|
||||
@@ -69,14 +64,14 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
|
||||
candidate: &CandidateEligibility,
|
||||
price_field: PriceField,
|
||||
) -> RuleCheck {
|
||||
if snapshot.paused || candidate.is_paused {
|
||||
return RuleCheck::reject("paused");
|
||||
}
|
||||
if !candidate.allow_buy {
|
||||
return RuleCheck::reject("buy disabled by eligibility flags");
|
||||
}
|
||||
if Self::at_upper_limit(snapshot, price_field) {
|
||||
return RuleCheck::reject("open at or above upper limit");
|
||||
if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason(
|
||||
_execution_date,
|
||||
candidate,
|
||||
snapshot,
|
||||
None,
|
||||
ChinaAShareRiskControl::buy_check_price(snapshot, price_field),
|
||||
) {
|
||||
return RuleCheck::reject(reason);
|
||||
}
|
||||
|
||||
RuleCheck::allow()
|
||||
@@ -90,17 +85,15 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
|
||||
position: &Position,
|
||||
price_field: PriceField,
|
||||
) -> RuleCheck {
|
||||
if snapshot.paused || candidate.is_paused {
|
||||
return RuleCheck::reject("paused");
|
||||
}
|
||||
if !candidate.allow_sell {
|
||||
return RuleCheck::reject("sell disabled by eligibility flags");
|
||||
}
|
||||
if Self::at_lower_limit(snapshot, price_field) {
|
||||
return RuleCheck::reject("open at or below lower limit");
|
||||
}
|
||||
if position.sellable_qty(execution_date) == 0 {
|
||||
return RuleCheck::reject("t+1 sellable quantity is zero");
|
||||
if let Some(reason) = ChinaAShareRiskControl::sell_rejection_reason(
|
||||
execution_date,
|
||||
candidate,
|
||||
snapshot,
|
||||
None,
|
||||
Some(position),
|
||||
ChinaAShareRiskControl::sell_check_price(snapshot, price_field),
|
||||
) {
|
||||
return RuleCheck::reject(reason);
|
||||
}
|
||||
|
||||
RuleCheck::allow()
|
||||
|
||||
@@ -7,7 +7,7 @@ pub enum ScheduleStage {
|
||||
BeforeTrading,
|
||||
OpenAuction,
|
||||
Bar,
|
||||
Tick,
|
||||
Minute,
|
||||
OnDay,
|
||||
AfterTrading,
|
||||
Settlement,
|
||||
@@ -225,7 +225,7 @@ pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> {
|
||||
ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")),
|
||||
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")),
|
||||
ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
|
||||
ScheduleStage::Tick => None,
|
||||
ScheduleStage::Minute => None,
|
||||
ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
|
||||
ScheduleStage::AfterTrading => Some(NaiveTime::from_hms_opt(15, 0, 0).expect("valid time")),
|
||||
ScheduleStage::Settlement => Some(NaiveTime::from_hms_opt(15, 1, 0).expect("valid time")),
|
||||
|
||||
+643
-414
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,30 @@ pub struct StrategyAiCatalog {
|
||||
pub indicator_factors: Vec<String>,
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub clickhouse_table_fields: Vec<ManualFactorSource>,
|
||||
pub data_lake_fields: Vec<ManualFactorSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StrategyAiHoldingCountContract {
|
||||
#[serde(
|
||||
default,
|
||||
alias = "holdingCount",
|
||||
alias = "holding_count",
|
||||
alias = "targetHoldingCount",
|
||||
alias = "target_holding_count"
|
||||
)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub count: Option<i64>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "kind",
|
||||
alias = "holdingCountMode",
|
||||
alias = "holding_count_mode",
|
||||
alias = "targetHoldingCountMode",
|
||||
alias = "target_holding_count_mode"
|
||||
)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -79,6 +102,9 @@ pub struct StrategyAiGenerateRequest {
|
||||
pub market: String,
|
||||
pub benchmark_symbol: String,
|
||||
pub signal_symbol: String,
|
||||
#[serde(default, alias = "holdingCountContract")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -87,8 +113,15 @@ pub struct StrategyAiOptimizeRequest {
|
||||
pub objective: String,
|
||||
pub result_summary: serde_json::Value,
|
||||
pub diagnostics: Vec<String>,
|
||||
#[serde(default, alias = "holdingCountContract")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
|
||||
}
|
||||
|
||||
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益 >= 150% 即视为满足收益目标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同。";
|
||||
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "reject_st_selection=false、reject_st_buy=true、reject_star_st_selection=false、reject_star_st_buy=true、reject_paused_selection=false、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=false、reject_inactive_buy=true、reject_inactive_sell=true、reject_new_listing_selection=false、reject_new_listing_buy=true、reject_kcb_selection=false、reject_kcb_buy=true、reject_bjse_selection=false、reject_bjse_buy=true、reject_one_yuan_selection=false、reject_one_yuan_buy=true、respect_allow_buy_sell=true、reject_upper_limit_selection=false、reject_lower_limit_selection=false、reject_upper_limit_buy=true、reject_lower_limit_sell=true、forbid_same_day_rebuy_after_sell=true、blacklist_enabled=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\"";
|
||||
const DEFAULT_RISK_POLICY_DSL_CODE: &str = "reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=true, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\"";
|
||||
|
||||
pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
StrategyAiManual {
|
||||
title: "OmniQuant 平台策略脚本手册".to_string(),
|
||||
@@ -97,10 +130,12 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
"平台策略脚本采用声明式 DSL + 表达式执行模型。".to_string(),
|
||||
"支持 let 变量、fn 自定义函数、when/unless/else 条件块、可用指标/因子字段映射。".to_string(),
|
||||
"支持数值型和字符串型因子,字符串字段可用于行业、概念、标签、板块等分类过滤。".to_string(),
|
||||
"当前默认回测数据已支持 OHLCV、市值、流通市值、换手率、有效换手率、上市天数、停牌/ST/板块、涨跌停价格、tick 触达涨跌停、常用价格/成交量均线,以及 stock_indicator_factors_v1 中已入库的通用指标因子。".to_string(),
|
||||
"用户明确指定目标持仓数量或最低持仓数量时,selection.limit 必须严格表达该数量;不要因为优化收益、减少交易或转换框架而擅自改小持仓数。".to_string(),
|
||||
"当前默认回测数据已支持 OHLCV、市值、流通市值、换手率、有效换手率、上市天数、停牌/ST/板块、涨跌停价格、分钟线触达涨跌停、常用价格/成交量均线,以及 stock_indicator_factors_v1 中已入库的通用指标因子。".to_string(),
|
||||
"AI 生成策略时只能输出完整 engine-script 代码,不输出 Markdown、解释、推理过程、JSON 包装或手册复述。".to_string(),
|
||||
"表达式字段以运行时字段为准:市值使用 market_cap,流通市值使用 free_float_cap;不要在策略表达式中使用数据库原始字段 float_market_cap。".to_string(),
|
||||
"任意窗口价格均线使用 rolling_mean(\"close\", n) 或 ma(\"close\", n),任意窗口均量使用 rolling_mean(\"volume\", n) 或 vma(n);不要使用未列出的 ma60、stock_ma60、signal_ma60 或 benchmark_ma60 变量。".to_string(),
|
||||
"next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(),
|
||||
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
|
||||
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
|
||||
],
|
||||
@@ -165,6 +200,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
title: "诊断解释".to_string(),
|
||||
detail: "结果为空或收益异常时优先展示 diagnostics、选股数量、过滤原因、缺失字段、窗口不足、涨跌停/停牌拒单、快照缓存命中情况。不要只返回 JSON;要给用户自然语言结论和下一步优化建议。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "收益合理性复核".to_string(),
|
||||
detail: "展示或用于优化前,应按 finalEquity / initialCash - 1 复算总收益。若小资金回测出现极端收益、指标与资金不一致、或历史 run 来自旧引擎,应检查交易明细并用当前编译后的回测引擎重新回测,不要把异常 run 当成成功样本。".to_string(),
|
||||
},
|
||||
],
|
||||
optimization_playbook: vec![
|
||||
ManualSection {
|
||||
@@ -198,8 +237,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
detail: "支持按交易周或交易月调仓,例如 rebalance.weekly(weekday=5).at([\"10:18\"])、rebalance.weekly(tradingday=-1).at([\"10:18\"])、rebalance.monthly(tradingday=1).at([\"10:18\"])。`.at([...])` 的最后一个时刻会编进分钟级 schedule/time_rule;当前平台把 on_day 近似到 10:18,把 open_auction 近似到 09:31。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "bar / tick 生命周期".to_string(),
|
||||
detail: "回测内核支持 平台内核 风格的 bar/tick 生命周期:日内会发布 pre_bar/bar/post_bar 过程事件;存在 tick 订阅或 tick 调度规则时,会按 execution_quotes 的时间顺序发布 pre_tick/tick/post_tick,并把 tick 阶段下单限制在当前 tick 时间窗内撮合。平台 DSL 中可通过 subscribe([...])、trading.subscription_guard(true) 和 process_event 字段配合显式订单模拟 tick 订阅策略。".to_string(),
|
||||
title: "bar / minute execution 生命周期".to_string(),
|
||||
detail: "回测内核支持 平台内核 风格的 bar/分钟执行价生命周期:日内会发布 pre_bar/bar/post_bar 过程事件;存在分钟执行价订阅或分钟调度规则时,会按 execution_quotes 的时间顺序发布 pre_minute/minute/post_minute 过程事件,并把日内阶段下单限制在当前分钟执行价时间窗内撮合。平台 DSL 中可通过 subscribe([...])、trading.subscription_guard(true) 和 process_event 字段配合显式订单模拟日内订阅策略。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "selection.market_cap_band / selection.limit / ordering.rank_by / ordering.rank_expr".to_string(),
|
||||
@@ -209,21 +248,25 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
title: "filter.stock_expr / risk.stop_loss / risk.take_profit / allocation.buy_scale".to_string(),
|
||||
detail: "表达式型规则,支持多条组合。stop_loss/take_profit 多条按 OR 组合,filter.stock_expr 多条按 AND 组合。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "risk.policy / risk.blacklist".to_string(),
|
||||
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_star_st_selection、reject_star_st_buy、reject_paused_selection、reject_paused_buy、reject_paused_sell、reject_inactive_selection、reject_inactive_buy、reject_inactive_sell、reject_new_listing_selection、reject_new_listing_buy、reject_kcb_selection、reject_kcb_buy、reject_bjse_selection、reject_bjse_buy、reject_one_yuan_selection、reject_one_yuan_buy、respect_allow_buy_sell、reject_upper_limit_selection、reject_lower_limit_selection、reject_upper_limit_buy、reject_lower_limit_sell、forbid_same_day_rebuy_after_sell、blacklist_enabled、allow_market_orders、live_trading_enabled、blacklisted_symbols、volume_limit_enabled、liquidity_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。ST、*ST、停牌、退市、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不要写进 universe.exclude 或 filter.stock_expr。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||
detail: "支持 corporate_actions.dividend_reinvestment(true)。开启后,现金分红到账会优先按 round lot 回补成同一只股票,零头保留为现金。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "execution.matching_type / execution.slippage".to_string(),
|
||||
detail: "设置撮合模式和滑点。支持 execution.matching_type(\"next_tick_last\" | \"next_tick_best_own\" | \"next_tick_best_counterparty\" | \"counterparty_offer\" | \"vwap\" | \"current_bar_close\" | \"next_bar_open\" | \"open_auction\")。其中 next_tick_last 使用 tick 的 last_price;next_tick_best_own / next_tick_best_counterparty 会按 L1 买一卖一近似 平台内核 的 tick 最优价语义;counterparty_offer 在存在 order_book_depth 多档盘口数据时会按真实档位逐档扫单并计算加权成交价,不存在 depth 时回退 L1 对手方报价;vwap 会在盘中执行价链路上聚合多笔成交为单条 VWAP 成交;open_auction 使用当日集合竞价开盘价 day_open 进行撮合,且不额外施加滑点,并按竞价成交量而不是盘口一档流动性限制成交;滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
|
||||
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\");current_bar_close 使用决策日当日 close,next_bar_open 使用决策日信号并在下一可交易日 open 撮合,禁止把执行日 open/high/low/close 解释为下单前已知数据;next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。日线调仓现金口径由 execution.rebalance_cash_mode(\"sell_then_buy\" | \"same_point_net\" | \"pre_open_cash\") 或页面/API 参数控制,默认 sell_then_buy;sell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "期货提交校验".to_string(),
|
||||
detail: "期货订单进入撮合前会先执行账户与交易规则校验:合约必须在上市/退市日期范围内,日行情不能停牌,trading_phase 需处于 continuous/trading/open_auction/auction/call_auction/opening_auction 等可交易阶段,限价必须为正且按 futures_trading_parameters.price_tick 或日行情 price_tick 对齐,并且不能越过 upper_limit/lower_limit;随后继续检查反向挂单自成交风险、保证金和可平数量。服务层可通过 FuturesValidationConfig 分别关闭 active instrument、trading phase、limit price tick、price limit 校验,用于兼容特殊数据,但默认全部开启。".to_string(),
|
||||
detail: "期货订单进入撮合前会先执行账户与交易规则校验:合约必须在上市/退市日期范围内,日行情不能停牌,trading_phase 需处于 continuous/trading/open_auction/auction/call_auction/opening_auction 等可交易阶段,限价必须为正且按 futures_trading_parameters.price_tick 或日行情 price_tick 对齐,并且不能越过 upper_limit/lower_limit;随后继续检查反向挂单自成交风险、保证金和可平数量。服务层可通过 FuturesValidationConfig 分别关闭 active instrument、trading phase、限价最小价位、price limit 校验,但默认全部开启。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(),
|
||||
detail: "支持显式下单、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的 tick 订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。其中 order.target_shares(...) 对应 平台内核 的 order_to,order.target_portfolio_smart(...) 对应 平台内核 的 order_target_portfolio_smart 批量目标权重语义;account.deposit_withdraw(...) 和 account.finance_repay(...) 对应 平台内核 账户出入金与融资/还款语义;order_prices 既可以是逐标的限价映射,也可以是 VWAPOrder/TWAPOrder 这类全局 AlgoOrder;order.vwap_* / order.twap_* 对应 平台内核 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 平台内核 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||
detail: "支持显式下单、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。其中 order.target_shares(...) 对应 平台内核 的 order_to,order.target_portfolio_smart(...) 对应 平台内核 的 order_target_portfolio_smart 批量目标权重语义;account.deposit_withdraw(...) 和 account.finance_repay(...) 对应 平台内核 账户出入金与融资/还款语义;order_prices 既可以是逐标的限价映射,也可以是 VWAPOrder/TWAPOrder 这类全局 AlgoOrder;order.vwap_* / order.twap_* 对应 平台内核 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 平台内核 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "when / unless / else".to_string(),
|
||||
@@ -262,17 +305,17 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
fields: vec![
|
||||
ManualField { name: "symbol".to_string(), field_type: "string".to_string(), detail: "证券代码。".to_string() },
|
||||
ManualField { name: "market_cap/free_float_cap".to_string(), field_type: "float".to_string(), detail: "总市值、流通市值。".to_string() },
|
||||
ManualField { name: "turnover/turnover_ratio/effective_turnover_ratio".to_string(), field_type: "float".to_string(), detail: "换手率、换手率标准字段、有效换手率;turnover 是 turnover_ratio 的兼容别名。".to_string() },
|
||||
ManualField { name: "turnover_ratio/effective_turnover_ratio".to_string(), field_type: "float".to_string(), detail: "换手率标准字段和有效换手率。".to_string() },
|
||||
ManualField { name: "open/high/low/close/last/last_price/prev_close/amount".to_string(), field_type: "float".to_string(), detail: "开盘、最高、最低、收盘、盘中价、昨收和成交额。".to_string() },
|
||||
ManualField { name: "upper_limit/lower_limit/price_tick/round_lot/minimum_order_quantity/order_step_size".to_string(), field_type: "float/int".to_string(), detail: "涨跌停、最小价位、整手、最小下单量和数量步长。KSH/BJSE 等板块可与 round_lot 不同。".to_string() },
|
||||
ManualField { name: "paused/is_st/is_kcb/is_one_yuan/is_new_listing".to_string(), field_type: "bool".to_string(), detail: "可交易性与板块标志。".to_string() },
|
||||
ManualField { name: "paused/is_st/is_star_st/is_kcb/is_one_yuan/is_new_listing".to_string(), field_type: "bool".to_string(), detail: "可交易性与板块标志,ST 与 *ST 是独立字段。".to_string() },
|
||||
ManualField { name: "allow_buy/allow_sell/at_upper_limit/at_lower_limit".to_string(), field_type: "bool".to_string(), detail: "盘中买卖与涨跌停状态。".to_string() },
|
||||
ManualField { name: "touched_upper_limit/touched_lower_limit/hit_upper_limit/hit_lower_limit".to_string(), field_type: "bool".to_string(), detail: "当日 tick 曾经触达涨跌停。".to_string() },
|
||||
ManualField { name: "touched_upper_limit/touched_lower_limit/hit_upper_limit/hit_lower_limit".to_string(), field_type: "bool".to_string(), detail: "当日分钟执行价曾经触达涨跌停。".to_string() },
|
||||
ManualField { name: "symbol_open_order_count/symbol_open_buy_qty/symbol_open_sell_qty/latest_symbol_open_order_id".to_string(), field_type: "int".to_string(), detail: "当前证券在挂单簿中的未成交挂单摘要和最近挂单 id。".to_string() },
|
||||
ManualField { name: "latest_symbol_open_order_status/latest_symbol_open_order_unfilled_qty".to_string(), field_type: "string/int".to_string(), detail: "当前证券最近一笔挂单的状态和未成交数量。".to_string() },
|
||||
ManualField { name: "in_dynamic_universe/is_subscribed".to_string(), field_type: "bool".to_string(), detail: "当前证券是否在动态 universe 内,以及是否仍在订阅集合中。".to_string() },
|
||||
ManualField { name: "stock_ma5/stock_ma10/stock_ma20/stock_ma30".to_string(), field_type: "float".to_string(), detail: "个股价格均线内建别名,按当前交易日前 N 个已完成交易日的收盘价计算;历史窗口不足时为 NaN,比较条件会自然不通过;15 日、45 日等任意窗口请改用 sma(\"close\", n)。".to_string() },
|
||||
ManualField { name: "stock_volume_ma5/stock_volume_ma10/stock_volume_ma20/stock_volume_ma60".to_string(), field_type: "float".to_string(), detail: "个股成交量均线内建别名,按当前交易日前 N 个已完成交易日的成交量计算,不包含回测当天未来成交量;历史窗口不足时为 NaN,比较条件会自然不通过;任意窗口请改用 rolling_mean(\"volume\", n)。".to_string() },
|
||||
ManualField { name: "stock_volume_ma5/stock_volume_ma10/stock_volume_ma20/stock_volume_ma60/stock_volume_ma100".to_string(), field_type: "float".to_string(), detail: "个股成交量均线内建别名,按当前交易日前 N 个已完成交易日的成交量计算,不包含回测当天未来成交量;历史窗口不足时为 NaN,比较条件会自然不通过;任意窗口请改用 rolling_mean(\"volume\", n)。".to_string() },
|
||||
ManualField { name: "factors[\"field\"] / factor(\"field\")".to_string(), field_type: "float/string".to_string(), detail: "当前证券当日可用因子。默认可用字段以手册的“可用指标、参数和字段”清单为准;自定义因子需要预先写入策略数据或 extra_factors。数值字段返回数字,字符串字段返回字符串。".to_string() },
|
||||
ManualField { name: "listed_days".to_string(), field_type: "int".to_string(), detail: "上市天数。".to_string() },
|
||||
],
|
||||
@@ -299,13 +342,13 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
functions: vec![
|
||||
ManualFunction { name: "factor".to_string(), signature: "factor(\"column_name\")".to_string(), detail: "读取当前股票当日可用因子列。数值因子返回 float,字符串因子返回 string;缺失字段默认返回 0 或空字符串,建议重要条件配合 diagnostics 查看候选过滤数量。".to_string() },
|
||||
ManualFunction { name: "day_factor".to_string(), signature: "day_factor(\"field_name\")".to_string(), detail: "读取日级/指数级字段映射。".to_string() },
|
||||
ManualFunction { name: "history_bars".to_string(), signature: "ctx.history_bars(symbol, count, \"1d\" | \"1m\" | \"tick\", \"close\", include_now)".to_string(), detail: "回测内核策略上下文数据 API,返回指定证券最近 N 条数值序列。日线字段支持 open/high/low/close/last/prev_close/volume/upper_limit/lower_limit;分钟或 tick 字段支持 last/bid1/ask1/volume_delta/amount_delta。日线 include_now=false 排除当前交易日;分钟/tick 会按当前 on_bar、on_tick 或调度时刻截断,include_now=false 排除当前 bar/tick,避免未来函数。".to_string() },
|
||||
ManualFunction { name: "current_snapshot".to_string(), signature: "ctx.current_snapshot(symbol)".to_string(), detail: "读取当前交易日指定证券的日级快照,可用于获得当日 open/close/last/upper_limit/lower_limit 等字段。".to_string() },
|
||||
ManualFunction { name: "history_bars".to_string(), signature: "ctx.history_bars(symbol, count, \"1d\" | \"1m\", \"close\", include_now)".to_string(), detail: "回测内核策略上下文数据 API,返回指定证券最近 N 条数值序列。日线字段支持 open/high/low/close/last/prev_close/volume/upper_limit/lower_limit;分钟字段支持 last/bid1/ask1/volume_delta/amount_delta。日线 include_now=false 排除当前信号日;分钟线会按当前 on_bar、日内事件或调度时刻截断,include_now=false 排除当前分钟执行价。next_bar_open 下该 API 只能看到信号日数据,不能读取实际成交日数据。".to_string() },
|
||||
ManualFunction { name: "current_snapshot".to_string(), signature: "ctx.current_snapshot(symbol)".to_string(), detail: "读取当前信号日指定证券的日级快照,可用于获得信号日 open/close/last/upper_limit/lower_limit 等字段;next_bar_open 的实际成交日涨跌停、停牌、ST、退市、一元、黑名单、成交量和盘口流动性由撮合层按执行日判断。".to_string() },
|
||||
ManualFunction { name: "instrument/instruments/all_instruments".to_string(), signature: "ctx.instrument(symbol)".to_string(), detail: "读取证券元数据,包括名称、板块、上市日期、退市日期、最小下单量、整手、最小价位等;all_instruments 按证券代码稳定排序返回全量证券。".to_string() },
|
||||
ManualFunction { name: "active_instruments/instruments_history".to_string(), signature: "ctx.active_instruments(&[symbol])".to_string(), detail: "active_instruments 返回当前交易日已上市且未退市的证券;instruments_history 返回给定代码的历史证券记录,包含当前已退市标的,对齐 平台内核 的 active_instruments/instruments_history 能力。".to_string() },
|
||||
ManualFunction { name: "active_instruments/instruments_history".to_string(), signature: "ctx.active_instruments(&[symbol])".to_string(), detail: "active_instruments 返回当前信号日已上市且未退市的证券;instruments_history 返回给定代码的历史证券记录,包含当前已退市标的,对齐 平台内核 的 active_instruments/instruments_history 能力。".to_string() },
|
||||
ManualFunction { name: "get_trading_dates/get_previous_trading_date/get_next_trading_date".to_string(), signature: "ctx.get_previous_trading_date(date, n)".to_string(), detail: "交易日历 API。get_trading_dates 返回闭区间交易日;previous/next 返回相对某日向前或向后的第 n 个交易日,当前日自身不计入。".to_string() },
|
||||
ManualFunction { name: "is_suspended/is_st_stock".to_string(), signature: "ctx.is_suspended(symbol, count)".to_string(), detail: "读取指定证券截至当前交易日最近 count 个交易日的停牌或 ST 标记,返回 bool 序列,顺序从旧到新;对应平台内核的 is_suspended/is_st_stock 数据能力。".to_string() },
|
||||
ManualFunction { name: "get_price".to_string(), signature: "ctx.get_price(symbol, start_date, end_date, \"1d\" | \"1m\" | \"tick\")".to_string(), detail: "按日期区间读取统一 PriceBar 序列。日线返回 open/high/low/close/last/volume/盘口字段;分钟或 tick 返回按 timestamp 排序的 last/bid1/ask1/volume_delta/amount_delta 映射,便于服务层转成表格或前端明细。".to_string() },
|
||||
ManualFunction { name: "is_suspended/is_st_stock".to_string(), signature: "ctx.is_suspended(symbol, count)".to_string(), detail: "读取指定证券截至当前信号日最近 count 个交易日的停牌或 ST 标记,返回 bool 序列,顺序从旧到新;对应平台内核的 is_suspended/is_st_stock 数据能力。执行日停牌或 ST 只能由撮合风控判断,不能在 next_bar_open 的 T 日提前固化。".to_string() },
|
||||
ManualFunction { name: "get_price".to_string(), signature: "ctx.get_price(symbol, start_date, end_date, \"1d\" | \"1m\")".to_string(), detail: "按日期区间读取统一 PriceBar 序列。日线返回 open/high/low/close/last/volume/盘口字段;分钟线返回按 timestamp 排序的 last/bid1/ask1/volume_delta/amount_delta 映射,便于服务层转成表格或前端明细。".to_string() },
|
||||
ManualFunction { name: "get_dividend / dividend_cash / has_dividend".to_string(), signature: "dividend_cash(lookback) / has_dividend(lookback)".to_string(), detail: "高级数据 风格分红 API。Rust Context 可用 ctx.get_dividend(symbol, start_date) 读取明细;平台表达式可用 dividend_cash(lookback) 汇总当前股票最近 N 个交易日现金分红,用 has_dividend(lookback) 判断是否发生分红,也支持 dividend_cash(\"600000.SH\", lookback)。".to_string() },
|
||||
ManualFunction { name: "get_split / split_ratio / has_split".to_string(), signature: "split_ratio(lookback) / has_split(lookback)".to_string(), detail: "高级数据 风格拆分/送转 API。Rust Context 可用 ctx.get_split(symbol, start_date) 读取明细;平台表达式可用 split_ratio(lookback) 计算当前股票最近 N 个交易日累计拆分比例,has_split(lookback) 判断是否发生送转。".to_string() },
|
||||
ManualFunction { name: "get_factor / factor_value".to_string(), signature: "factor_value(\"field\", lookback=1)".to_string(), detail: "数值因子 API。factor(\"field\") 读取当前股票当日因子;factor_value(\"field\", lookback) 会在最近 N 个交易日内取该字段最新数值,适合读取任意可用指标或自定义数值因子。Rust Context 可用 ctx.get_factor(symbol, start, end, field) 读取完整数值序列。".to_string() },
|
||||
@@ -323,13 +366,13 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason;可用便捷函数读取状态、成交均价和费用,对齐 平台内核 Order 的核心属性。".to_string() },
|
||||
ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等;DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() },
|
||||
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金;receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力。".to_string() },
|
||||
ManualFunction { name: "rolling_mean / sma / ma".to_string(), signature: "rolling_mean(\"field\", lookback) / ma(\"close\", 20)".to_string(), detail: "任意字段滚动均值,支持 close、volume、amount、turnover_ratio、effective_turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 和所有数值型 extra_factors。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".to_string() },
|
||||
ManualFunction { name: "rolling_mean / sma / ma".to_string(), signature: "rolling_mean(\"field\", lookback) / ma(\"close\", 20)".to_string(), detail: "任意字段滚动均值,支持 close、volume、amount、turnover_ratio、effective_turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 和所有数值型 extra_factors。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".to_string() },
|
||||
ManualFunction { name: "vma".to_string(), signature: "vma(60)".to_string(), detail: "rolling_mean(\"volume\", lookback) 的便捷别名,用于任意窗口成交量均线,例如 vma(5) < vma(60)。".to_string() },
|
||||
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
|
||||
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
|
||||
ManualFunction { name: "数据库指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "stock_indicator_factors_v1 中的指标会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() },
|
||||
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
|
||||
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;需要收益率波动时先使用已注册收益率字段或发布因子,不要写 rolling_stddev(pct_change(\"close\", 1), 20)。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
|
||||
ManualFunction { name: "Source Lake 指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "Strategy Factory Source Lake 中已完成 PIT/as-of 审计的 source rows 字段、已发布指标或因子 artifact 会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() },
|
||||
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },
|
||||
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },
|
||||
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs) / safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法,两参数形式默认 fallback=0。".to_string() },
|
||||
ManualFunction { name: "contains/starts_with/ends_with/lower/upper/trim/strlen".to_string(), signature: "starts_with(symbol, \"60\")".to_string(), detail: "字符串辅助函数。".to_string() },
|
||||
],
|
||||
factor_sources: vec![
|
||||
@@ -355,12 +398,12 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
},
|
||||
ManualFactorSource {
|
||||
table: "盘口深度参数".to_string(),
|
||||
detail: "可选字段包括 date、symbol、timestamp、level、bid_price、bid_volume、ask_price、ask_volume。存在盘口深度时,期货 counterparty_offer / next_tick_best_counterparty 可按真实多档盘口逐档扫单;不存在时不会伪造 depth。".to_string(),
|
||||
detail: "可选字段包括 date、symbol、timestamp、level、bid_price、bid_volume、ask_price、ask_volume。存在盘口深度时,期货 minute_best_counterparty 可按真实多档盘口逐档扫单;不存在时不会伪造 depth。".to_string(),
|
||||
fields: vec![],
|
||||
},
|
||||
ManualFactorSource {
|
||||
table: "期货交易参数".to_string(),
|
||||
detail: "字段包括 symbol、effective_date、contract_multiplier、long_margin_rate、short_margin_rate、commission_type、open_commission_ratio、close_commission_ratio、close_today_commission_ratio、price_tick。回测会按交易日自动选择不晚于当前日期的最新参数,用于保证金、手续费和限价 tick 校验。".to_string(),
|
||||
detail: "字段包括 symbol、effective_date、contract_multiplier、long_margin_rate、short_margin_rate、commission_type、open_commission_ratio、close_commission_ratio、close_today_commission_ratio、price_tick。回测会按交易日自动选择不晚于当前日期的最新参数,用于保证金、手续费和限价最小价位校验。".to_string(),
|
||||
fields: vec![],
|
||||
},
|
||||
],
|
||||
@@ -378,8 +421,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
code: "filter.stock_expr(industry_name(\"citics\", 1) == \"电子\" && factor_text(\"concept\") == \"ai_chip\")".to_string(),
|
||||
},
|
||||
ManualExample {
|
||||
title: "next tick 撮合 + tick 滑点".to_string(),
|
||||
code: "execution.matching_type(\"next_tick_last\")\nexecution.slippage(\"tick_size\", 1)".to_string(),
|
||||
title: "分钟执行价撮合 + 最小价位滑点".to_string(),
|
||||
code: "execution.matching_type(\"minute_last\")\nexecution.slippage(\"tick_size\", 1)".to_string(),
|
||||
},
|
||||
ManualExample {
|
||||
title: "动态 universe 和订阅".to_string(),
|
||||
@@ -427,13 +470,22 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
|
||||
out.push_str("## AI 代码生成硬约束\n");
|
||||
out.push_str("- 只输出完整 `engine-script` 代码;第一行必须是 `strategy(\"...\")`、`let`、`fn`、`const` 或 `//`。\n");
|
||||
out.push_str("- 禁止输出 Markdown、解释、推理过程、JSON 包装、手册复述或结果报告。\n");
|
||||
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`execution.matching_type`、`execution.slippage`、`universe.exclude`。\n");
|
||||
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`risk.policy`、`risk.blacklist`、`execution.matching_type`、`execution.rebalance_cash_mode`、`execution.slippage`、`universe.exclude`。\n");
|
||||
out.push_str("- `universe.exclude` 只用于用户明确要求的业务排除项;ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量、手续费和印花税等基础风控必须写 `risk.policy(...)` 或由运行态 RiskLimits 注入。\n");
|
||||
out.push_str("- 禁止伪 DSL:`filter(...)`、`rank(...)`、`select.top(...)`、`weight.equal(...)`、`sell_rule(...)`、`backtest(...)`、`risk.max_position(...)`。\n");
|
||||
out.push_str("- 市值表达式字段只能用 `market_cap` 或 `free_float_cap`;不要使用数据库原始字段 `float_market_cap`。\n");
|
||||
out.push_str("- 任意窗口价格均线使用 `rolling_mean(\"close\", n)` 或 `ma(\"close\", n)`;任意窗口均量使用 `rolling_mean(\"volume\", n)` 或 `vma(n)`;不要使用未列出的 `ma60`、`stock_ma60`、`signal_ma60` 或 `benchmark_ma60` 变量。\n");
|
||||
out.push_str("- `rolling_mean`、`rolling_sum/min/max/stddev/zscore`、`pct_change`、`factor_value` 等 helper 的第一个参数必须是字段名或字符串字段名;不要输出 `rolling_stddev(pct_change(\"close\", 1), 20)` 这类嵌套表达式。\n");
|
||||
out.push_str("- 自定义 `fn` 必须通过参数传入运行时字段;不要用 `fn score()` 这类零参数函数直接引用 `market_cap`、`close`、`ma5` 等股票字段。\n");
|
||||
out.push_str("- `selection.market_cap_band` 必须写命名参数:`field=\"market_cap\"` 或 `field=\"free_float_cap\"`,并包含 `lower=...` 与 `upper=...`。\n");
|
||||
out.push_str("- `risk.index_exposure(...)` 只能传一个表达式;`execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
|
||||
out.push_str(
|
||||
"- `risk.index_exposure(...)` 只能传一个表达式;不要生成 `risk.exposure(...)`。\n",
|
||||
);
|
||||
out.push_str("- `filter.stock_expr(...)` 只写 alpha 或业务过滤条件;不要把 `!is_st`、`!paused`、`!at_upper_limit`、`!at_lower_limit` 这类基础风控散落在过滤表达式里。\n");
|
||||
out.push_str("- 完整三元表达式 `cond ? a : b` 可在表达式参数中使用;若当前运行环境报 `Unknown operator: '?'`,先重编译并重启回测服务,不要改写策略语义掩盖运行时漂移。\n");
|
||||
out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\n");
|
||||
out.push_str("- `next_bar_open` 必须区分信号日、订单创建日和实际成交日:T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须比较实际 next-open 成交价与涨跌停价,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
|
||||
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
|
||||
out.push_str("## 语句块\n");
|
||||
for item in &manual.statement_blocks {
|
||||
out.push_str(&format!("- `{}`: {}\n", item.title, item.detail));
|
||||
@@ -504,13 +556,19 @@ pub fn build_generation_prompt(
|
||||
prompt.push_str("- 不要输出解释文本。\n");
|
||||
prompt.push_str("- 必须使用 strategy(\"...\") { ... } 语法。\n");
|
||||
prompt.push_str("- 如需自定义参数,使用 let 和 fn。\n");
|
||||
prompt.push_str("- 优先使用数据库已存在字段和 factors[...]。\n\n");
|
||||
prompt.push_str("- 优先使用 Strategy Factory Source Lake 已注册 source rows 字段、已发布指标/因子 artifact 和运行时已存在字段、factors[...];不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。\n\n");
|
||||
prompt.push_str("- 生成的代码必须能转换为 strategy_spec 并提交 POST /v1/backtests。\n");
|
||||
prompt.push_str("- 用户指定“持仓N只、目标持仓N、stocknum=N、selection.limit(N)”时,必须把最终持仓槽位写成 N;用户指定“至少/不少于N只”时,最终持仓槽位必须 >= N。\n");
|
||||
prompt.push_str("- ");
|
||||
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
|
||||
prompt.push('\n');
|
||||
prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\n");
|
||||
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、execution.matching_type、execution.slippage、universe.exclude。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
|
||||
prompt.push_str("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,写 !is_st、!paused、!at_upper_limit、!at_lower_limit,不要写 is_st == 0;risk.index_exposure 只能传一个数值表达式,例如 ((signal_close < signal_ma20) ? 0.35 : 1.0);execution.matching_type 只能取 next_tick_last、next_tick_best_own、next_tick_best_counterparty、counterparty_offer、vwap、current_bar_close、next_bar_open、open_auction;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n");
|
||||
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、risk.policy、risk.blacklist、execution.matching_type、execution.slippage、universe.exclude。universe.exclude 只用于用户明确要求的业务排除项,不能表达 FIDC 基础风控。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
|
||||
prompt.push_str(&format!("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);rolling_mean、rolling_sum/min/max/stddev/zscore、pct_change、factor_value 等 helper 的第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0;filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;risk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,必须覆盖完整默认配置面,例如 {DEFAULT_RISK_POLICY_DSL_PROMPT},不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n"));
|
||||
prompt.push_str("回测成功但 tradeCount=0 或 holdingCount=0 是无效策略;第一版必须保持稳定买入覆盖率,复杂因子只能在后续优化中逐步加严。\n");
|
||||
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && !is_st && !paused && close > 2 && !at_upper_limit && !at_lower_limit)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.index_exposure((signal_close < signal_ma20) ? 0.35 : 1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
|
||||
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && close > 2)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.policy(");
|
||||
prompt.push_str(DEFAULT_RISK_POLICY_DSL_CODE);
|
||||
prompt.push_str(")\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
|
||||
prompt.push_str("用户目标:\n");
|
||||
prompt.push_str(&format!("- {}\n", request.user_goal));
|
||||
if !request.constraints.is_empty() {
|
||||
@@ -536,8 +594,11 @@ pub fn build_optimization_prompt(
|
||||
prompt.push_str("你是 OmniQuant 平台策略脚本优化器。必须输出完整、可运行的平台策略脚本,不要输出解释文本。\n");
|
||||
prompt.push_str("输出格式硬约束:回复第一行必须是 strategy(\"...\")、let、fn、const 或 //;回复中不得包含 Markdown、解释、思考过程、手册复述、JSON 包装或自然语言总结。\n");
|
||||
prompt.push_str("长度硬约束:策略代码目标 80 行以内,只保留必要 let/fn/strategy 块;不要复制下面的手册片段、历史策略全文或字段清单。\n");
|
||||
prompt.push_str("只修改与优化目标相关的少量参数或过滤条件,保留原策略的市场、基准、信号指数和核心风控;不要引入手册未列出的字段或外部平台 API 名称。\n");
|
||||
prompt.push_str("优化可以调整调仓周期、持仓数、市值带、filter.stock_expr、ordering.rank_expr、allocation.buy_scale、止盈止损;如上一轮无交易或质量分过低,必须先放宽过滤条件并优先使用已入库指标因子、rolling_mean/ma/vma/rolling_stddev/pct_change 等支持函数。\n");
|
||||
prompt.push_str("优化不限制在原策略已有参数或少量扰动。只要 OmniQuant/FIDC 已支持,可以自由增加、修改、删除策略代码、参数、候选池、过滤函数、排序、仓位、止盈止损、调仓周期、指标因子和辅助函数;不得引入手册未列出的字段或外部平台 API 名称。\n");
|
||||
prompt.push_str("持仓数量属于策略合同,不是优化自由参数。原策略或用户目标明确 stocknum、selection.limit、目标持仓N只或不少于N只时,优化后必须保留该目标槽位或满足最低槽位,不能为了收益或交易次数擅自改小。\n");
|
||||
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
|
||||
prompt.push('\n');
|
||||
prompt.push_str("可以使用 Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计的日频 source rows 字段、已发布指标/因子 artifact 和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;这些滚动/因子 helper 的字段参数只能是字段名或字符串字段名,不要嵌套表达式;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
|
||||
prompt.push_str("优化目标:\n");
|
||||
prompt.push_str(&format!("- {}\n\n", request.objective));
|
||||
prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n");
|
||||
@@ -559,3 +620,63 @@ pub fn build_optimization_prompt(
|
||||
prompt.push_str(manual_markdown);
|
||||
prompt
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn generation_prompt_contains_three_year_return_acceptance_target() {
|
||||
let prompt = build_generation_prompt(
|
||||
"manual",
|
||||
&StrategyAiGenerateRequest {
|
||||
user_goal: "生成策略".to_string(),
|
||||
constraints: Vec::new(),
|
||||
market: "CN_A".to_string(),
|
||||
benchmark_symbol: "000852.SH".to_string(),
|
||||
signal_symbol: "000001.SH".to_string(),
|
||||
holding_count_contract: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||
assert!(prompt.contains("不得把已达标策略判为失败"));
|
||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
|
||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||
assert!(prompt.contains("ClickHouse"));
|
||||
assert!(prompt.contains("T 日只生成订单意图"));
|
||||
assert!(prompt.contains("按实际成交日判断"));
|
||||
assert!(prompt.contains("禁止用 T 日执行状态拦截 T+1 可交易订单"));
|
||||
assert!(prompt.contains("必须覆盖完整默认配置面"));
|
||||
assert!(prompt.contains("reject_inactive_buy=true"));
|
||||
assert!(prompt.contains("reject_inactive_sell=true"));
|
||||
assert!(prompt.contains("reject_new_listing_buy=true"));
|
||||
assert!(prompt.contains("reject_kcb_buy=true"));
|
||||
assert!(prompt.contains("reject_bjse_buy=true"));
|
||||
assert!(prompt.contains("reject_one_yuan_buy=true"));
|
||||
assert!(prompt.contains("respect_allow_buy_sell=true"));
|
||||
assert!(prompt.contains("stamp_tax_rate_before_change=0.001"));
|
||||
assert!(prompt.contains("stamp_tax_change_date=\"2023-08-28\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimization_prompt_contains_three_year_return_acceptance_target() {
|
||||
let prompt = build_optimization_prompt(
|
||||
"manual",
|
||||
&StrategyAiOptimizeRequest {
|
||||
current_code: "strategy(\"demo\") {}".to_string(),
|
||||
objective: "优化收益".to_string(),
|
||||
result_summary: json!({ "total_return": 1.49 }),
|
||||
diagnostics: Vec::new(),
|
||||
holding_count_contract: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||
assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性"));
|
||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
|
||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||
assert!(prompt.contains("ClickHouse"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use chrono::NaiveDate;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::data::{BenchmarkSnapshot, DataSet, EligibleUniverseSnapshot};
|
||||
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BandRegime {
|
||||
@@ -39,6 +40,7 @@ pub struct SelectionDiagnostics {
|
||||
pub missing_market_cap_symbols: Vec<String>,
|
||||
pub selected_symbols: Vec<String>,
|
||||
pub rejection_examples: Vec<String>,
|
||||
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
|
||||
}
|
||||
|
||||
pub struct SelectionContext<'a> {
|
||||
@@ -47,20 +49,62 @@ pub struct SelectionContext<'a> {
|
||||
pub reference_level: f64,
|
||||
pub data: &'a DataSet,
|
||||
pub dynamic_universe: Option<&'a BTreeSet<String>>,
|
||||
pub risk_config: Option<&'a FidcRiskControlConfig>,
|
||||
pub defer_selection_risk: bool,
|
||||
}
|
||||
|
||||
impl SelectionContext<'_> {
|
||||
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
|
||||
let eligible = self.data.eligible_universe_on(self.decision_date);
|
||||
let eligible = match (self.risk_config, self.defer_selection_risk) {
|
||||
(Some(risk_config), false) => self
|
||||
.data
|
||||
.eligible_universe_on_with_risk_config(self.decision_date, risk_config),
|
||||
_ => self.data.eligible_universe_on(self.decision_date).to_vec(),
|
||||
};
|
||||
match self.dynamic_universe {
|
||||
Some(symbols) if !symbols.is_empty() => eligible
|
||||
.iter()
|
||||
.into_iter()
|
||||
.filter(|row| symbols.contains(&row.symbol))
|
||||
.cloned()
|
||||
.collect(),
|
||||
_ => eligible.to_vec(),
|
||||
_ => eligible,
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_risk_decisions(&self) -> Vec<FidcRiskDecisionAudit> {
|
||||
let default_risk_config;
|
||||
let risk_config = match self.risk_config {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
default_risk_config = FidcRiskControlConfig::default();
|
||||
&default_risk_config
|
||||
}
|
||||
};
|
||||
let mut decisions = Vec::new();
|
||||
for factor in self.data.factor_snapshots_on(self.decision_date) {
|
||||
if self
|
||||
.dynamic_universe
|
||||
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(candidate) = self.data.candidate(self.decision_date, &factor.symbol) else {
|
||||
continue;
|
||||
};
|
||||
let Some(market) = self.data.market(self.decision_date, &factor.symbol) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(decision) = ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
self.decision_date,
|
||||
candidate,
|
||||
market,
|
||||
self.data.instrument(&factor.symbol),
|
||||
risk_config,
|
||||
) {
|
||||
decisions.push(decision);
|
||||
}
|
||||
}
|
||||
decisions
|
||||
}
|
||||
}
|
||||
|
||||
pub trait UniverseSelector {
|
||||
@@ -78,6 +122,9 @@ pub struct DynamicMarketCapBandSelector {
|
||||
pub cap_span: f64,
|
||||
pub xs: f64,
|
||||
pub top_n: usize,
|
||||
pub padding_ratio: f64,
|
||||
pub min_padding: f64,
|
||||
pub max_padding: f64,
|
||||
}
|
||||
|
||||
impl DynamicMarketCapBandSelector {
|
||||
@@ -87,6 +134,9 @@ impl DynamicMarketCapBandSelector {
|
||||
cap_span: f64,
|
||||
xs: f64,
|
||||
top_n: usize,
|
||||
padding_ratio: f64,
|
||||
min_padding: f64,
|
||||
max_padding: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_index_level,
|
||||
@@ -94,11 +144,14 @@ impl DynamicMarketCapBandSelector {
|
||||
cap_span,
|
||||
xs,
|
||||
top_n,
|
||||
padding_ratio,
|
||||
min_padding,
|
||||
max_padding,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn demo(top_n: usize) -> Self {
|
||||
Self::new(2000.0, 7.0, 10.0, 4.0 / 500.0, top_n)
|
||||
Self::new(2000.0, 7.0, 10.0, 4.0 / 500.0, top_n, 0.5, 8.0, 20.0)
|
||||
}
|
||||
|
||||
pub fn regime(&self, benchmark_level: f64) -> BandRegime {
|
||||
@@ -114,7 +167,18 @@ impl DynamicMarketCapBandSelector {
|
||||
pub fn band_for_level(&self, benchmark_level: f64) -> (f64, f64) {
|
||||
let start = ((benchmark_level - self.base_index_level) * self.xs) + self.base_cap_floor;
|
||||
let low = start.round();
|
||||
(low, low + self.cap_span)
|
||||
let high = low + self.cap_span;
|
||||
|
||||
// Apply padding to expand the range
|
||||
let span = high - low;
|
||||
let padding = (span * self.padding_ratio)
|
||||
.max(self.min_padding)
|
||||
.min(self.max_padding);
|
||||
|
||||
let lower_bound = (low - padding).max(0.0);
|
||||
let upper_bound = high + padding;
|
||||
|
||||
(lower_bound, upper_bound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,9 +210,23 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
|
||||
missing_market_cap_symbols: Vec::new(),
|
||||
selected_symbols: Vec::new(),
|
||||
rejection_examples: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
};
|
||||
|
||||
diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len();
|
||||
diagnostics.risk_decisions = ctx.selection_risk_decisions();
|
||||
diagnostics.not_eligible_count = diagnostics.risk_decisions.len();
|
||||
diagnostics.paused_count = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.filter(|decision| decision.rule_code == "paused")
|
||||
.count();
|
||||
diagnostics.rejection_examples = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|decision| format!("{} rejected by {}", decision.symbol, decision.rule_code))
|
||||
.collect();
|
||||
let eligible = ctx.eligible_universe();
|
||||
diagnostics.market_cap_missing_count =
|
||||
diagnostics.factor_total.saturating_sub(eligible.len());
|
||||
@@ -201,3 +279,266 @@ fn to_universe_candidate(
|
||||
band_high,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data::{
|
||||
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||
};
|
||||
use crate::instrument::Instrument;
|
||||
|
||||
fn d() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()
|
||||
}
|
||||
|
||||
fn instrument(symbol: &str) -> Instrument {
|
||||
Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: symbol.rsplit('.').next().unwrap_or("").to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn market(symbol: &str, price: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: d(),
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: Some("2025-01-02 10:00:00".to_string()),
|
||||
day_open: price,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
last_price: price,
|
||||
bid1: price,
|
||||
ask1: price,
|
||||
prev_close: price,
|
||||
volume: 1_000_000,
|
||||
minute_volume: 10_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: price * 1.1,
|
||||
lower_limit: price * 0.9,
|
||||
price_tick: 0.01,
|
||||
}
|
||||
}
|
||||
|
||||
fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date: d(),
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn: market_cap_bn,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(0.01),
|
||||
effective_turnover_ratio: Some(0.01),
|
||||
extra_factors: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date: d(),
|
||||
symbol: symbol.to_string(),
|
||||
is_st,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn benchmark() -> BenchmarkSnapshot {
|
||||
BenchmarkSnapshot {
|
||||
date: d(),
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 2000.0,
|
||||
close: 2000.0,
|
||||
prev_close: 1990.0,
|
||||
volume: 1_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_records_structured_selection_risk_decisions() {
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
instrument("000001.SZ"),
|
||||
instrument("688001.SH"),
|
||||
instrument("000002.SZ"),
|
||||
],
|
||||
vec![
|
||||
market("000001.SZ", 10.0),
|
||||
market("688001.SH", 10.0),
|
||||
market("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
factor("000001.SZ", 8.0),
|
||||
factor("688001.SH", 9.0),
|
||||
factor("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
candidate("000001.SZ", true, false),
|
||||
candidate("688001.SH", false, true),
|
||||
candidate("000002.SZ", false, false),
|
||||
],
|
||||
vec![benchmark()],
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
let (_selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&risk_config),
|
||||
defer_selection_risk: false,
|
||||
});
|
||||
|
||||
let rules = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.map(|decision| decision.rule_code.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
|
||||
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
|
||||
assert_eq!(
|
||||
diagnostics.not_eligible_count,
|
||||
diagnostics.risk_decisions.len()
|
||||
);
|
||||
assert!(
|
||||
diagnostics.risk_decisions[0]
|
||||
.diagnostic_line()
|
||||
.starts_with("risk_decision=")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_applies_configured_selection_risk_on_decision_date() {
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
instrument("000001.SZ"),
|
||||
instrument("688001.SH"),
|
||||
instrument("000002.SZ"),
|
||||
],
|
||||
vec![
|
||||
market("000001.SZ", 10.0),
|
||||
market("688001.SH", 10.0),
|
||||
market("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
factor("000001.SZ", 8.0),
|
||||
factor("688001.SH", 9.0),
|
||||
factor("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
candidate("000001.SZ", true, false),
|
||||
candidate("688001.SH", false, true),
|
||||
candidate("000002.SZ", false, false),
|
||||
],
|
||||
vec![benchmark()],
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&risk_config),
|
||||
defer_selection_risk: false,
|
||||
});
|
||||
|
||||
let selected_symbols = selected
|
||||
.iter()
|
||||
.map(|candidate| candidate.symbol.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(!selected_symbols.contains("000001.SZ"));
|
||||
assert!(!selected_symbols.contains("688001.SH"));
|
||||
assert!(selected_symbols.contains("000002.SZ"));
|
||||
assert_eq!(diagnostics.not_eligible_count, 2);
|
||||
let rules = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.map(|decision| decision.rule_code.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
|
||||
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_can_defer_configured_selection_risk_without_losing_diagnostics() {
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
instrument("000001.SZ"),
|
||||
instrument("688001.SH"),
|
||||
instrument("000002.SZ"),
|
||||
],
|
||||
vec![
|
||||
market("000001.SZ", 10.0),
|
||||
market("688001.SH", 10.0),
|
||||
market("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
factor("000001.SZ", 8.0),
|
||||
factor("688001.SH", 9.0),
|
||||
factor("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
candidate("000001.SZ", true, false),
|
||||
candidate("688001.SH", false, true),
|
||||
candidate("000002.SZ", false, false),
|
||||
],
|
||||
vec![benchmark()],
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
|
||||
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&risk_config),
|
||||
defer_selection_risk: true,
|
||||
});
|
||||
|
||||
let selected_symbols = selected
|
||||
.iter()
|
||||
.map(|candidate| candidate.symbol.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(selected_symbols.contains("000001.SZ"));
|
||||
assert!(selected_symbols.contains("688001.SH"));
|
||||
assert!(selected_symbols.contains("000002.SZ"));
|
||||
let rules = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.map(|decision| decision.rule_code.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
|
||||
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,14 @@ fn candidate() -> CandidateEligibility {
|
||||
date: d(2024, 1, 3),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +43,7 @@ fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapsho
|
||||
ask1: open,
|
||||
prev_close: 10.0,
|
||||
volume: 1_000_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 50_000,
|
||||
ask1_volume: 50_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -61,10 +63,26 @@ fn china_cost_model_applies_minimum_commission_and_stamp_tax() {
|
||||
assert_eq!(buy.stamp_tax, 0.0);
|
||||
|
||||
let sell = model.calculate(d(2023, 8, 25), OrderSide::Sell, 100_000.0);
|
||||
assert!((sell.commission - 30.0).abs() < 1e-9);
|
||||
assert!((sell.commission - 80.0).abs() < 1e-9);
|
||||
assert!((sell.stamp_tax - 100.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiquant_cost_model_matches_alv_run_options() {
|
||||
let model = ChinaAShareCostModel::aiquant_default();
|
||||
|
||||
let buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 49_978.84);
|
||||
assert!((buy.commission - 14.993652).abs() < 1e-9);
|
||||
assert_eq!(buy.stamp_tax, 0.0);
|
||||
|
||||
let sell = model.calculate(d(2026, 5, 19), OrderSide::Sell, 100_724.72);
|
||||
assert!((sell.commission - 30.217416).abs() < 1e-9);
|
||||
assert!((sell.stamp_tax - 50.36236).abs() < 1e-9);
|
||||
|
||||
let small_buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 1_000.0);
|
||||
assert!((small_buy.commission - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn china_cost_model_switches_stamp_tax_rate_after_2023_08_28() {
|
||||
let model = ChinaAShareCostModel::default();
|
||||
@@ -112,7 +130,7 @@ fn china_cost_model_tracks_minimum_commission_per_order_id() {
|
||||
|
||||
assert!((first.commission - 5.0).abs() < 1e-9);
|
||||
assert!(second.commission.abs() < 1e-9);
|
||||
assert!((third.commission - 1.6).abs() < 1e-9);
|
||||
assert!((third.commission - 12.6).abs() < 1e-9);
|
||||
assert!((another_order.commission - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
@@ -175,7 +193,7 @@ fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn china_rule_hooks_use_tick_size_tolerance_for_price_limits() {
|
||||
fn china_rule_hooks_use_strict_price_limits() {
|
||||
let hooks = ChinaEquityRuleHooks;
|
||||
let candidate = candidate();
|
||||
|
||||
@@ -184,6 +202,13 @@ fn china_rule_hooks_use_tick_size_tolerance_for_price_limits() {
|
||||
..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 exact_upper = DailyMarketSnapshot {
|
||||
price_tick: 0.001,
|
||||
..snapshot(11.0, 11.0, 9.0)
|
||||
};
|
||||
let buy_check = hooks.can_buy(d(2024, 1, 3), &exact_upper, &candidate, PriceField::Open);
|
||||
assert!(!buy_check.allowed);
|
||||
|
||||
let near_lower = DailyMarketSnapshot {
|
||||
@@ -199,6 +224,19 @@ fn china_rule_hooks_use_tick_size_tolerance_for_price_limits() {
|
||||
&position,
|
||||
PriceField::Open,
|
||||
);
|
||||
assert!(sell_check.allowed);
|
||||
|
||||
let exact_lower = DailyMarketSnapshot {
|
||||
price_tick: 0.001,
|
||||
..snapshot(9.0, 11.0, 9.0)
|
||||
};
|
||||
let sell_check = hooks.can_sell(
|
||||
d(2024, 1, 3),
|
||||
&exact_lower,
|
||||
&candidate,
|
||||
&position,
|
||||
PriceField::Open,
|
||||
);
|
||||
assert!(!sell_check.allowed);
|
||||
}
|
||||
|
||||
@@ -223,7 +261,7 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
|
||||
ask1: 2.53,
|
||||
prev_close: 2.80,
|
||||
volume: 1_000_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 50_000,
|
||||
ask1_volume: 50_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
|
||||
@@ -88,10 +88,77 @@ impl Strategy for BuyAndHoldStrategy {
|
||||
},
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.1,
|
||||
low: 9.9,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
minute_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,
|
||||
}
|
||||
}
|
||||
|
||||
fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
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),
|
||||
extra_factors: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stock_candidate(date: NaiveDate) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn benchmark_snapshot(date: NaiveDate) -> BenchmarkSnapshot {
|
||||
BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
let buy_date = d(2025, 1, 1);
|
||||
@@ -122,7 +189,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -145,7 +212,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -168,7 +235,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -215,34 +282,40 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
date: buy_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: payable_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
@@ -300,7 +373,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
PriceField::Open,
|
||||
),
|
||||
BacktestConfig {
|
||||
initial_cash: 11_005.0,
|
||||
initial_cash: 11_008.0,
|
||||
benchmark_code: "000300.SH".to_string(),
|
||||
start_date: Some(buy_date),
|
||||
end_date: Some(payable_date),
|
||||
@@ -327,3 +400,96 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
assert_eq!(reinvest_fill.commission, 0.0);
|
||||
assert_eq!(reinvest_fill.stamp_tax, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() {
|
||||
let buy_date = d(2025, 1, 1);
|
||||
let ex_date = d(2025, 1, 2);
|
||||
let data = DataSet::from_components_with_actions(
|
||||
vec![Instrument {
|
||||
symbol: "000001.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![
|
||||
stock_market_snapshot(buy_date),
|
||||
stock_market_snapshot(ex_date),
|
||||
],
|
||||
vec![
|
||||
stock_factor_snapshot(buy_date),
|
||||
stock_factor_snapshot(ex_date),
|
||||
],
|
||||
vec![stock_candidate(buy_date), stock_candidate(ex_date)],
|
||||
vec![benchmark_snapshot(buy_date), benchmark_snapshot(ex_date)],
|
||||
vec![CorporateAction {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
payable_date: Some(ex_date),
|
||||
share_cash: 1.05,
|
||||
share_bonus: 0.2,
|
||||
share_gift: 0.0,
|
||||
issue_quantity: 0.0,
|
||||
issue_price: 0.0,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: None,
|
||||
successor_ratio: None,
|
||||
successor_cash: None,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
BuyAndHoldStrategy {
|
||||
first_date: buy_date,
|
||||
},
|
||||
BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
),
|
||||
BacktestConfig {
|
||||
initial_cash: 11_008.0,
|
||||
benchmark_code: "000300.SH".to_string(),
|
||||
start_date: Some(buy_date),
|
||||
end_date: Some(ex_date),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_dividend_reinvestment(true);
|
||||
|
||||
let result = engine.run().expect("backtest run");
|
||||
let final_holding = result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.find(|row| row.symbol == "000001.SZ")
|
||||
.expect("holding");
|
||||
assert_eq!(final_holding.quantity, 1_300);
|
||||
|
||||
let reinvest_fill = result
|
||||
.fills
|
||||
.iter()
|
||||
.find(|fill| fill.reason == "dividend_reinvestment")
|
||||
.expect("reinvestment fill");
|
||||
assert_eq!(reinvest_fill.quantity, 100);
|
||||
assert!((reinvest_fill.price - ((10.0 - 1.05) / 1.2)).abs() < 1e-9);
|
||||
|
||||
assert!(
|
||||
result
|
||||
.position_events
|
||||
.iter()
|
||||
.any(|event| event.reason == "stock_split 1.200000" && event.quantity_after == 1_200)
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.account_events
|
||||
.iter()
|
||||
.any(|event| event.note.contains("cash_receivable_reinvested"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
use chrono::{Duration, NaiveDate, NaiveTime};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||
StrategyDecision,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
||||
}
|
||||
|
||||
fn t(hour: u32, minute: u32, second: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DecisionQuoteReader {
|
||||
day_count: usize,
|
||||
}
|
||||
|
||||
impl Strategy for DecisionQuoteReader {
|
||||
fn name(&self) -> &str {
|
||||
"decision_quote_reader"
|
||||
}
|
||||
|
||||
fn decision_quote_times(&self) -> Vec<NaiveTime> {
|
||||
vec![t(10, 40, 0)]
|
||||
}
|
||||
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.day_count += 1;
|
||||
if self.day_count == 1 {
|
||||
return Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Value {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
value: 5_000.0,
|
||||
reason: "seed_position".to_string(),
|
||||
}],
|
||||
..StrategyDecision::default()
|
||||
});
|
||||
}
|
||||
|
||||
assert!(
|
||||
ctx.portfolio.position("000001.SZ").is_some(),
|
||||
"second day should carry the first day position"
|
||||
);
|
||||
let quote_loaded_before_decision = ctx
|
||||
.data
|
||||
.execution_quotes_on(ctx.execution_date, "000001.SZ")
|
||||
.iter()
|
||||
.any(|quote| quote.timestamp.time() == t(10, 39, 59) && quote.last_price == 11.0);
|
||||
assert!(
|
||||
quote_loaded_before_decision,
|
||||
"engine must load declared decision quote before strategy.on_day"
|
||||
);
|
||||
Ok(StrategyDecision::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
let first = d(2026, 1, 5);
|
||||
let second = d(2026, 1, 6);
|
||||
let data = DataSet::from_components(
|
||||
Vec::new(),
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.2,
|
||||
low: 9.9,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 9.8,
|
||||
volume: 10_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 10.78,
|
||||
lower_limit: 8.82,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
high: 11.2,
|
||||
low: 10.4,
|
||||
close: 10.6,
|
||||
last_price: 10.6,
|
||||
bid1: 10.6,
|
||||
ask1: 10.6,
|
||||
prev_close: 10.0,
|
||||
volume: 10_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
BenchmarkSnapshot {
|
||||
date: first,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1000.0,
|
||||
prev_close: 990.0,
|
||||
volume: 1_000_000,
|
||||
},
|
||||
BenchmarkSnapshot {
|
||||
date: second,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1001.0,
|
||||
prev_close: 1000.0,
|
||||
volume: 1_000_000,
|
||||
},
|
||||
],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
benchmark_code: "000852.SH".to_string(),
|
||||
start_date: Some(first),
|
||||
end_date: Some(second),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Last,
|
||||
};
|
||||
let mut engine = BacktestEngine::new(data, DecisionQuoteReader::default(), broker, config)
|
||||
.with_execution_quote_loader(move |request| {
|
||||
assert_eq!(
|
||||
request.end_time, None,
|
||||
"decision quote preload must request latest quote at or before start_time"
|
||||
);
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(t(10, 39, 59)),
|
||||
last_price: if request.date == second { 11.0 } else { 10.0 },
|
||||
bid1: if request.date == second { 11.0 } else { 10.0 },
|
||||
ask1: if request.date == second { 11.0 } else { 10.0 },
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
volume_delta: 10_000,
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
})
|
||||
.collect())
|
||||
});
|
||||
|
||||
engine.run().expect("backtest should run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
let first = d(2026, 1, 5);
|
||||
let second = d(2026, 1, 6);
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
Vec::new(),
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.2,
|
||||
low: 9.9,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 9.8,
|
||||
volume: 10_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 10.78,
|
||||
lower_limit: 8.82,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
high: 11.2,
|
||||
low: 10.4,
|
||||
close: 10.6,
|
||||
last_price: 10.6,
|
||||
bid1: 10.6,
|
||||
ask1: 10.6,
|
||||
prev_close: 10.0,
|
||||
volume: 10_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
BenchmarkSnapshot {
|
||||
date: first,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1000.0,
|
||||
prev_close: 990.0,
|
||||
volume: 1_000_000,
|
||||
},
|
||||
BenchmarkSnapshot {
|
||||
date: second,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1001.0,
|
||||
prev_close: 1000.0,
|
||||
volume: 1_000_000,
|
||||
},
|
||||
],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: first.and_time(t(10, 39, 59)),
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
volume_delta: 10_000,
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: second.and_time(t(10, 39, 59)),
|
||||
last_price: 11.0,
|
||||
bid1: 11.0,
|
||||
ask1: 11.0,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
volume_delta: 10_000,
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
benchmark_code: "000852.SH".to_string(),
|
||||
start_date: Some(first),
|
||||
end_date: Some(second),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Last,
|
||||
};
|
||||
let loader_calls = Arc::new(Mutex::new(0usize));
|
||||
let captured_loader_calls = Arc::clone(&loader_calls);
|
||||
let mut engine = BacktestEngine::new(data, DecisionQuoteReader::default(), broker, config)
|
||||
.with_execution_quote_loader(move |_| {
|
||||
*captured_loader_calls.lock().expect("loader mutex") += 1;
|
||||
Ok(Vec::new())
|
||||
});
|
||||
|
||||
engine.run().expect("backtest should run");
|
||||
assert_eq!(
|
||||
*loader_calls.lock().expect("loader mutex"),
|
||||
0,
|
||||
"preloaded execution quotes should satisfy decision-time quote requests"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MultiTimeDecisionQuoteReader {
|
||||
day_count: usize,
|
||||
}
|
||||
|
||||
impl Strategy for MultiTimeDecisionQuoteReader {
|
||||
fn name(&self) -> &str {
|
||||
"multi_time_decision_quote_reader"
|
||||
}
|
||||
|
||||
fn decision_quote_times(&self) -> Vec<NaiveTime> {
|
||||
vec![t(10, 31, 0), t(10, 40, 0)]
|
||||
}
|
||||
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.day_count += 1;
|
||||
if self.day_count == 1 {
|
||||
return Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Value {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
value: 5_000.0,
|
||||
reason: "seed_position".to_string(),
|
||||
}],
|
||||
..StrategyDecision::default()
|
||||
});
|
||||
}
|
||||
|
||||
let quote_times = ctx
|
||||
.data
|
||||
.execution_quotes_on(ctx.execution_date, "000001.SZ")
|
||||
.iter()
|
||||
.map(|quote| quote.timestamp.time())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
quote_times.contains(&t(10, 30, 59)),
|
||||
"10:31 decision quote must be loaded"
|
||||
);
|
||||
assert!(
|
||||
quote_times.contains(&t(10, 39, 59)),
|
||||
"10:40 decision quote must not be skipped because 10:31 was loaded"
|
||||
);
|
||||
Ok(StrategyDecision::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
let first = d(2026, 1, 5);
|
||||
let second = d(2026, 1, 6);
|
||||
let data = DataSet::from_components(
|
||||
Vec::new(),
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.2,
|
||||
low: 9.9,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 9.8,
|
||||
volume: 10_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 10.78,
|
||||
lower_limit: 8.82,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
high: 11.2,
|
||||
low: 10.4,
|
||||
close: 10.6,
|
||||
last_price: 10.6,
|
||||
bid1: 10.6,
|
||||
ask1: 10.6,
|
||||
prev_close: 10.0,
|
||||
volume: 10_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
BenchmarkSnapshot {
|
||||
date: first,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1000.0,
|
||||
prev_close: 990.0,
|
||||
volume: 1_000_000,
|
||||
},
|
||||
BenchmarkSnapshot {
|
||||
date: second,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1001.0,
|
||||
prev_close: 1000.0,
|
||||
volume: 1_000_000,
|
||||
},
|
||||
],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
benchmark_code: "000852.SH".to_string(),
|
||||
start_date: Some(first),
|
||||
end_date: Some(second),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Last,
|
||||
};
|
||||
let requests = Arc::new(Mutex::new(Vec::<(NaiveDate, NaiveTime)>::new()));
|
||||
let captured_requests = Arc::clone(&requests);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
MultiTimeDecisionQuoteReader::default(),
|
||||
broker,
|
||||
config,
|
||||
)
|
||||
.with_execution_quote_loader(move |request| {
|
||||
let start_time = request
|
||||
.start_time
|
||||
.expect("decision quote loader request must include start_time");
|
||||
captured_requests
|
||||
.lock()
|
||||
.expect("request mutex")
|
||||
.push((request.date, start_time));
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(start_time) - Duration::seconds(1),
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
volume_delta: 10_000,
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
})
|
||||
.collect())
|
||||
});
|
||||
|
||||
engine.run().expect("backtest should run");
|
||||
|
||||
let requests = requests.lock().expect("request mutex").clone();
|
||||
assert!(
|
||||
requests.contains(&(second, t(10, 31, 0))),
|
||||
"second-day 10:31 quote request is required"
|
||||
);
|
||||
assert!(
|
||||
requests.contains(&(second, t(10, 40, 0))),
|
||||
"second-day 10:40 quote request must not be skipped by earlier quote"
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ impl Strategy for BuyThenHoldStrategy {
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(StrategyDecision::default())
|
||||
@@ -43,7 +44,8 @@ impl Strategy for BuyThenHoldStrategy {
|
||||
#[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 delist_date = d(2025, 1, 3);
|
||||
let date2 = d(2025, 1, 6);
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
Instrument {
|
||||
@@ -52,8 +54,8 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: Some(date1),
|
||||
status: "delisted".to_string(),
|
||||
delisted_at: Some(delist_date),
|
||||
status: "active".to_string(),
|
||||
},
|
||||
Instrument {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
@@ -80,7 +82,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -103,7 +105,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
ask1: 5.01,
|
||||
prev_close: 5.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -113,9 +115,32 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 5.05,
|
||||
open: 5.05,
|
||||
high: 5.15,
|
||||
low: 5.0,
|
||||
close: 5.05,
|
||||
last_price: 5.05,
|
||||
bid1: 5.04,
|
||||
ask1: 5.06,
|
||||
prev_close: 5.0,
|
||||
volume: 110_000,
|
||||
minute_volume: 110_000,
|
||||
bid1_volume: 110_000,
|
||||
ask1_volume: 110_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-06 10:18:00".to_string()),
|
||||
day_open: 5.1,
|
||||
open: 5.1,
|
||||
high: 5.2,
|
||||
@@ -126,7 +151,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
ask1: 5.11,
|
||||
prev_close: 5.0,
|
||||
volume: 120_000,
|
||||
tick_volume: 120_000,
|
||||
minute_volume: 120_000,
|
||||
bid1_volume: 120_000,
|
||||
ask1_volume: 120_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -157,6 +182,16 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
market_cap_bn: 30.5,
|
||||
free_float_cap_bn: 28.5,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
@@ -173,34 +208,53 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
@@ -212,6 +266,14 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
},
|
||||
BenchmarkSnapshot {
|
||||
date: delist_date,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 100.5,
|
||||
close: 100.5,
|
||||
prev_close: 100.0,
|
||||
volume: 1_050_000,
|
||||
},
|
||||
BenchmarkSnapshot {
|
||||
date: date2,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
@@ -273,7 +335,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: Some(date2),
|
||||
status: "delisted".to_string(),
|
||||
status: "active".to_string(),
|
||||
},
|
||||
Instrument {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
@@ -300,7 +362,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -323,7 +385,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
ask1: 20.0,
|
||||
prev_close: 20.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -346,7 +408,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
ask1: 21.0,
|
||||
prev_close: 20.0,
|
||||
volume: 120_000,
|
||||
tick_volume: 120_000,
|
||||
minute_volume: 120_000,
|
||||
bid1_volume: 120_000,
|
||||
ask1_volume: 120_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -393,34 +455,40 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
@@ -492,7 +560,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
.iter()
|
||||
.find(|holding| holding.symbol == "000002.SZ")
|
||||
.expect("successor holding exists");
|
||||
assert_eq!(successor_holding.quantity, 500);
|
||||
assert_eq!(successor_holding.quantity, 450);
|
||||
assert!(
|
||||
result
|
||||
.holdings_summary
|
||||
@@ -503,6 +571,6 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
event
|
||||
.note
|
||||
.contains("successor_conversion 000001.SZ->000002.SZ")
|
||||
&& event.note.contains("cash=1000.00")
|
||||
&& event.note.contains("cash=900.00")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
|
||||
ask1: 10.0,
|
||||
prev_close: 9.9,
|
||||
volume: 1_000_000,
|
||||
tick_volume: 1_000_000,
|
||||
minute_volume: 1_000_000,
|
||||
bid1_volume: 1_000_000,
|
||||
ask1_volume: 1_000_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -81,12 +81,14 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
@@ -115,7 +117,7 @@ fn market_row(date: NaiveDate, symbol: &str, open: f64, close: f64) -> DailyMark
|
||||
ask1: close,
|
||||
prev_close: open,
|
||||
volume: 1_000_000,
|
||||
tick_volume: 1_000_000,
|
||||
minute_volume: 1_000_000,
|
||||
bid1_volume: 1_000_000,
|
||||
ask1_volume: 1_000_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -148,12 +150,14 @@ fn candidate_row(date: NaiveDate, symbol: &str) -> CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +296,7 @@ impl Strategy for HookProbeStrategy {
|
||||
order_intents: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -329,11 +334,12 @@ impl Strategy for AuctionOrderStrategy {
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: vec![fidc_core::OrderIntent::Value {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
value: 1_000.0,
|
||||
value: 1_010.0,
|
||||
reason: "auction_buy".to_string(),
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -382,6 +388,7 @@ impl Strategy for FuturesOrderStrategy {
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -625,7 +632,7 @@ struct UniverseDirectiveStrategy {
|
||||
snapshots: Rc<RefCell<Vec<String>>>,
|
||||
}
|
||||
|
||||
struct TickProbeStrategy {
|
||||
struct MinuteProbeStrategy {
|
||||
seen_ticks: Rc<RefCell<Vec<String>>>,
|
||||
ordered: bool,
|
||||
}
|
||||
@@ -714,6 +721,7 @@ impl Strategy for LimitCarryStrategy {
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -791,13 +799,14 @@ impl Strategy for UniverseDirectiveStrategy {
|
||||
order_intents,
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Strategy for TickProbeStrategy {
|
||||
impl Strategy for MinuteProbeStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"tick-probe"
|
||||
"minute-probe"
|
||||
}
|
||||
|
||||
fn on_day(
|
||||
@@ -810,26 +819,27 @@ impl Strategy for TickProbeStrategy {
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: vec![OrderIntent::Subscribe {
|
||||
symbols: BTreeSet::from(["000001.SZ".to_string()]),
|
||||
reason: "subscribe_tick_probe".to_string(),
|
||||
reason: "subscribe_minute_probe".to_string(),
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn on_tick(
|
||||
fn on_minute(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
quote: &IntradayExecutionQuote,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
let visible_last = ctx
|
||||
.history_bars("e.symbol, 9, "tick", "last", true)
|
||||
.history_bars("e.symbol, 9, "1m", "last", true)
|
||||
.iter()
|
||||
.map(|value| format!("{value:.2}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let previous_last = ctx
|
||||
.history_bars("e.symbol, 9, "tick", "last", false)
|
||||
.history_bars("e.symbol, 9, "1m", "last", false)
|
||||
.iter()
|
||||
.map(|value| format!("{value:.2}"))
|
||||
.collect::<Vec<_>>()
|
||||
@@ -851,10 +861,11 @@ impl Strategy for TickProbeStrategy {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: quote.symbol.clone(),
|
||||
quantity: 100,
|
||||
reason: "tick_buy".to_string(),
|
||||
reason: "minute_buy".to_string(),
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -881,13 +892,13 @@ impl Strategy for DataApiProbeStrategy {
|
||||
.map(|value| format!("{value:.2}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let tick_last = ctx
|
||||
let minute_last = ctx
|
||||
.history_bars("000001.SZ", 2, "1m", "last", true)
|
||||
.iter()
|
||||
.map(|value| format!("{value:.2}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let previous_tick_last = ctx
|
||||
let previous_minute_last = ctx
|
||||
.history_bars("000001.SZ", 2, "1m", "last", false)
|
||||
.iter()
|
||||
.map(|value| format!("{value:.2}"))
|
||||
@@ -917,14 +928,18 @@ impl Strategy for DataApiProbeStrategy {
|
||||
let daily_price_count = ctx
|
||||
.get_price("000001.SZ", d(2025, 1, 3), ctx.execution_date, "1d")
|
||||
.len();
|
||||
let tick_price_count = ctx
|
||||
.get_price("000001.SZ", d(2025, 1, 3), ctx.execution_date, "tick")
|
||||
.len();
|
||||
let minute_price_bars =
|
||||
ctx.get_price("000001.SZ", d(2025, 1, 3), ctx.execution_date, "1m");
|
||||
let minute_price_count = minute_price_bars.len();
|
||||
let minute_frequency = minute_price_bars
|
||||
.first()
|
||||
.map(|bar| bar.frequency.clone())
|
||||
.unwrap_or_default();
|
||||
let instrument_history_count =
|
||||
ctx.instruments_history(&["000001.SZ", "000002.SZ"]).len();
|
||||
let active_instrument_count = ctx.active_instruments(&["000001.SZ", "000002.SZ"]).len();
|
||||
self.snapshots.borrow_mut().push(format!(
|
||||
"daily={daily_close};previous={previous_close};tick={tick_last};previous_tick={previous_tick_last};current={current_close};instrument={instrument_name};all={};history={instrument_history_count};active={active_instrument_count};range={trading_date_count};prev={prev_date};next={next_date};suspended={suspended};st={st_flags};price_daily={daily_price_count};price_tick={tick_price_count}",
|
||||
"daily={daily_close};previous={previous_close};minute={minute_last};previous_minute={previous_minute_last};current={current_close};instrument={instrument_name};all={};history={instrument_history_count};active={active_instrument_count};range={trading_date_count};prev={prev_date};next={next_date};suspended={suspended};st={st_flags};price_daily={daily_price_count};price_tick={minute_price_count};minute_frequency={minute_frequency}",
|
||||
ctx.all_instruments().len()
|
||||
));
|
||||
}
|
||||
@@ -952,6 +967,7 @@ impl Strategy for OrderInspectionStrategy {
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1009,6 +1025,7 @@ impl Strategy for AccountFlowStrategy {
|
||||
],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1050,7 +1067,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -1073,7 +1090,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
ask1: 10.1,
|
||||
prev_close: 10.0,
|
||||
volume: 110_000,
|
||||
tick_volume: 110_000,
|
||||
minute_volume: 110_000,
|
||||
bid1_volume: 110_000,
|
||||
ask1_volume: 110_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -1110,23 +1127,27 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
@@ -1244,7 +1265,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("open_auction".to_string()),
|
||||
@@ -1267,12 +1288,14 @@ fn engine_executes_open_auction_decisions_before_on_day() {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
@@ -1342,7 +1365,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
|
||||
ask1: 10.0,
|
||||
prev_close: 9.9,
|
||||
volume: 1_000_000,
|
||||
tick_volume: 1_000_000,
|
||||
minute_volume: 1_000_000,
|
||||
bid1_volume: 1_000_000,
|
||||
ask1_volume: 1_000_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -1365,12 +1388,14 @@ fn engine_executes_futures_order_intents_against_future_account() {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
@@ -1832,7 +1857,7 @@ fn engine_sweeps_futures_order_book_depth_when_available() {
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Last,
|
||||
)
|
||||
.with_matching_type(MatchingType::CounterpartyOffer);
|
||||
.with_matching_type(MatchingType::MinuteBestCounterparty);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
FuturesDepthLimitOrderStrategy,
|
||||
@@ -1905,7 +1930,7 @@ fn strategy_context_exposes_advanced_data_helpers() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
|
||||
fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
let date = d(2025, 1, 2);
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
vec![Instrument {
|
||||
@@ -1931,7 +1956,7 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
|
||||
ask1: 10.2,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -1954,12 +1979,14 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
@@ -2002,7 +2029,7 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
|
||||
.expect("dataset");
|
||||
|
||||
let seen_ticks = Rc::new(RefCell::new(Vec::new()));
|
||||
let strategy = TickProbeStrategy {
|
||||
let strategy = MinuteProbeStrategy {
|
||||
seen_ticks: seen_ticks.clone(),
|
||||
ordered: false,
|
||||
};
|
||||
@@ -2035,25 +2062,25 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
|
||||
]
|
||||
);
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].reason, "tick_buy");
|
||||
assert_eq!(result.fills[0].reason, "minute_buy");
|
||||
assert_eq!(result.fills[0].quantity, 100);
|
||||
assert!(
|
||||
result
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.kind == ProcessEventKind::PreTick)
|
||||
.any(|event| event.kind == ProcessEventKind::PreMinute)
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.kind == ProcessEventKind::Tick)
|
||||
.any(|event| event.kind == ProcessEventKind::Minute)
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.kind == ProcessEventKind::PostTick)
|
||||
.any(|event| event.kind == ProcessEventKind::PostMinute)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2103,7 +2130,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
ask1: close + 0.01,
|
||||
prev_close,
|
||||
volume,
|
||||
tick_volume: volume,
|
||||
minute_volume: volume,
|
||||
bid1_volume: volume,
|
||||
ask1_volume: volume,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -2137,12 +2164,14 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let benchmarks = [
|
||||
@@ -2241,7 +2270,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
assert_eq!(
|
||||
snapshots.borrow().as_slice(),
|
||||
[
|
||||
"daily=10.10,10.20;previous=10.00,10.10;tick=10.15,10.25;previous_tick=10.15;current=10.20;instrument=Anchor;all=2;history=2;active=1;range=3;prev=2025-01-03;next=2025-01-06;suspended=0,1,0;st=0,1,0;price_daily=2;price_tick=3"
|
||||
"daily=10.10,10.20;previous=10.00,10.10;minute=10.15,10.25;previous_minute=10.15;current=10.20;instrument=Anchor;all=2;history=2;active=1;range=3;prev=2025-01-03;next=2025-01-06;suspended=0,1,0;st=0,1,0;price_daily=2;price_tick=3;minute_frequency=1m"
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -2273,7 +2302,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
|
||||
ask1: 10.2,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -2296,12 +2325,14 @@ fn strategy_context_exposes_final_order_runtime_view() {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
@@ -2489,7 +2520,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
||||
ask1: 10.01,
|
||||
prev_close: 9.9,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -2512,7 +2543,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
||||
ask1: 10.01,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -2549,23 +2580,27 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
@@ -2671,7 +2706,7 @@ fn engine_rejects_pending_limit_orders_at_market_close() {
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -2694,7 +2729,7 @@ fn engine_rejects_pending_limit_orders_at_market_close() {
|
||||
ask1: 9.7,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -2731,23 +2766,27 @@ fn engine_rejects_pending_limit_orders_at_market_close() {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
@@ -2839,7 +2878,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
ask1: 10.0,
|
||||
prev_close: 9.9,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("open_auction".to_string()),
|
||||
@@ -2862,7 +2901,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
ask1: 10.1,
|
||||
prev_close: 10.0,
|
||||
volume: 110_000,
|
||||
tick_volume: 110_000,
|
||||
minute_volume: 110_000,
|
||||
bid1_volume: 110_000,
|
||||
ask1_volume: 110_000,
|
||||
trading_phase: Some("open_auction".to_string()),
|
||||
@@ -2885,7 +2924,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
ask1: 10.2,
|
||||
prev_close: 10.1,
|
||||
volume: 120_000,
|
||||
tick_volume: 120_000,
|
||||
minute_volume: 120_000,
|
||||
bid1_volume: 120_000,
|
||||
ask1_volume: 120_000,
|
||||
trading_phase: Some("open_auction".to_string()),
|
||||
@@ -2932,34 +2971,40 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
@@ -3085,7 +3130,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
ask1: 10.0,
|
||||
prev_close: 9.9,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("open_auction".to_string()),
|
||||
@@ -3108,7 +3153,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
ask1: 10.1,
|
||||
prev_close: 10.0,
|
||||
volume: 110_000,
|
||||
tick_volume: 110_000,
|
||||
minute_volume: 110_000,
|
||||
bid1_volume: 110_000,
|
||||
ask1_volume: 110_000,
|
||||
trading_phase: Some("open_auction".to_string()),
|
||||
@@ -3131,7 +3176,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
ask1: 10.2,
|
||||
prev_close: 10.1,
|
||||
volume: 120_000,
|
||||
tick_volume: 120_000,
|
||||
minute_volume: 120_000,
|
||||
bid1_volume: 120_000,
|
||||
ask1_volume: 120_000,
|
||||
trading_phase: Some("open_auction".to_string()),
|
||||
@@ -3178,34 +3223,40 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
@@ -3462,7 +3513,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||
ask1: 10.01,
|
||||
prev_close: 9.95,
|
||||
volume: 100_000,
|
||||
tick_volume: 5_000,
|
||||
minute_volume: 5_000,
|
||||
bid1_volume: 2_000,
|
||||
ask1_volume: 2_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -3485,7 +3536,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||
ask1: 20.01,
|
||||
prev_close: 19.95,
|
||||
volume: 100_000,
|
||||
tick_volume: 5_000,
|
||||
minute_volume: 5_000,
|
||||
bid1_volume: 2_000,
|
||||
ask1_volume: 2_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -3532,23 +3583,27 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: *date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
},
|
||||
]
|
||||
})
|
||||
@@ -3642,7 +3697,7 @@ fn engine_exposes_current_process_context_to_strategies() {
|
||||
ask1: 10.0,
|
||||
prev_close: 9.9,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -3665,12 +3720,14 @@ fn engine_exposes_current_process_context_to_strategies() {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
@@ -3734,11 +3791,12 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: vec![OrderIntent::Value {
|
||||
symbol: "601028.SH".to_string(),
|
||||
value: 1_000.0,
|
||||
value: 1_010.0,
|
||||
reason: "seed_position".to_string(),
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(StrategyDecision::default())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,93 +0,0 @@
|
||||
use fidc_core::DataSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let uniq = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock")
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("fidc-bt-partitioned-{uniq}"));
|
||||
fs::create_dir_all(&dir).expect("mkdir temp");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_load_partitioned_snapshot_dir() {
|
||||
let dir = temp_dir();
|
||||
fs::create_dir_all(dir.join("benchmark/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("candidates/2024/01")).unwrap();
|
||||
fs::create_dir_all(dir.join("corporate_actions/2024/01")).unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("instruments.csv"),
|
||||
"symbol,name,board,round_lot,listed_at,delisted_at,status\n000001.SZ,PingAn,SZ,100,2020-01-01,,active\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("benchmark/2024/01/2024-01-02.csv"),
|
||||
"date,benchmark,open,close,prev_close,volume\n2024-01-02,CSI300.DEMO,2990,3000,2980,100000000\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("market/2024/01/2024-01-02.csv"),
|
||||
"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();
|
||||
fs::write(
|
||||
dir.join("factors/2024/01/2024-01-02.csv"),
|
||||
"date,symbol,market_cap_bn,free_float_cap_bn,pe_ttm,turnover_ratio,effective_turnover_ratio\n2024-01-02,000001.SZ,40,35,12,3.2,2.1\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("candidates/2024/01/2024-01-02.csv"),
|
||||
"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();
|
||||
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");
|
||||
assert_eq!(data.benchmark_code(), "CSI300.DEMO");
|
||||
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);
|
||||
}
|
||||
@@ -1,15 +1,582 @@
|
||||
use chrono::NaiveDate;
|
||||
use fidc_core::{
|
||||
CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, OmniMicroCapConfig,
|
||||
BenchmarkSnapshot, CandidateEligibility, CnSmallCapRotationConfig, CnSmallCapRotationStrategy,
|
||||
DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, OmniMicroCapConfig,
|
||||
OmniMicroCapStrategy, PortfolioState, Strategy, StrategyContext,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn d(value: &str) -> NaiveDate {
|
||||
NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
|
||||
fn instrument(symbol: &str, name: &str) -> Instrument {
|
||||
Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: name.to_string(),
|
||||
board: "Main".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn market(
|
||||
date: &str,
|
||||
symbol: &str,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
prev_close: f64,
|
||||
volume: u64,
|
||||
paused: bool,
|
||||
) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: d(date),
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: None,
|
||||
day_open: open,
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
last_price: close,
|
||||
bid1: close,
|
||||
ask1: close,
|
||||
prev_close,
|
||||
volume,
|
||||
minute_volume: 0,
|
||||
bid1_volume: 0,
|
||||
ask1_volume: 0,
|
||||
trading_phase: None,
|
||||
paused,
|
||||
upper_limit: (prev_close * 1.10 * 100.0).round() / 100.0,
|
||||
lower_limit: (prev_close * 0.90 * 100.0).round() / 100.0,
|
||||
price_tick: 0.01,
|
||||
}
|
||||
}
|
||||
|
||||
fn factor(
|
||||
date: &str,
|
||||
symbol: &str,
|
||||
market_cap_bn: f64,
|
||||
free_float_cap_bn: f64,
|
||||
) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date: d(date),
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
pe_ttm: 18.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
extra_factors: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(
|
||||
date: &str,
|
||||
symbol: &str,
|
||||
is_new_listing: bool,
|
||||
is_paused: bool,
|
||||
allow_buy: bool,
|
||||
allow_sell: bool,
|
||||
) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date: d(date),
|
||||
symbol: symbol.to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing,
|
||||
is_paused,
|
||||
allow_buy,
|
||||
allow_sell,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn benchmark(date: &str, open: f64, close: f64, prev_close: f64, volume: u64) -> BenchmarkSnapshot {
|
||||
BenchmarkSnapshot {
|
||||
date: d(date),
|
||||
benchmark: "CSI300.DEMO".to_string(),
|
||||
open,
|
||||
close,
|
||||
prev_close,
|
||||
volume,
|
||||
}
|
||||
}
|
||||
|
||||
fn strategy_test_dataset() -> DataSet {
|
||||
let dates = [
|
||||
"2024-01-02",
|
||||
"2024-01-03",
|
||||
"2024-01-04",
|
||||
"2024-01-05",
|
||||
"2024-01-08",
|
||||
"2024-01-09",
|
||||
"2024-01-10",
|
||||
"2024-01-11",
|
||||
"2024-01-12",
|
||||
];
|
||||
let instruments = vec![
|
||||
instrument("000001.SZ", "Alpha Components"),
|
||||
instrument("000002.SZ", "Beta Precision"),
|
||||
instrument("000003.SZ", "Charlie Materials"),
|
||||
instrument("600001.SH", "Delta Industrials"),
|
||||
];
|
||||
let market = vec![
|
||||
market(
|
||||
"2024-01-02",
|
||||
"000001.SZ",
|
||||
10.0,
|
||||
10.2,
|
||||
9.9,
|
||||
10.1,
|
||||
9.8,
|
||||
1_200_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-02",
|
||||
"000002.SZ",
|
||||
11.0,
|
||||
11.3,
|
||||
10.9,
|
||||
11.2,
|
||||
10.8,
|
||||
1_100_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-02",
|
||||
"000003.SZ",
|
||||
8.0,
|
||||
8.1,
|
||||
7.8,
|
||||
7.9,
|
||||
8.0,
|
||||
900_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-02",
|
||||
"600001.SH",
|
||||
15.0,
|
||||
15.2,
|
||||
14.9,
|
||||
15.1,
|
||||
15.0,
|
||||
800_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-03",
|
||||
"000001.SZ",
|
||||
10.2,
|
||||
10.5,
|
||||
10.1,
|
||||
10.4,
|
||||
10.1,
|
||||
1_250_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-03",
|
||||
"000002.SZ",
|
||||
11.2,
|
||||
11.6,
|
||||
11.1,
|
||||
11.5,
|
||||
11.2,
|
||||
1_120_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-03",
|
||||
"000003.SZ",
|
||||
7.8,
|
||||
7.9,
|
||||
7.3,
|
||||
7.4,
|
||||
7.9,
|
||||
930_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-03",
|
||||
"600001.SH",
|
||||
15.1,
|
||||
15.3,
|
||||
15.0,
|
||||
15.2,
|
||||
15.1,
|
||||
820_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-04",
|
||||
"000001.SZ",
|
||||
10.5,
|
||||
10.8,
|
||||
10.4,
|
||||
10.7,
|
||||
10.4,
|
||||
1_280_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-04",
|
||||
"000002.SZ",
|
||||
11.4,
|
||||
11.9,
|
||||
11.3,
|
||||
11.8,
|
||||
11.5,
|
||||
1_150_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-04",
|
||||
"000003.SZ",
|
||||
7.3,
|
||||
7.4,
|
||||
7.0,
|
||||
7.1,
|
||||
7.4,
|
||||
940_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-04",
|
||||
"600001.SH",
|
||||
15.2,
|
||||
15.5,
|
||||
15.1,
|
||||
15.4,
|
||||
15.2,
|
||||
830_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-05",
|
||||
"000001.SZ",
|
||||
10.8,
|
||||
11.1,
|
||||
10.7,
|
||||
11.0,
|
||||
10.7,
|
||||
1_300_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-05",
|
||||
"000002.SZ",
|
||||
11.9,
|
||||
12.1,
|
||||
11.8,
|
||||
12.0,
|
||||
11.8,
|
||||
1_180_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-05",
|
||||
"000003.SZ",
|
||||
7.0,
|
||||
7.1,
|
||||
6.8,
|
||||
6.9,
|
||||
7.1,
|
||||
950_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-05",
|
||||
"600001.SH",
|
||||
15.4,
|
||||
15.6,
|
||||
15.3,
|
||||
15.5,
|
||||
15.4,
|
||||
840_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-08",
|
||||
"000001.SZ",
|
||||
11.1,
|
||||
11.6,
|
||||
11.0,
|
||||
11.5,
|
||||
11.0,
|
||||
1_400_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-08",
|
||||
"000002.SZ",
|
||||
12.1,
|
||||
12.5,
|
||||
12.0,
|
||||
12.4,
|
||||
12.0,
|
||||
1_200_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-08",
|
||||
"000003.SZ",
|
||||
7.0,
|
||||
7.3,
|
||||
6.9,
|
||||
7.2,
|
||||
6.9,
|
||||
980_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-08",
|
||||
"600001.SH",
|
||||
15.5,
|
||||
15.7,
|
||||
15.4,
|
||||
15.6,
|
||||
15.5,
|
||||
850_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-09",
|
||||
"000001.SZ",
|
||||
11.6,
|
||||
12.4,
|
||||
11.5,
|
||||
12.3,
|
||||
11.5,
|
||||
1_500_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-09",
|
||||
"000002.SZ",
|
||||
12.5,
|
||||
12.9,
|
||||
12.4,
|
||||
12.8,
|
||||
12.4,
|
||||
1_250_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-09",
|
||||
"000003.SZ",
|
||||
7.2,
|
||||
7.5,
|
||||
7.1,
|
||||
7.4,
|
||||
7.2,
|
||||
990_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-09",
|
||||
"600001.SH",
|
||||
15.6,
|
||||
15.7,
|
||||
15.4,
|
||||
15.5,
|
||||
15.6,
|
||||
860_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-10",
|
||||
"000001.SZ",
|
||||
12.2,
|
||||
12.3,
|
||||
11.9,
|
||||
12.0,
|
||||
12.3,
|
||||
1_450_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-10",
|
||||
"000002.SZ",
|
||||
12.7,
|
||||
12.8,
|
||||
12.5,
|
||||
12.6,
|
||||
12.8,
|
||||
1_220_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-10",
|
||||
"000003.SZ",
|
||||
7.5,
|
||||
7.6,
|
||||
7.4,
|
||||
7.5,
|
||||
7.4,
|
||||
1_000_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-10",
|
||||
"600001.SH",
|
||||
15.4,
|
||||
15.5,
|
||||
15.1,
|
||||
15.2,
|
||||
15.5,
|
||||
870_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-11",
|
||||
"000001.SZ",
|
||||
12.0,
|
||||
12.1,
|
||||
11.5,
|
||||
11.6,
|
||||
12.0,
|
||||
1_420_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-11",
|
||||
"000002.SZ",
|
||||
12.5,
|
||||
12.6,
|
||||
12.1,
|
||||
12.2,
|
||||
12.6,
|
||||
1_210_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-11",
|
||||
"000003.SZ",
|
||||
7.4,
|
||||
7.5,
|
||||
7.2,
|
||||
7.3,
|
||||
7.5,
|
||||
980_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-11",
|
||||
"600001.SH",
|
||||
15.2,
|
||||
15.2,
|
||||
15.2,
|
||||
15.2,
|
||||
15.2,
|
||||
0,
|
||||
true,
|
||||
),
|
||||
market(
|
||||
"2024-01-12",
|
||||
"000001.SZ",
|
||||
11.5,
|
||||
11.6,
|
||||
11.1,
|
||||
11.2,
|
||||
11.6,
|
||||
1_380_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-12",
|
||||
"000002.SZ",
|
||||
12.1,
|
||||
12.2,
|
||||
11.8,
|
||||
11.9,
|
||||
12.2,
|
||||
1_190_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-12",
|
||||
"000003.SZ",
|
||||
7.2,
|
||||
7.2,
|
||||
6.9,
|
||||
7.0,
|
||||
7.3,
|
||||
960_000,
|
||||
false,
|
||||
),
|
||||
market(
|
||||
"2024-01-12",
|
||||
"600001.SH",
|
||||
14.8,
|
||||
15.0,
|
||||
14.7,
|
||||
14.9,
|
||||
15.2,
|
||||
850_000,
|
||||
false,
|
||||
),
|
||||
];
|
||||
let factors = dates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(idx, date)| {
|
||||
let i = idx as f64;
|
||||
[
|
||||
factor(date, "000001.SZ", 38.0 + i, 24.0 + i * 0.5),
|
||||
factor(date, "000002.SZ", 45.0 + i, 30.0 + i * 0.5),
|
||||
factor(date, "000003.SZ", 65.0 - i, 40.0 - i * 0.5),
|
||||
factor(date, "600001.SH", 85.0 + i, 55.0 + i * 0.5),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let candidates = dates
|
||||
.iter()
|
||||
.flat_map(|date| {
|
||||
let first_two = *date == "2024-01-02" || *date == "2024-01-03";
|
||||
let paused_600001 = *date == "2024-01-11";
|
||||
[
|
||||
candidate(date, "000001.SZ", first_two, false, !first_two, true),
|
||||
candidate(date, "000002.SZ", false, false, true, true),
|
||||
candidate(date, "000003.SZ", false, false, true, true),
|
||||
candidate(
|
||||
date,
|
||||
"600001.SH",
|
||||
false,
|
||||
paused_600001,
|
||||
!paused_600001,
|
||||
!paused_600001,
|
||||
),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let benchmarks = vec![
|
||||
benchmark("2024-01-02", 2990.0, 3000.0, 2980.0, 100_000_000),
|
||||
benchmark("2024-01-03", 3005.0, 3020.0, 3000.0, 102_000_000),
|
||||
benchmark("2024-01-04", 3025.0, 3050.0, 3020.0, 105_000_000),
|
||||
benchmark("2024-01-05", 3055.0, 3080.0, 3050.0, 108_000_000),
|
||||
benchmark("2024-01-08", 3085.0, 3110.0, 3080.0, 109_000_000),
|
||||
benchmark("2024-01-09", 3100.0, 3090.0, 3110.0, 107_000_000),
|
||||
benchmark("2024-01-10", 3080.0, 3040.0, 3090.0, 111_000_000),
|
||||
benchmark("2024-01-11", 3030.0, 2990.0, 3040.0, 115_000_000),
|
||||
benchmark("2024-01-12", 2980.0, 2950.0, 2990.0, 118_000_000),
|
||||
];
|
||||
DataSet::from_components(instruments, market, factors, candidates, benchmarks)
|
||||
.expect("strategy test dataset")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_emits_target_weights_and_diagnostics() {
|
||||
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data/demo");
|
||||
let data = DataSet::from_csv_dir(&data_dir).expect("demo data");
|
||||
let data = strategy_test_dataset();
|
||||
let decision_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
@@ -53,8 +620,7 @@ fn strategy_emits_target_weights_and_diagnostics() {
|
||||
|
||||
#[test]
|
||||
fn omni_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 data = strategy_test_dataset();
|
||||
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
let mut cfg = OmniMicroCapConfig::omni_microcap();
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
let refresh_rate = 15;
|
||||
let stocknum = 40;
|
||||
let close_rate = 1.07;
|
||||
let loss_rate = 0.93;
|
||||
let rsi_rate = 1.0001;
|
||||
let trade_rate = 0.5;
|
||||
let xs = 4 / 500;
|
||||
let base_index_level = 2000;
|
||||
let base_cap_floor = 3;
|
||||
let base_cap_ceiling = 28;
|
||||
|
||||
fn band_start(current_price, base_index_level, xs, base_cap_floor) {
|
||||
if current_price == base_index_level {
|
||||
base_cap_floor
|
||||
} else if current_price > 0 {
|
||||
round((current_price - base_index_level) * xs + base_cap_floor)
|
||||
} else {
|
||||
base_cap_floor
|
||||
}
|
||||
}
|
||||
|
||||
fn band_end(current_price, base_index_level, xs, base_cap_ceiling) {
|
||||
if current_price == base_index_level {
|
||||
base_cap_ceiling
|
||||
} else if current_price > 0 {
|
||||
round((current_price - base_index_level) * xs + base_cap_ceiling)
|
||||
} else {
|
||||
base_cap_ceiling
|
||||
}
|
||||
}
|
||||
|
||||
strategy("microcap_volume_trend_000852") {
|
||||
market("CN_A")
|
||||
benchmark("000852.SH")
|
||||
signal("000852.SH")
|
||||
|
||||
rebalance.every_days(refresh_rate).at("10:18")
|
||||
|
||||
universe.exclude("paused", "st", "kcb", "one_yuan", "new_listing")
|
||||
|
||||
selection.limit(stocknum)
|
||||
selection.market_cap_band(
|
||||
field="market_cap",
|
||||
lower=band_start(signal_close, base_index_level, xs, base_cap_floor),
|
||||
upper=band_end(signal_close, base_index_level, xs, base_cap_ceiling)
|
||||
)
|
||||
|
||||
risk.index_exposure(
|
||||
signal_ma5 > signal_ma10 * rsi_rate ? 1.0 : trade_rate
|
||||
)
|
||||
|
||||
filter.stock_expr(
|
||||
stock_ma5 > stock_ma10 * rsi_rate &&
|
||||
stock_ma10 > stock_ma30 * rsi_rate &&
|
||||
rolling_mean("volume", 5) < rolling_mean("volume", 60)
|
||||
)
|
||||
|
||||
risk.take_profit(close_rate)
|
||||
risk.stop_loss(loss_rate)
|
||||
allocation.buy_scale(touched_upper_limit ? 1.0 : trade_rate)
|
||||
|
||||
ordering.rank_by("market_cap", "asc")
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"strategyId": "microcap_volume_trend_000852",
|
||||
"version": "2",
|
||||
"parser": "omniquant-engine-script-v2",
|
||||
"market": "CN_A",
|
||||
"signalSymbol": "000852.SH",
|
||||
"benchmark": {
|
||||
"instrumentId": "000852.SH",
|
||||
"fallbackInstrumentId": "000852.SH"
|
||||
},
|
||||
"engineConfig": {
|
||||
"market": "CN_A",
|
||||
"signalSymbol": "000852.SH",
|
||||
"benchmarkSymbol": "000852.SH",
|
||||
"refreshRate": 15,
|
||||
"rankLimit": 40
|
||||
},
|
||||
"runtimeExpressions": {
|
||||
"prelude": "let refresh_rate = 15;\nlet stocknum = 40;\nlet close_rate = 1.07;\nlet loss_rate = 0.93;\nlet rsi_rate = 1.0001;\nlet trade_rate = 0.5;\nlet xs = 4 / 500;\nlet base_index_level = 2000;\nlet base_cap_floor = 3;\nlet base_cap_ceiling = 28;\nfn band_start(current_price, base_index_level, xs, base_cap_floor) {\n if current_price == base_index_level {\n base_cap_floor\n } else if current_price > 0 {\n round((current_price - base_index_level) * xs + base_cap_floor)\n } else {\n base_cap_floor\n }\n}\nfn band_end(current_price, base_index_level, xs, base_cap_ceiling) {\n if current_price == base_index_level {\n base_cap_ceiling\n } else if current_price > 0 {\n round((current_price - base_index_level) * xs + base_cap_ceiling)\n } else {\n base_cap_ceiling\n }\n}",
|
||||
"selection": {
|
||||
"limitExpr": "stocknum",
|
||||
"marketCapField": "market_cap",
|
||||
"marketCapLowerExpr": "band_start(signal_close, base_index_level, xs, base_cap_floor)",
|
||||
"marketCapUpperExpr": "band_end(signal_close, base_index_level, xs, base_cap_ceiling)",
|
||||
"stockFilterExpr": "stock_ma5 > stock_ma10 * rsi_rate && stock_ma10 > stock_ma30 * rsi_rate && rolling_mean(\"volume\", 5) < rolling_mean(\"volume\", 60)"
|
||||
},
|
||||
"risk": {
|
||||
"exposureExpr": "signal_ma5 > signal_ma10 * rsi_rate ? 1.0 : trade_rate",
|
||||
"stopLossExpr": "loss_rate",
|
||||
"takeProfitExpr": "close_rate"
|
||||
},
|
||||
"allocation": {
|
||||
"buyScaleExpr": "touched_upper_limit ? 1.0 : trade_rate"
|
||||
},
|
||||
"ordering": {
|
||||
"rankBy": "market_cap",
|
||||
"rankExpr": "",
|
||||
"rankOrder": "asc"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
let refresh_rate = 15;
|
||||
let stocknum = 40;
|
||||
let xs = 0.008;
|
||||
let base_index_level = 2000;
|
||||
let lower_offset = 3;
|
||||
let upper_offset = 28;
|
||||
|
||||
fn cap_floor(current_price, base_index_level, xs, lower_offset) {
|
||||
round((current_price - base_index_level) * xs + lower_offset)
|
||||
}
|
||||
|
||||
fn cap_ceiling(current_price, base_index_level, xs, upper_offset) {
|
||||
round((current_price - base_index_level) * xs + upper_offset)
|
||||
}
|
||||
|
||||
strategy("ai_generated_000001_open_cap_band") {
|
||||
market("CN_A")
|
||||
benchmark("000852.SH")
|
||||
signal("000001.SH")
|
||||
|
||||
rebalance.every_days(refresh_rate).at("10:18")
|
||||
|
||||
universe.exclude("paused", "st", "kcb", "one_yuan", "new_listing")
|
||||
|
||||
selection.limit(stocknum)
|
||||
selection.market_cap_band(
|
||||
field="market_cap",
|
||||
lower=cap_floor(signal_open, base_index_level, xs, lower_offset),
|
||||
upper=cap_ceiling(signal_open, base_index_level, xs, upper_offset)
|
||||
)
|
||||
|
||||
filter.stock_expr(
|
||||
stock_ma5 > stock_ma10 &&
|
||||
stock_ma10 > stock_ma30 &&
|
||||
rolling_mean("volume", 5) < rolling_mean("volume", 60) &&
|
||||
!ends_with(symbol, ".BJ") &&
|
||||
!at_upper_limit &&
|
||||
!at_lower_limit
|
||||
)
|
||||
|
||||
ordering.rank_by("market_cap", "asc")
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"strategyId": "ai_generated_000001_open_cap_band",
|
||||
"version": "2",
|
||||
"parser": "omniquant-engine-script-v2",
|
||||
"market": "CN_A",
|
||||
"signalSymbol": "000001.SH",
|
||||
"benchmark": {
|
||||
"instrumentId": "000852.SH",
|
||||
"fallbackInstrumentId": "000852.SH"
|
||||
},
|
||||
"engineConfig": {
|
||||
"market": "CN_A",
|
||||
"signalSymbol": "000001.SH",
|
||||
"benchmarkSymbol": "000852.SH",
|
||||
"refreshRate": 15,
|
||||
"rankLimit": 40
|
||||
},
|
||||
"runtimeExpressions": {
|
||||
"prelude": "let refresh_rate = 15;\nlet stocknum = 40;\nlet xs = 0.008;\nlet base_index_level = 2000;\nlet lower_offset = 3;\nlet upper_offset = 28;\n\nfn cap_floor(current_price, base_index_level, xs, lower_offset) {\nround((current_price - base_index_level) * xs + lower_offset)\n}\n\nfn cap_ceiling(current_price, base_index_level, xs, upper_offset) {\nround((current_price - base_index_level) * xs + upper_offset)\n}",
|
||||
"selection": {
|
||||
"limitExpr": "stocknum",
|
||||
"marketCapField": "market_cap",
|
||||
"marketCapLowerExpr": "cap_floor(signal_open, base_index_level, xs, lower_offset)",
|
||||
"marketCapUpperExpr": "cap_ceiling(signal_open, base_index_level, xs, upper_offset)",
|
||||
"stockFilterExpr": "stock_ma5 > stock_ma10 && stock_ma10 > stock_ma30 && rolling_mean(\"volume\", 5) < rolling_mean(\"volume\", 60) && !ends_with(symbol, \".BJ\") && !at_upper_limit && !at_lower_limit"
|
||||
},
|
||||
"ordering": {
|
||||
"rankBy": "market_cap",
|
||||
"rankExpr": "",
|
||||
"rankOrder": "asc"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
date,benchmark,open,close,prev_close,volume
|
||||
2024-01-02,CSI300.DEMO,2990,3000,2980,100000000
|
||||
2024-01-03,CSI300.DEMO,3005,3020,3000,102000000
|
||||
2024-01-04,CSI300.DEMO,3025,3050,3020,105000000
|
||||
2024-01-05,CSI300.DEMO,3055,3080,3050,108000000
|
||||
2024-01-08,CSI300.DEMO,3085,3110,3080,109000000
|
||||
2024-01-09,CSI300.DEMO,3100,3090,3110,107000000
|
||||
2024-01-10,CSI300.DEMO,3080,3040,3090,111000000
|
||||
2024-01-11,CSI300.DEMO,3030,2990,3040,115000000
|
||||
2024-01-12,CSI300.DEMO,2980,2950,2990,118000000
|
||||
|
@@ -1,37 +0,0 @@
|
||||
date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan
|
||||
2024-01-02,000001.SZ,false,true,false,false,true,false,false
|
||||
2024-01-02,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-02,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-02,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-03,000001.SZ,false,true,false,false,true,false,false
|
||||
2024-01-03,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-03,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-03,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-04,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-04,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-04,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-04,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-05,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-05,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-05,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-05,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-08,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-08,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-08,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-08,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-09,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-09,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-09,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-09,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-10,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-10,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-10,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-10,600001.SH,false,false,false,true,true,false,false
|
||||
2024-01-11,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-11,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-11,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-11,600001.SH,false,false,true,false,false,false,false
|
||||
2024-01-12,000001.SZ,false,false,false,true,true,false,false
|
||||
2024-01-12,000002.SZ,false,false,false,true,true,false,false
|
||||
2024-01-12,000003.SZ,false,false,false,true,true,false,false
|
||||
2024-01-12,600001.SH,false,false,false,true,true,false,false
|
||||
|
@@ -1,37 +0,0 @@
|
||||
date,symbol,market_cap_bn,free_float_cap_bn,pe_ttm
|
||||
2024-01-02,000001.SZ,38,24,18
|
||||
2024-01-02,000002.SZ,45,30,20
|
||||
2024-01-02,000003.SZ,65,40,15
|
||||
2024-01-02,600001.SH,85,55,13
|
||||
2024-01-03,000001.SZ,39,24.5,18
|
||||
2024-01-03,000002.SZ,46,30.5,20
|
||||
2024-01-03,000003.SZ,64,39.5,15
|
||||
2024-01-03,600001.SH,85,55,13
|
||||
2024-01-04,000001.SZ,40,25,18
|
||||
2024-01-04,000002.SZ,47,31,20
|
||||
2024-01-04,000003.SZ,63,39,15
|
||||
2024-01-04,600001.SH,86,55.5,13
|
||||
2024-01-05,000001.SZ,41,25.5,18
|
||||
2024-01-05,000002.SZ,48,32,20
|
||||
2024-01-05,000003.SZ,62,38.5,15
|
||||
2024-01-05,600001.SH,86,56,13
|
||||
2024-01-08,000001.SZ,42,26,18
|
||||
2024-01-08,000002.SZ,50,33,21
|
||||
2024-01-08,000003.SZ,61,38,15
|
||||
2024-01-08,600001.SH,87,56.5,13
|
||||
2024-01-09,000001.SZ,44,27,19
|
||||
2024-01-09,000002.SZ,52,34,21
|
||||
2024-01-09,000003.SZ,60,37.5,15
|
||||
2024-01-09,600001.SH,88,57,13
|
||||
2024-01-10,000001.SZ,43,26.5,19
|
||||
2024-01-10,000002.SZ,53,34.5,21
|
||||
2024-01-10,000003.SZ,59,37,15
|
||||
2024-01-10,600001.SH,89,57.5,13
|
||||
2024-01-11,000001.SZ,42,26,18
|
||||
2024-01-11,000002.SZ,52,34,21
|
||||
2024-01-11,000003.SZ,58,36.5,15
|
||||
2024-01-11,600001.SH,90,58,13
|
||||
2024-01-12,000001.SZ,40,25,18
|
||||
2024-01-12,000002.SZ,50,33,20
|
||||
2024-01-12,000003.SZ,57,36,15
|
||||
2024-01-12,600001.SH,92,59,13
|
||||
|
@@ -1,5 +0,0 @@
|
||||
symbol,name,board
|
||||
000001.SZ,Alpha Components,Main
|
||||
000002.SZ,Beta Precision,Main
|
||||
000003.SZ,Charlie Materials,Main
|
||||
600001.SH,Delta Industrials,Main
|
||||
|
@@ -1,37 +0,0 @@
|
||||
date,symbol,open,high,low,close,prev_close,volume,paused
|
||||
2024-01-02,000001.SZ,10.0,10.2,9.9,10.1,9.8,1200000,false
|
||||
2024-01-02,000002.SZ,11.0,11.3,10.9,11.2,10.8,1100000,false
|
||||
2024-01-02,000003.SZ,8.0,8.1,7.8,7.9,8.0,900000,false
|
||||
2024-01-02,600001.SH,15.0,15.2,14.9,15.1,15.0,800000,false
|
||||
2024-01-03,000001.SZ,10.2,10.5,10.1,10.4,10.1,1250000,false
|
||||
2024-01-03,000002.SZ,11.2,11.6,11.1,11.5,11.2,1120000,false
|
||||
2024-01-03,000003.SZ,7.8,7.9,7.3,7.4,7.9,930000,false
|
||||
2024-01-03,600001.SH,15.1,15.3,15.0,15.2,15.1,820000,false
|
||||
2024-01-04,000001.SZ,10.5,10.8,10.4,10.7,10.4,1280000,false
|
||||
2024-01-04,000002.SZ,11.4,11.9,11.3,11.8,11.5,1150000,false
|
||||
2024-01-04,000003.SZ,7.3,7.4,7.0,7.1,7.4,940000,false
|
||||
2024-01-04,600001.SH,15.2,15.5,15.1,15.4,15.2,830000,false
|
||||
2024-01-05,000001.SZ,10.8,11.1,10.7,11.0,10.7,1300000,false
|
||||
2024-01-05,000002.SZ,11.9,12.1,11.8,12.0,11.8,1180000,false
|
||||
2024-01-05,000003.SZ,7.0,7.1,6.8,6.9,7.1,950000,false
|
||||
2024-01-05,600001.SH,15.4,15.6,15.3,15.5,15.4,840000,false
|
||||
2024-01-08,000001.SZ,11.1,11.6,11.0,11.5,11.0,1400000,false
|
||||
2024-01-08,000002.SZ,12.1,12.5,12.0,12.4,12.0,1200000,false
|
||||
2024-01-08,000003.SZ,7.0,7.3,6.9,7.2,6.9,980000,false
|
||||
2024-01-08,600001.SH,15.5,15.7,15.4,15.6,15.5,850000,false
|
||||
2024-01-09,000001.SZ,11.6,12.4,11.5,12.3,11.5,1500000,false
|
||||
2024-01-09,000002.SZ,12.5,12.9,12.4,12.8,12.4,1250000,false
|
||||
2024-01-09,000003.SZ,7.2,7.5,7.1,7.4,7.2,990000,false
|
||||
2024-01-09,600001.SH,15.6,15.7,15.4,15.5,15.6,860000,false
|
||||
2024-01-10,000001.SZ,12.2,12.3,11.9,12.0,12.3,1450000,false
|
||||
2024-01-10,000002.SZ,12.7,12.8,12.5,12.6,12.8,1220000,false
|
||||
2024-01-10,000003.SZ,7.5,7.6,7.4,7.5,7.4,1000000,false
|
||||
2024-01-10,600001.SH,15.4,15.5,15.1,15.2,15.5,870000,false
|
||||
2024-01-11,000001.SZ,12.0,12.1,11.5,11.6,12.0,1420000,false
|
||||
2024-01-11,000002.SZ,12.5,12.6,12.1,12.2,12.6,1210000,false
|
||||
2024-01-11,000003.SZ,7.4,7.5,7.2,7.3,7.5,980000,false
|
||||
2024-01-11,600001.SH,15.2,15.2,15.2,15.2,15.2,0,true
|
||||
2024-01-12,000001.SZ,11.5,11.6,11.1,11.2,11.6,1380000,false
|
||||
2024-01-12,000002.SZ,12.1,12.2,11.8,11.9,12.2,1190000,false
|
||||
2024-01-12,000003.SZ,7.2,7.2,6.9,7.0,7.3,960000,false
|
||||
2024-01-12,600001.SH,14.8,15.0,14.7,14.9,15.2,850000,false
|
||||
|
@@ -52,22 +52,22 @@ futures path. Confirmed aligned areas:
|
||||
- [x] Rich explicit order styles exposed to platform scripts.
|
||||
- [x] Minute-level `time_rule` semantics including market-open, market-close,
|
||||
and physical-time style schedules.
|
||||
- [x] Fine-grained daily, minute, and tick strategy execution entrypoints.
|
||||
- [x] Fine-grained daily and minute execution quote strategy entrypoints.
|
||||
- [x] Scheduled actions evaluated against explicit intraday times.
|
||||
- [x] `update_universe`, `subscribe`, and `unsubscribe`.
|
||||
- [x] Tick-frequency subscription guards at strategy API level.
|
||||
- [x] Intraday subscription guards at strategy API level; intraday execution uses minute quote semantics.
|
||||
- [x] VWAP and TWAP explicit action styles.
|
||||
- [x] `order_target_portfolio_smart(..., order_prices=AlgoOrder, valuation_prices=...)`.
|
||||
- [x] Trading PnL, position PnL, dividend receivable, and richer position
|
||||
lifecycle fields.
|
||||
- [x] Stock position aliases including `order_book_id`, `avg_price`,
|
||||
`sellable`, `closable`, `equity`, and `position_prev_close`.
|
||||
- [x] `history_bars` numeric helper for daily, intraday, and tick fields.
|
||||
- [x] `history_bars` numeric helper for daily and minute execution quote fields.
|
||||
- [x] `current_snapshot`, instrument metadata, all-instrument queries, and
|
||||
active/historical instrument helpers.
|
||||
- [x] Trading-date range, previous-date, and next-date helpers.
|
||||
- [x] Phase-aware minute/tick history cursor semantics matching the active bar
|
||||
or tick callback.
|
||||
- [x] Phase-aware minute history cursor semantics matching the active bar or
|
||||
intraday execution quote callback.
|
||||
- [x] Suspension, ST, date-range price, active instrument, and instrument
|
||||
history helpers.
|
||||
- [x] Open-order status, unfilled quantity, final order lookup, average fill
|
||||
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
fail() {
|
||||
local message="$1"
|
||||
local details="${2:-}"
|
||||
printf '[FAIL] %s\n' "$message" >&2
|
||||
if [[ -n "$details" ]]; then
|
||||
printf '%s\n' "$details" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
runtime_hits="$(
|
||||
rg -n \
|
||||
--glob '!**/.git/**' \
|
||||
--glob '!**/target/**' \
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'fidatacenter|FIDATACENTER|/v1/backtest/data|/v1/xuntou|ClickHouse|clickhouse|CLICKHOUSE|FIDC_BT_INSTRUMENT_METADATA_CSV|FIDC_BT_WRITE_SNAPSHOTS|FIDC_BT_WRITE_CSV_SNAPSHOTS|FIDC_BT_WRITE_COMBINED_SOURCE_ROW_CACHE|write_csv_snapshot_files|write_csv\(|FIDC_RISK_RUNTIME_FILE|FIDC_RISK_RUNTIME_URL|FIDC_FIRISK_RUNTIME_FILE|FiRisk runtime snapshot' \
|
||||
crates Cargo.toml \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
|
||||
if [[ -n "$runtime_hits" ]]; then
|
||||
fail "legacy fidatacenter/ClickHouse/CSV snapshot/FiRisk runtime data source references are not allowed in fidc-backtest-engine" "$runtime_hits"
|
||||
fi
|
||||
|
||||
manifest_hits="$(
|
||||
rg -n --glob '!scripts/verify-no-legacy-data-source.sh' '\b(mysql|mariadb|clickhouse|clickhouse-rs|mysql_async|sqlx-mysql)\b' Cargo.toml crates 2>/dev/null || true
|
||||
)"
|
||||
|
||||
if [[ -n "$manifest_hits" ]]; then
|
||||
fail "legacy database dependencies are not allowed in fidc-backtest-engine manifests" "$manifest_hits"
|
||||
fi
|
||||
|
||||
local_only_hits="$(
|
||||
rg -n \
|
||||
--glob '!**/.git/**' \
|
||||
--glob '!**/target/**' \
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'FICLAW_DATA_AGENT_URL|ficlaw_data\.source_rows_v1|strategy-factory-source-lake://local|FIDC_BT_WRITE_COMBINED_SOURCE_ROW_CACHE' \
|
||||
crates Cargo.toml \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
|
||||
if [[ -n "$local_only_hits" ]]; then
|
||||
fail "legacy or local-only data source references are not allowed in fidc-backtest-engine runtime" "$local_only_hits"
|
||||
fi
|
||||
|
||||
json_query_hits="$(
|
||||
rg -n \
|
||||
--glob '!**/.git/**' \
|
||||
--glob '!**/target/**' \
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'/v1/query/(source-rows|daily-execution-prices|minute-execution-prices|instruments|corporate-actions)\.json' \
|
||||
crates Cargo.toml \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
|
||||
if [[ -n "$json_query_hits" ]]; then
|
||||
fail "JSON Source Lake high-throughput endpoints are not allowed in fidc-backtest-engine runtime; use Arrow endpoints" "$json_query_hits"
|
||||
fi
|
||||
|
||||
fused_hits="$(
|
||||
rg -n \
|
||||
--glob '!**/.git/**' \
|
||||
--glob '!**/target/**' \
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'exported_fused|fidc_fused|fusion|fused|wide_table|wide table|source_rows_export|exported_daily|merged_daily|daily_merged|merged_source|materialized[_ -]source|materialized_source_rows|source_rows_materialized|融合表|融合宽表' \
|
||||
crates Cargo.toml \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
|
||||
if [[ -n "$fused_hits" ]]; then
|
||||
fail "exported fused tables are not allowed in fidc-backtest-engine runtime; use Strategy Factory Source Lake source rows directly" "$fused_hits"
|
||||
fi
|
||||
|
||||
feature_store_hits="$(
|
||||
rg -n \
|
||||
--glob '!**/.git/**' \
|
||||
--glob '!**/target/**' \
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'research_feature_store|feature_store|FEATURE_STORE|daily_minute_current|FIDC_STRATEGY_FACTORY_ENABLE_FEATURE_STORE_CACHE|ALPHA_FACTORY_ENABLE_FEATURE_STORE_CACHE|ENABLE_FEATURE_STORE_CACHE' \
|
||||
crates Cargo.toml \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
|
||||
if [[ -n "$feature_store_hits" ]]; then
|
||||
fail "historical feature-store paths are not allowed in fidc-backtest-engine runtime; use Strategy Factory Source Lake raw/indicator/artifact partitions and discardable caches" "$feature_store_hits"
|
||||
fi
|
||||
|
||||
truth_csv_hits="$(
|
||||
rg -n \
|
||||
--glob '!**/.git/**' \
|
||||
--glob '!**/target/**' \
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'FIDC_BT_TRUTH_STOCK_LIST_CSV|OMNI_BT_TRUTH_STOCK_LIST_CSV|OMNI_BACKTEST_TRUTH_STOCK_LIST_CSV|selection_source=truth_csv|truth_stock_list|truth_csv' \
|
||||
crates Cargo.toml \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
|
||||
if [[ -n "$truth_csv_hits" ]]; then
|
||||
fail "CSV truth stock-list overrides are not allowed in fidc-backtest-engine runtime; use Source Lake runtime spec selection only" "$truth_csv_hits"
|
||||
fi
|
||||
|
||||
csv_snapshot_loader_hits="$(
|
||||
rg -n \
|
||||
--glob '!**/.git/**' \
|
||||
--glob '!**/target/**' \
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'from_csv_dir|from_partitioned_dir|instruments\.csv|candidate_flags\.csv|market\.csv|benchmark\.csv' \
|
||||
crates Cargo.toml \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
|
||||
if [[ -n "$csv_snapshot_loader_hits" ]]; then
|
||||
fail "CSV snapshot loaders are not allowed in fidc-backtest-engine runtime; construct DataSet from Source Lake components" "$csv_snapshot_loader_hits"
|
||||
fi
|
||||
|
||||
printf '[OK] fidc-backtest-engine has no legacy runtime data-source references\n'
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
|
||||
fail() {
|
||||
local message="$1"
|
||||
local details="${2:-}"
|
||||
printf '[FAIL] %s\n' "$message" >&2
|
||||
if [[ -n "$details" ]]; then
|
||||
printf '%s\n' "$details" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_core_test() {
|
||||
local filter="$1"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
printf '[INFO] cargo test -p fidc-core %s\n' "$filter"
|
||||
if cargo test -p fidc-core "$filter" -- --nocapture 2>&1 | tee "$tmp"; then
|
||||
local passed_count
|
||||
passed_count="$(
|
||||
"$PYTHON_BIN" - "$tmp" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
count = 0
|
||||
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
|
||||
match = re.search(r"test result: ok\. (\d+) passed;", line)
|
||||
if match:
|
||||
count += int(match.group(1))
|
||||
print(count)
|
||||
PY
|
||||
)"
|
||||
rm -f "$tmp"
|
||||
if [[ "$passed_count" -le 0 ]]; then
|
||||
fail "cargo test filter matched 0 tests: package=fidc-core filter=${filter}"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
local output
|
||||
output="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
fail "cargo test failed: package=fidc-core filter=${filter}" "$output"
|
||||
}
|
||||
|
||||
run_core_test eligible_universe_does_not_require_candidate_risk_state_when_selection_risk_is_disabled
|
||||
run_core_test next_bar_open_eligible_universe_helper_does_not_block_on_decision_day_risk
|
||||
run_core_test platform_next_open_selection_records_risk_diagnostics_without_filtering
|
||||
run_core_test next_open_buy_risk_uses_execution_date_not_signal_date
|
||||
run_core_test next_open_buy_limit_risk_uses_open_not_close
|
||||
run_core_test next_open_sell_risk_uses_execution_date_not_signal_date
|
||||
run_core_test next_open_sell_limit_risk_uses_open_not_close
|
||||
run_core_test next_bar_open_sell_respects_allow_sell_policy_on_execution_day
|
||||
run_core_test volume_limit_uses_floor_for_odd_lot_sell
|
||||
run_core_test configurable_upper_limit_buy_filter_can_be_disabled
|
||||
|
||||
printf '[OK] fidc-backtest-engine runtime risk contracts passed\n'
|
||||
Reference in New Issue
Block a user