Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8cbc5dc96 | |||
| 6fba34d2e4 | |||
| 375b8b2df1 | |||
| b8776d7169 | |||
| 7a1631efa3 | |||
| fe7e0f397f | |||
| 839ca1fa0d | |||
| d51d324977 | |||
| 6f2c39aaf2 | |||
| 2a4a9d1290 | |||
| 29fcd67bf8 | |||
| faa8ac7c13 | |||
| 40e4c12cdd | |||
| 53c68250e4 | |||
| 2749983267 | |||
| 2c93f4a1ed | |||
| 51acdf1d31 | |||
| d21680ed4f | |||
| ca9732ecb2 | |||
| 4132793219 | |||
| d9ce3eeb5c | |||
| 24528ecfeb | |||
| a77a00c70a | |||
| 117f7be9c8 | |||
| bcb45077fb | |||
| 6c39acd54e | |||
| 518aadb9fd | |||
| 0dca331950 | |||
| 4c0fde7621 | |||
| 755fffda0f | |||
| af4cd25f47 | |||
| 59a500b879 | |||
| 71b5acee30 | |||
| 031e4ee054 | |||
| b6df63c79e | |||
| 6e8eeb984f | |||
| ffc9179cff | |||
| ef491340f6 | |||
| 7f65fda790 | |||
| 5918a03456 | |||
| 0337cc8a22 | |||
| 4d7245d8b0 | |||
| 81ac623fca | |||
| bdd5a41106 | |||
| 60457389a3 | |||
| 8f098e4da1 | |||
| a734cbeaec | |||
| 8f167e7de1 | |||
| 2c1a9be38e | |||
| 01b2ca02ff | |||
| 9d47d06064 | |||
| e7d1c875fd | |||
| d63ac73903 | |||
| 26315e2016 | |||
| 9f85625b83 | |||
| e17c5ad3b0 | |||
| 5f5f0fcf16 | |||
| bacb70e327 | |||
| 0ea5fae69d | |||
| 214872dfbf | |||
| 2b64fb7c7e | |||
| 438757ab54 | |||
| 67f15f12ca | |||
| 992d0e063c | |||
| 428434d98d | |||
| 20b07ddd7d | |||
| c094e78bef | |||
| 57345e8230 | |||
| d5d67102ac | |||
| 30a4071ee0 | |||
| 942ba84ca5 | |||
| ab3c821e59 | |||
| 1953e92b7b | |||
| 9cc625409f | |||
| 558d92fe23 | |||
| 0aef8f9491 | |||
| e275f4632d | |||
| 5166916926 | |||
| 56859dbe32 | |||
| 1272e427a1 | |||
| e396c895dc | |||
| f7d0889bbc | |||
| 9b84f3a1b9 | |||
| b1520fcca0 | |||
| bb51d91b76 | |||
| 2c43feec3e | |||
| 825de1d886 | |||
| 7397a2d69f | |||
| 7951ba67e3 | |||
| 2fcacb4313 | |||
| 5e480cd69b | |||
| bfbbac8952 | |||
| bb04864436 | |||
| b87e1b4a02 | |||
| 185ed49fe2 | |||
| a30face86a | |||
| 188376b75a | |||
| 6a98d9b0bd | |||
| a562a8e2ed | |||
| 215c4046d1 | |||
| d30c93989c | |||
| 4554f92fb4 | |||
| 556ed9b848 | |||
| 344e7e90c2 | |||
| c64bf16c8b | |||
| ce5ef3b77d | |||
| 8f47ee3679 | |||
| f15f229a09 | |||
| da12cdddd4 | |||
| 0bb47812e5 |
Generated
+52
@@ -99,12 +99,43 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -117,6 +148,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"indexmap",
|
||||
"rayon",
|
||||
"rhai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -294,6 +326,26 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rhai"
|
||||
version = "1.23.6"
|
||||
|
||||
@@ -14,6 +14,7 @@ authors = ["OpenAI Codex"]
|
||||
chrono = { version = "=0.4.44", features = ["serde"] }
|
||||
indexmap = { version = "=2.11.4", features = ["serde"] }
|
||||
reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] }
|
||||
rayon = "=1.12.0"
|
||||
rhai = { version = "=1.23.6", features = ["sync"] }
|
||||
serde = { version = "=1.0.228", features = ["derive"] }
|
||||
serde_json = "=1.0.145"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- Broker 支持目标权重、显式金额、目标股数、限价、VWAP/TWAP、挂单、撤单和订单查询。
|
||||
- 期货账户支持多空持仓、开平仓、今昨仓、保证金、手续费、结算和到期处理。
|
||||
- 报告输出支持权益曲线、成交、持仓、月度收益、风险指标、基准序列和 JSON 分析包。
|
||||
- 账户出入金以独立外部现金流记录保存;权益曲线同时输出 `externalCashFlow` 和现金流中性 `unitNav`,收益指标不把入金/出金计入交易收益。延迟出金在结算前做整批资金校验,不能把账户现金变成负数。
|
||||
- 内置 `OmniMicroCapStrategy`,覆盖动态市值带、均线过滤、止损止盈、固定频率再平衡和盘中执行近似。
|
||||
|
||||
## Workspace 布局
|
||||
@@ -80,6 +81,10 @@
|
||||
|
||||
任意数据库指标和自定义因子通过 `factor("field")`、`factor_value("field", lookback)`、`rolling_mean("field", n)`、`sma("close", n)` 等函数读取。未预计算的均线窗口可在回测中按已有历史数据实时计算。
|
||||
|
||||
Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与真实 `volume=0` 含义不同。依赖成交量的 rolling 窗口只要包含源空值就返回缺失,不得把空值补成 0;停牌日明确提供的 0 成交量仍是合法观测。该合同随 runner 快照版本冻结,旧快照不能跨版本复用。
|
||||
|
||||
`holdUntilExit=true` 与 `stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
|
||||
|
||||
## 内置微盘策略
|
||||
|
||||
`OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线:
|
||||
|
||||
@@ -8,6 +8,7 @@ authors.workspace = true
|
||||
[dependencies]
|
||||
chrono.workspace = true
|
||||
indexmap.workspace = true
|
||||
rayon.workspace = true
|
||||
rhai.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
+2037
-329
File diff suppressed because it is too large
Load Diff
@@ -5,17 +5,16 @@ 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);
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TradingCost {
|
||||
pub commission: f64,
|
||||
pub stamp_tax: f64,
|
||||
pub transfer_fee: f64,
|
||||
}
|
||||
|
||||
impl TradingCost {
|
||||
pub fn total(self) -> f64 {
|
||||
self.commission + self.stamp_tax
|
||||
self.commission + self.stamp_tax + self.transfer_fee
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,30 +40,16 @@ pub struct ChinaAShareCostModel {
|
||||
pub stamp_tax_rate_after_change: f64,
|
||||
pub stamp_tax_change_date: NaiveDate,
|
||||
pub minimum_commission: f64,
|
||||
pub transfer_fee_rate: f64,
|
||||
}
|
||||
|
||||
impl Default for ChinaAShareCostModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
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,
|
||||
}
|
||||
Self::from_trading_constraints(TradingConstraintConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -72,6 +57,7 @@ impl ChinaAShareCostModel {
|
||||
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,
|
||||
transfer_fee_rate: config.transfer_fee_rate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +83,13 @@ impl ChinaAShareCostModel {
|
||||
gross_amount * self.stamp_tax_rate_for(date)
|
||||
}
|
||||
|
||||
pub fn transfer_fee_for(&self, gross_amount: f64) -> f64 {
|
||||
if gross_amount <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
gross_amount * self.transfer_fee_rate
|
||||
}
|
||||
|
||||
pub fn commission_for_order_fill(
|
||||
&self,
|
||||
gross_amount: f64,
|
||||
@@ -135,30 +128,24 @@ 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 {
|
||||
return TradingCost {
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let commission = self.commission_for(gross_amount);
|
||||
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
|
||||
let transfer_fee = self.transfer_fee_for(gross_amount);
|
||||
|
||||
TradingCost {
|
||||
commission,
|
||||
stamp_tax,
|
||||
transfer_fee,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,15 +161,18 @@ impl CostModel for ChinaAShareCostModel {
|
||||
return TradingCost {
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let commission = self.commission_for_order_fill(gross_amount, order_id, commission_state);
|
||||
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
|
||||
let transfer_fee = self.transfer_fee_for(gross_amount);
|
||||
|
||||
TradingCost {
|
||||
commission,
|
||||
stamp_tax,
|
||||
transfer_fee,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,8 +182,8 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aiquant_default_matches_current_backtest_fee_model() {
|
||||
let model = ChinaAShareCostModel::aiquant_default();
|
||||
fn default_matches_configurable_trading_constraints() {
|
||||
let model = ChinaAShareCostModel::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);
|
||||
@@ -207,6 +197,7 @@ mod tests {
|
||||
let config = TradingConstraintConfig {
|
||||
commission_rate: 0.0003,
|
||||
minimum_commission: 5.0,
|
||||
transfer_fee_rate: 0.00001,
|
||||
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"),
|
||||
@@ -214,6 +205,8 @@ mod tests {
|
||||
};
|
||||
let model = ChinaAShareCostModel::from_trading_constraints(config);
|
||||
|
||||
assert!((model.transfer_fee_for(10_000.0) - 0.1).abs() < 1e-12);
|
||||
|
||||
assert!(
|
||||
(model.stamp_tax_for(
|
||||
NaiveDate::from_ymd_opt(2025, 1, 9).expect("valid date"),
|
||||
|
||||
+557
-132
@@ -1,7 +1,8 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::{Arc, OnceLock, RwLock};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -10,6 +11,8 @@ use crate::futures::FuturesTradingParameter;
|
||||
use crate::instrument::Instrument;
|
||||
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig};
|
||||
|
||||
const SOURCE_DAILY_VOLUME_AVAILABLE_FIELD: &str = "source_daily_volume_available";
|
||||
|
||||
mod date_format {
|
||||
use chrono::NaiveDate;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
@@ -448,49 +451,12 @@ pub struct EligibleUniverseSnapshot {
|
||||
pub free_float_cap_bn: f64,
|
||||
}
|
||||
|
||||
pub fn decision_adjusted_cap_bn(
|
||||
factor_date: NaiveDate,
|
||||
raw_cap_bn: f64,
|
||||
market: &DailyMarketSnapshot,
|
||||
) -> f64 {
|
||||
if !raw_cap_bn.is_finite() || raw_cap_bn <= 0.0 {
|
||||
return f64::NAN;
|
||||
}
|
||||
if factor_date != market.date {
|
||||
return raw_cap_bn;
|
||||
}
|
||||
if !market.close.is_finite()
|
||||
|| market.close <= 0.0
|
||||
|| !market.prev_close.is_finite()
|
||||
|| market.prev_close <= 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
raw_cap_bn * market.prev_close / market.close
|
||||
pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
|
||||
factor.market_cap_bn
|
||||
}
|
||||
|
||||
fn factor_market_cap_is_decision_adjusted(factor: &DailyFactorSnapshot) -> bool {
|
||||
factor
|
||||
.extra_factors
|
||||
.get("__market_cap_decision_adjusted")
|
||||
.is_some_and(|value| value.is_finite() && *value > 0.0)
|
||||
}
|
||||
|
||||
pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot, market: &DailyMarketSnapshot) -> f64 {
|
||||
if factor_market_cap_is_decision_adjusted(factor) {
|
||||
return factor.market_cap_bn;
|
||||
}
|
||||
decision_adjusted_cap_bn(factor.date, factor.market_cap_bn, market)
|
||||
}
|
||||
|
||||
pub fn decision_free_float_cap_bn(
|
||||
factor: &DailyFactorSnapshot,
|
||||
market: &DailyMarketSnapshot,
|
||||
) -> f64 {
|
||||
if factor_market_cap_is_decision_adjusted(factor) {
|
||||
return factor.free_float_cap_bn;
|
||||
}
|
||||
decision_adjusted_cap_bn(factor.date, factor.free_float_cap_bn, market)
|
||||
pub fn decision_free_float_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
|
||||
factor.free_float_cap_bn
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -523,6 +489,106 @@ struct SymbolPriceSeries {
|
||||
volume_prefix: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AdjustedCloseSeries {
|
||||
dates: Vec<NaiveDate>,
|
||||
backward_factors: Vec<Option<f64>>,
|
||||
back_adjusted_closes: Vec<Option<f64>>,
|
||||
back_adjusted_close_prefix: Vec<f64>,
|
||||
missing_back_adjusted_close_prefix: Vec<u32>,
|
||||
}
|
||||
|
||||
impl AdjustedCloseSeries {
|
||||
fn new(
|
||||
market: &SymbolPriceSeries,
|
||||
factor_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyFactorSnapshot>>>,
|
||||
) -> Option<Self> {
|
||||
let mut backward_factors = Vec::with_capacity(market.dates.len());
|
||||
let mut back_adjusted_closes = Vec::with_capacity(market.dates.len());
|
||||
let mut back_adjusted_close_prefix = Vec::with_capacity(market.dates.len() + 1);
|
||||
let mut missing_back_adjusted_close_prefix = Vec::with_capacity(market.dates.len() + 1);
|
||||
back_adjusted_close_prefix.push(0.0);
|
||||
missing_back_adjusted_close_prefix.push(0);
|
||||
for (date, close) in market.dates.iter().zip(&market.closes) {
|
||||
let factor = factor_by_date
|
||||
.get(date)
|
||||
.and_then(|rows| {
|
||||
find_arc_by_symbol(rows, &market.symbol, |row| row.symbol.as_str())
|
||||
})
|
||||
.and_then(|snapshot| factor_numeric_value(snapshot, "adjustment_factor_backward1"))
|
||||
.filter(|factor| factor.is_finite() && *factor > 0.0);
|
||||
let back_adjusted_close = factor
|
||||
.filter(|_| close.is_finite() && *close > 0.0)
|
||||
.map(|factor| close * factor);
|
||||
backward_factors.push(factor);
|
||||
back_adjusted_closes.push(back_adjusted_close);
|
||||
back_adjusted_close_prefix.push(
|
||||
back_adjusted_close_prefix
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
+ back_adjusted_close.unwrap_or_default(),
|
||||
);
|
||||
missing_back_adjusted_close_prefix.push(
|
||||
missing_back_adjusted_close_prefix
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
+ u32::from(back_adjusted_close.is_none()),
|
||||
);
|
||||
}
|
||||
Some(Self {
|
||||
dates: market.dates.clone(),
|
||||
backward_factors,
|
||||
back_adjusted_closes,
|
||||
back_adjusted_close_prefix,
|
||||
missing_back_adjusted_close_prefix,
|
||||
})
|
||||
}
|
||||
|
||||
fn current_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
||||
if lookback == 0 {
|
||||
return None;
|
||||
}
|
||||
let end = match self.dates.binary_search(&date) {
|
||||
Ok(index) => index + 1,
|
||||
Err(0) => return None,
|
||||
Err(index) => index,
|
||||
};
|
||||
if end < lookback {
|
||||
return None;
|
||||
}
|
||||
let base_factor = self.backward_factors.get(end - 1).copied().flatten()?;
|
||||
let start = end - lookback;
|
||||
if self.missing_back_adjusted_close_prefix[end]
|
||||
!= self.missing_back_adjusted_close_prefix[start]
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let sum = self.back_adjusted_close_prefix[end] - self.back_adjusted_close_prefix[start];
|
||||
if !sum.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(normalize_rolling_factor(
|
||||
sum / lookback as f64 / base_factor,
|
||||
12,
|
||||
))
|
||||
}
|
||||
|
||||
fn latest_back_adjusted_close(&self, date: NaiveDate) -> Option<f64> {
|
||||
let index = match self.dates.binary_search(&date) {
|
||||
Ok(index) => index,
|
||||
Err(0) => return None,
|
||||
Err(index) => index - 1,
|
||||
};
|
||||
self.back_adjusted_closes
|
||||
.get(index)
|
||||
.copied()
|
||||
.flatten()
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl SymbolPriceSeries {
|
||||
fn new<'a, I>(symbol: String, rows: I) -> Self
|
||||
where
|
||||
@@ -739,7 +805,10 @@ impl SymbolPriceSeries {
|
||||
}
|
||||
let start = end - lookback;
|
||||
let sum = self.volume_prefix[end] - self.volume_prefix[start];
|
||||
Some(sum / lookback as f64)
|
||||
if !sum.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(normalize_rolling_factor(sum / lookback as f64, 6))
|
||||
}
|
||||
|
||||
fn decision_volume_values(&self, date: NaiveDate, lookback: usize) -> Option<Vec<f64>> {
|
||||
@@ -984,9 +1053,12 @@ pub struct DataSet {
|
||||
execution_quotes_by_date: HashMap<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>,
|
||||
order_book_depth_index: HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>,
|
||||
benchmark_by_date: BTreeMap<NaiveDate, BenchmarkSnapshot>,
|
||||
market_series_by_symbol: Arc<RwLock<HashMap<String, Arc<SymbolPriceSeries>>>>,
|
||||
market_series_by_symbol: Arc<HashMap<String, Arc<SymbolPriceSeries>>>,
|
||||
adjusted_close_series_by_symbol: Arc<HashMap<String, Arc<AdjustedCloseSeries>>>,
|
||||
benchmark_series_cache: BenchmarkPriceSeries,
|
||||
eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>,
|
||||
source_daily_volume_contract_symbols: HashSet<String>,
|
||||
source_daily_volume_missing_dates_by_symbol: HashMap<String, Vec<NaiveDate>>,
|
||||
benchmark_code: String,
|
||||
futures_params_by_symbol: HashMap<String, Vec<FuturesTradingParameter>>,
|
||||
}
|
||||
@@ -1134,10 +1206,30 @@ impl DataSet {
|
||||
) -> Result<Self, DataSetError> {
|
||||
let benchmark_code = collect_benchmark_code(&benchmarks)?;
|
||||
let calendar = TradingCalendar::new(benchmarks.iter().map(|item| item.date).collect());
|
||||
let factors = normalize_factor_snapshots(factors)
|
||||
.into_iter()
|
||||
.map(Arc::new)
|
||||
.collect::<Vec<_>>();
|
||||
let factors = normalize_factor_snapshots(factors);
|
||||
let mut source_daily_volume_contract_symbols = HashSet::new();
|
||||
let mut source_daily_volume_missing_dates_by_symbol =
|
||||
HashMap::<String, Vec<NaiveDate>>::new();
|
||||
for snapshot in &factors {
|
||||
let Some(available) = snapshot
|
||||
.extra_factors
|
||||
.get(SOURCE_DAILY_VOLUME_AVAILABLE_FIELD)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
source_daily_volume_contract_symbols.insert(snapshot.symbol.clone());
|
||||
if *available < 0.5 {
|
||||
source_daily_volume_missing_dates_by_symbol
|
||||
.entry(snapshot.symbol.clone())
|
||||
.or_default()
|
||||
.push(snapshot.date);
|
||||
}
|
||||
}
|
||||
for dates in source_daily_volume_missing_dates_by_symbol.values_mut() {
|
||||
dates.sort_unstable();
|
||||
dates.dedup();
|
||||
}
|
||||
let factors = factors.into_iter().map(Arc::new).collect::<Vec<_>>();
|
||||
let candidates = candidates.into_iter().map(Arc::new).collect::<Vec<_>>();
|
||||
|
||||
let instruments = instruments
|
||||
@@ -1151,6 +1243,27 @@ impl DataSet {
|
||||
|
||||
let mut factor_by_date = group_arc_by_date(&factors, |item| item.date);
|
||||
sort_arc_groups_by_symbol(&mut factor_by_date, |item| item.symbol.as_str());
|
||||
let mut market_rows_by_symbol = HashMap::<String, Vec<&DailyMarketSnapshot>>::new();
|
||||
for row in &market {
|
||||
market_rows_by_symbol
|
||||
.entry(row.symbol.clone())
|
||||
.or_default()
|
||||
.push(row.as_ref());
|
||||
}
|
||||
let market_series_by_symbol = market_rows_by_symbol
|
||||
.into_par_iter()
|
||||
.map(|(symbol, rows)| {
|
||||
let series = Arc::new(SymbolPriceSeries::new(symbol.clone(), rows));
|
||||
(symbol, series)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let adjusted_close_series_by_symbol = market_series_by_symbol
|
||||
.par_iter()
|
||||
.filter_map(|(symbol, market)| {
|
||||
AdjustedCloseSeries::new(market, &factor_by_date)
|
||||
.map(|series| (symbol.clone(), Arc::new(series)))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let factor_texts = factor_texts
|
||||
.into_iter()
|
||||
.filter_map(|mut item| {
|
||||
@@ -1194,9 +1307,12 @@ impl DataSet {
|
||||
execution_quotes_by_date,
|
||||
order_book_depth_index,
|
||||
benchmark_by_date,
|
||||
market_series_by_symbol: Arc::new(RwLock::new(HashMap::new())),
|
||||
market_series_by_symbol: Arc::new(market_series_by_symbol),
|
||||
adjusted_close_series_by_symbol: Arc::new(adjusted_close_series_by_symbol),
|
||||
benchmark_series_cache,
|
||||
eligible_universe_by_date: Arc::new(OnceLock::new()),
|
||||
source_daily_volume_contract_symbols,
|
||||
source_daily_volume_missing_dates_by_symbol,
|
||||
benchmark_code,
|
||||
futures_params_by_symbol,
|
||||
})
|
||||
@@ -1246,36 +1362,11 @@ impl DataSet {
|
||||
}
|
||||
|
||||
fn market_series(&self, symbol: &str) -> Option<Arc<SymbolPriceSeries>> {
|
||||
if let Some(series) = self
|
||||
.market_series_by_symbol
|
||||
.read()
|
||||
.expect("market series cache lock poisoned")
|
||||
.get(symbol)
|
||||
.cloned()
|
||||
{
|
||||
return Some(series);
|
||||
}
|
||||
self.market_series_by_symbol.get(symbol).cloned()
|
||||
}
|
||||
|
||||
let rows = self
|
||||
.market_by_date
|
||||
.values()
|
||||
.filter_map(|day_rows| find_arc_by_symbol(day_rows, symbol, |row| row.symbol.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let series = Arc::new(SymbolPriceSeries::new(symbol.to_string(), rows));
|
||||
let mut cache = self
|
||||
.market_series_by_symbol
|
||||
.write()
|
||||
.expect("market series cache lock poisoned");
|
||||
Some(
|
||||
cache
|
||||
.entry(symbol.to_string())
|
||||
.or_insert_with(|| Arc::clone(&series))
|
||||
.clone(),
|
||||
)
|
||||
fn adjusted_close_series(&self, symbol: &str) -> Option<Arc<AdjustedCloseSeries>> {
|
||||
self.adjusted_close_series_by_symbol.get(symbol).cloned()
|
||||
}
|
||||
|
||||
pub fn factor(&self, date: NaiveDate, symbol: &str) -> Option<&DailyFactorSnapshot> {
|
||||
@@ -2322,9 +2413,14 @@ impl DataSet {
|
||||
"close" | "prev_close" | "stock_close" | "price" => self
|
||||
.market_series(symbol)
|
||||
.and_then(|series| series.decision_close_moving_average(date, lookback)),
|
||||
"volume" | "stock_volume" => self
|
||||
.market_series(symbol)
|
||||
.and_then(|series| series.decision_volume_moving_average(date, lookback)),
|
||||
"volume" | "stock_volume" => {
|
||||
if !self.source_daily_volume_window_available(date, symbol, lookback, false) {
|
||||
None
|
||||
} else {
|
||||
self.market_series(symbol)
|
||||
.and_then(|series| series.decision_volume_moving_average(date, lookback))
|
||||
}
|
||||
}
|
||||
"day_open" | "dayopen" => {
|
||||
self.market_moving_average(date, symbol, lookback, PriceField::DayOpen)
|
||||
}
|
||||
@@ -2345,13 +2441,20 @@ impl DataSet {
|
||||
) -> Option<f64> {
|
||||
let field = normalize_field(field);
|
||||
match field.as_str() {
|
||||
"close" | "prev_close" | "stock_close" | "price" => {
|
||||
self.market_moving_average(date, symbol, lookback, PriceField::Close)
|
||||
"close" | "prev_close" | "stock_close" | "price" => self
|
||||
.adjusted_close_series(symbol)
|
||||
.and_then(|series| series.current_moving_average(date, lookback)),
|
||||
"volume" | "stock_volume" => {
|
||||
if !self.source_daily_volume_window_available(date, symbol, lookback, true) {
|
||||
None
|
||||
} else {
|
||||
self.market_series(symbol)
|
||||
.and_then(|series| series.current_volume_moving_average(date, lookback))
|
||||
.or_else(|| {
|
||||
self.factor_moving_average(date, symbol, "daily_volume", lookback)
|
||||
})
|
||||
}
|
||||
}
|
||||
"volume" | "stock_volume" => self
|
||||
.market_series(symbol)
|
||||
.and_then(|series| series.current_volume_moving_average(date, lookback))
|
||||
.or_else(|| self.factor_moving_average(date, symbol, "daily_volume", lookback)),
|
||||
"day_open" | "dayopen" => {
|
||||
self.market_moving_average(date, symbol, lookback, PriceField::DayOpen)
|
||||
}
|
||||
@@ -2363,6 +2466,11 @@ impl DataSet {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn market_latest_back_adjusted_close(&self, date: NaiveDate, symbol: &str) -> Option<f64> {
|
||||
self.adjusted_close_series(symbol)
|
||||
.and_then(|series| series.latest_back_adjusted_close(date))
|
||||
}
|
||||
|
||||
pub fn market_decision_numeric_values(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -2379,10 +2487,15 @@ impl DataSet {
|
||||
.market_series(symbol)
|
||||
.and_then(|series| series.decision_prev_close_values(date, lookback))
|
||||
.unwrap_or_default(),
|
||||
"volume" | "stock_volume" => self
|
||||
.market_series(symbol)
|
||||
.and_then(|series| series.decision_volume_values(date, lookback))
|
||||
.unwrap_or_default(),
|
||||
"volume" | "stock_volume" => {
|
||||
if !self.source_daily_volume_window_available(date, symbol, lookback, false) {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.market_series(symbol)
|
||||
.and_then(|series| series.decision_volume_values(date, lookback))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
"day_open" | "dayopen" => self
|
||||
.market_series(symbol)
|
||||
.map(|series| series.trailing_values(date, lookback, PriceField::DayOpen))
|
||||
@@ -2399,6 +2512,67 @@ impl DataSet {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn market_current_numeric_values(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
field: &str,
|
||||
lookback: usize,
|
||||
) -> Vec<f64> {
|
||||
let field = normalize_field(field);
|
||||
if matches!(field.as_str(), "volume" | "stock_volume")
|
||||
&& !self.source_daily_volume_window_available(date, symbol, lookback, true)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
self.market_series(symbol)
|
||||
.map(|series| series.trailing_numeric_values(date, lookback, &field, true))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn source_daily_volume_window_available(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
lookback: usize,
|
||||
include_now: bool,
|
||||
) -> bool {
|
||||
if !self.source_daily_volume_contract_symbols.contains(symbol) {
|
||||
return true;
|
||||
}
|
||||
if lookback == 0 {
|
||||
return false;
|
||||
}
|
||||
let end = if include_now && self.calendar.index_of(date).is_some() {
|
||||
date
|
||||
} else {
|
||||
let Some(previous) = self.calendar.previous_trading_date(date, 1) else {
|
||||
return false;
|
||||
};
|
||||
previous
|
||||
};
|
||||
let dates = self.calendar.trailing_days(end, lookback);
|
||||
if dates.len() != lookback {
|
||||
return false;
|
||||
}
|
||||
let Some(series) = self.market_series(symbol) else {
|
||||
return false;
|
||||
};
|
||||
if dates
|
||||
.iter()
|
||||
.any(|trading_day| series.dates.binary_search(trading_day).is_err())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(missing_dates) = self.source_daily_volume_missing_dates_by_symbol.get(symbol)
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
!dates
|
||||
.iter()
|
||||
.any(|trading_day| missing_dates.binary_search(trading_day).is_ok())
|
||||
}
|
||||
|
||||
pub fn factor_numeric_values(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -2933,6 +3107,11 @@ fn prefix_sums(values: &[f64]) -> Vec<f64> {
|
||||
prefix
|
||||
}
|
||||
|
||||
fn normalize_rolling_factor(value: f64, decimals: i32) -> f64 {
|
||||
let scale = 10_f64.powi(decimals);
|
||||
(value * scale).round() / scale
|
||||
}
|
||||
|
||||
mod optional_date_format {
|
||||
use chrono::NaiveDate;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
@@ -3048,20 +3227,21 @@ fn build_fundamental_universe_for_date(
|
||||
return rows;
|
||||
};
|
||||
for factor in factors {
|
||||
let Some(market) = market_by_date
|
||||
if market_by_date
|
||||
.get(&date)
|
||||
.and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()))
|
||||
else {
|
||||
.is_none()
|
||||
{
|
||||
continue;
|
||||
};
|
||||
let market_cap_bn = decision_market_cap_bn(factor, market);
|
||||
}
|
||||
let market_cap_bn = decision_market_cap_bn(factor);
|
||||
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
||||
continue;
|
||||
}
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
symbol: factor.symbol.clone(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn: decision_free_float_cap_bn(factor, market),
|
||||
free_float_cap_bn: decision_free_float_cap_bn(factor),
|
||||
});
|
||||
}
|
||||
rows.sort_by(|left, right| {
|
||||
@@ -3136,11 +3316,11 @@ fn build_eligible_universe_for_date_from_factors(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let market_cap_bn = decision_market_cap_bn(factor, market);
|
||||
let market_cap_bn = decision_market_cap_bn(factor);
|
||||
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let free_float_cap_bn = decision_free_float_cap_bn(factor, market);
|
||||
let free_float_cap_bn = decision_free_float_cap_bn(factor);
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
symbol: factor.symbol.clone(),
|
||||
market_cap_bn,
|
||||
@@ -3306,6 +3486,113 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn volume_contract_data(availability: Option<[f64; 3]>) -> DataSet {
|
||||
let dates = [
|
||||
NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(),
|
||||
NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(),
|
||||
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
|
||||
];
|
||||
let volumes = [100_u64, 0, 300];
|
||||
DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "000001.SZ".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(dates[0]),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
dates
|
||||
.iter()
|
||||
.zip(volumes)
|
||||
.map(|(date, volume)| {
|
||||
market_row(&date.format("%Y-%m-%d").to_string(), 10.0, volume)
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, date)| {
|
||||
let mut extra_factors = BTreeMap::new();
|
||||
if let Some(values) = availability {
|
||||
extra_factors.insert(
|
||||
SOURCE_DAILY_VOLUME_AVAILABLE_FIELD.to_string(),
|
||||
values[index],
|
||||
);
|
||||
if values[index] >= 0.5 {
|
||||
extra_factors.insert("daily_volume".to_string(), volumes[index] as f64);
|
||||
}
|
||||
}
|
||||
DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
extra_factors,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| BenchmarkSnapshot {
|
||||
date: *date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 100.0,
|
||||
volume: 1_000_000,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.expect("volume contract dataset")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_volume_contract_rejects_windows_containing_missing_values() {
|
||||
let data = volume_contract_data(Some([1.0, 0.0, 1.0]));
|
||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
data.market_current_numeric_moving_average(date, "000001.SZ", "volume", 3),
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
data.market_current_numeric_values(date, "000001.SZ", "volume", 3)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
data.market_decision_numeric_moving_average(date, "000001.SZ", "volume", 2),
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
data.market_decision_numeric_values(date, "000001.SZ", "volume", 2)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_volume_contract_keeps_valid_zero_volume_and_legacy_data() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
for data in [
|
||||
volume_contract_data(Some([1.0, 1.0, 1.0])),
|
||||
volume_contract_data(None),
|
||||
] {
|
||||
assert_eq!(
|
||||
data.market_current_numeric_moving_average(date, "000001.SZ", "volume", 3),
|
||||
Some(133.333333)
|
||||
);
|
||||
assert_eq!(
|
||||
data.market_current_numeric_values(date, "000001.SZ", "volume", 3),
|
||||
vec![100.0, 0.0, 300.0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_close_average_ignores_current_day_close() {
|
||||
let mut current = market_row("2025-01-06", 12.0, 10_000);
|
||||
@@ -3331,6 +3618,169 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_close_average_uses_backward_adjustment_factor_and_current_base() {
|
||||
let dates = [
|
||||
NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(),
|
||||
NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(),
|
||||
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
|
||||
];
|
||||
let factors = [1.0, 1.0, 2.0];
|
||||
let closes = [10.0, 11.0, 6.0];
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "000001.SZ".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(dates[0]),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
dates
|
||||
.iter()
|
||||
.zip(closes)
|
||||
.map(|(date, close)| market_row(&date.format("%Y-%m-%d").to_string(), close, 100))
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.zip(factors)
|
||||
.map(|(date, factor)| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
extra_factors: BTreeMap::from([(
|
||||
"adjustment_factor_backward1".to_string(),
|
||||
factor,
|
||||
)]),
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| BenchmarkSnapshot {
|
||||
date: *date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 100.0,
|
||||
volume: 1_000_000,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
assert_eq!(
|
||||
data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3),
|
||||
Some(5.5)
|
||||
);
|
||||
assert_ne!(
|
||||
data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3),
|
||||
data.market_moving_average(dates[2], "000001.SZ", 3, PriceField::Close)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjusted_close_average_normalization_prevents_strict_crossover_drift() {
|
||||
let pattern = [
|
||||
2.953, 1.093, 2.717, 1.579, 1.289, 1.236, 1.617, 2.632, 1.361, 2.163,
|
||||
];
|
||||
let start = NaiveDate::parse_from_str("2025-01-01", "%Y-%m-%d").unwrap();
|
||||
let values = (0..30)
|
||||
.map(|index| pattern[index % pattern.len()])
|
||||
.collect::<Vec<_>>();
|
||||
let series = AdjustedCloseSeries {
|
||||
dates: (0..30)
|
||||
.map(|index| start + chrono::Duration::days(index as i64))
|
||||
.collect(),
|
||||
backward_factors: vec![Some(1.0); 30],
|
||||
back_adjusted_closes: values.iter().copied().map(Some).collect(),
|
||||
back_adjusted_close_prefix: prefix_sums(&values),
|
||||
missing_back_adjusted_close_prefix: vec![0; 31],
|
||||
};
|
||||
let date = *series.dates.last().expect("last date");
|
||||
|
||||
assert_eq!(
|
||||
series.current_moving_average(date, 10),
|
||||
series.current_moving_average(date, 30)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_missing_adjustment_factor_does_not_invalidate_historical_window() {
|
||||
let dates = [
|
||||
NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(),
|
||||
NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(),
|
||||
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
|
||||
NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(),
|
||||
];
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "000001.SZ".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(dates[0]),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
dates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, date)| {
|
||||
market_row(
|
||||
&date.format("%Y-%m-%d").to_string(),
|
||||
10.0 + index as f64,
|
||||
100,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
extra_factors: if *date == dates[3] {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
BTreeMap::from([("adjustment_factor_backward1".to_string(), 1.0)])
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| BenchmarkSnapshot {
|
||||
date: *date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 100.0,
|
||||
volume: 1_000_000,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
assert_eq!(
|
||||
data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3),
|
||||
Some(11.0)
|
||||
);
|
||||
assert_eq!(
|
||||
data.market_current_numeric_moving_average(dates[3], "000001.SZ", "close", 3),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_volume_average_includes_paused_zero_volume_days() {
|
||||
let mut paused = market_row("2025-01-03", 11.0, 0);
|
||||
@@ -3444,11 +3894,11 @@ mod tests {
|
||||
|
||||
let rows = data.eligible_universe_on(date);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].symbol, "000001.SZ");
|
||||
assert!((rows[0].market_cap_bn - 6.0).abs() < 1e-9);
|
||||
assert!((rows[0].free_float_cap_bn - 2.0).abs() < 1e-9);
|
||||
assert_eq!(rows[1].symbol, "000002.SZ");
|
||||
assert!((rows[1].market_cap_bn - 10.0).abs() < 1e-9);
|
||||
assert_eq!(rows[0].symbol, "000002.SZ");
|
||||
assert!((rows[0].market_cap_bn - 10.0).abs() < 1e-9);
|
||||
assert_eq!(rows[1].symbol, "000001.SZ");
|
||||
assert!((rows[1].market_cap_bn - 12.0).abs() < 1e-9);
|
||||
assert!((rows[1].free_float_cap_bn - 4.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3621,33 +4071,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_market_cap_keeps_pre_adjusted_factor() {
|
||||
fn decision_market_cap_uses_factor_date_snapshot_without_price_reconstruction() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
let market = DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 20.0,
|
||||
low: 10.0,
|
||||
close: 20.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 1_000,
|
||||
ask1_volume: 1_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
};
|
||||
let mut extra_factors = BTreeMap::new();
|
||||
extra_factors.insert("__market_cap_decision_adjusted".to_string(), 1.0);
|
||||
let factor = DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
@@ -3656,11 +4081,11 @@ mod tests {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors,
|
||||
extra_factors: BTreeMap::new(),
|
||||
};
|
||||
|
||||
assert!((decision_market_cap_bn(&factor, &market) - 12.0).abs() < 1e-9);
|
||||
assert!((decision_free_float_cap_bn(&factor, &market) - 4.0).abs() < 1e-9);
|
||||
assert!((decision_market_cap_bn(&factor) - 12.0).abs() < 1e-9);
|
||||
assert!((decision_free_float_cap_bn(&factor) - 4.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+252
-130
@@ -78,6 +78,13 @@ pub struct DailyEquityPoint {
|
||||
pub cash: f64,
|
||||
pub market_value: f64,
|
||||
pub total_equity: f64,
|
||||
/// External cash flow settled on this trading date (deposit positive,
|
||||
/// withdrawal negative). Trading cash movements are excluded.
|
||||
#[serde(default)]
|
||||
pub external_cash_flow: f64,
|
||||
/// Cash-flow-neutral unit NAV after all activity on this date.
|
||||
#[serde(default)]
|
||||
pub unit_nav: f64,
|
||||
pub benchmark_close: f64,
|
||||
pub benchmark_prev_close: f64,
|
||||
pub notes: String,
|
||||
@@ -196,7 +203,7 @@ impl BacktestResult {
|
||||
quantity: fill.quantity,
|
||||
price: fill.price,
|
||||
gross_amount: fill.gross_amount,
|
||||
transaction_cost: fill.commission + fill.stamp_tax,
|
||||
transaction_cost: fill.commission + fill.stamp_tax + fill.transfer_fee,
|
||||
net_cash_flow: fill.net_cash_flow,
|
||||
reason: fill.reason.clone(),
|
||||
})
|
||||
@@ -230,27 +237,34 @@ impl BacktestResult {
|
||||
|
||||
pub fn analyzer_monthly_returns(&self) -> Vec<AnalyzerMonthlyReturnRow> {
|
||||
let mut month_points = BTreeMap::<(i32, u32), (f64, f64, f64, f64)>::new();
|
||||
let mut previous_equity = self.metrics.initial_cash;
|
||||
let mut previous_equity = 1.0;
|
||||
let mut previous_benchmark = self
|
||||
.equity_curve
|
||||
.first()
|
||||
.map(|point| point.benchmark_prev_close)
|
||||
.unwrap_or_default();
|
||||
for point in &self.equity_curve {
|
||||
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
|
||||
point.unit_nav
|
||||
} else if self.metrics.initial_cash.abs() > f64::EPSILON {
|
||||
point.total_equity / self.metrics.initial_cash
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let key = (point.date.year(), point.date.month());
|
||||
month_points
|
||||
.entry(key)
|
||||
.and_modify(|(_, _, end_equity, end_benchmark)| {
|
||||
*end_equity = point.total_equity;
|
||||
*end_equity = point_nav;
|
||||
*end_benchmark = point.benchmark_close;
|
||||
})
|
||||
.or_insert((
|
||||
previous_equity,
|
||||
previous_benchmark,
|
||||
point.total_equity,
|
||||
point_nav,
|
||||
point.benchmark_close,
|
||||
));
|
||||
previous_equity = point.total_equity;
|
||||
previous_equity = point_nav;
|
||||
previous_benchmark = point.benchmark_close;
|
||||
}
|
||||
month_points
|
||||
@@ -299,6 +313,8 @@ pub struct BacktestDayProgress {
|
||||
pub cash: f64,
|
||||
pub market_value: f64,
|
||||
pub total_equity: f64,
|
||||
#[serde(default)]
|
||||
pub external_cash_flow: f64,
|
||||
pub unit_nav: f64,
|
||||
pub total_return: f64,
|
||||
pub benchmark_close: f64,
|
||||
@@ -1728,6 +1744,8 @@ where
|
||||
daily_holdings: Vec::new(),
|
||||
metrics: BacktestMetrics::default(),
|
||||
};
|
||||
let mut stock_equity_by_date = BTreeMap::<NaiveDate, f64>::new();
|
||||
let mut previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||
|
||||
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
|
||||
let mut corporate_action_notes = Vec::new();
|
||||
@@ -1739,7 +1757,7 @@ where
|
||||
execution_date,
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
);
|
||||
)?;
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
pending_cash_flow_report,
|
||||
@@ -1768,11 +1786,11 @@ where
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
let delisting_report = self.settle_delisted_positions(
|
||||
let delisting_report = self.audit_unresolved_delisted_positions(
|
||||
execution_date,
|
||||
&mut portfolio,
|
||||
&portfolio,
|
||||
&mut corporate_action_notes,
|
||||
)?;
|
||||
);
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
delisting_report,
|
||||
@@ -1828,16 +1846,21 @@ where
|
||||
.join(" | ");
|
||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||
let day_process_events = process_events.clone();
|
||||
let aggregate_initial_cash = self.aggregate_initial_cash();
|
||||
let aggregate_cash = self.aggregate_cash(&portfolio);
|
||||
let aggregate_market_value = self.aggregate_market_value(&portfolio);
|
||||
let aggregate_total_equity = self.aggregate_total_equity(&portfolio);
|
||||
let unit_nav = portfolio.unit_net_value();
|
||||
let external_cash_flow =
|
||||
portfolio.external_cash_flow_total() - previous_external_cash_flow_total;
|
||||
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||
|
||||
result.equity_curve.push(DailyEquityPoint {
|
||||
date: execution_date,
|
||||
cash: aggregate_cash,
|
||||
market_value: aggregate_market_value,
|
||||
total_equity: aggregate_total_equity,
|
||||
external_cash_flow,
|
||||
unit_nav,
|
||||
benchmark_close: benchmark.close,
|
||||
benchmark_prev_close: benchmark.prev_close,
|
||||
notes,
|
||||
@@ -1853,16 +1876,9 @@ where
|
||||
cash: latest.cash,
|
||||
market_value: latest.market_value,
|
||||
total_equity: latest.total_equity,
|
||||
unit_nav: if aggregate_initial_cash.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
latest.total_equity / aggregate_initial_cash
|
||||
},
|
||||
total_return: if aggregate_initial_cash.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(latest.total_equity / aggregate_initial_cash) - 1.0
|
||||
},
|
||||
external_cash_flow: latest.external_cash_flow,
|
||||
unit_nav: latest.unit_nav,
|
||||
total_return: latest.unit_nav - 1.0,
|
||||
benchmark_close: latest.benchmark_close,
|
||||
daily_fill_count,
|
||||
cumulative_trade_count: result.fills.len(),
|
||||
@@ -1875,8 +1891,12 @@ where
|
||||
process_events: day_process_events,
|
||||
});
|
||||
result.process_events.append(&mut process_events);
|
||||
stock_equity_by_date.insert(execution_date, portfolio.total_equity());
|
||||
continue;
|
||||
};
|
||||
let decision_total_equity = (decision_date < execution_date)
|
||||
.then(|| stock_equity_by_date.get(&decision_date).copied())
|
||||
.flatten();
|
||||
let mut process_events = Vec::new();
|
||||
let mut directive_report = BrokerExecutionReport::default();
|
||||
let pre_open_orders = self.open_order_views();
|
||||
@@ -2073,10 +2093,11 @@ where
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
let mut report = self.broker.execute_with_event_dates(
|
||||
let mut report = self.broker.execute_with_event_dates_and_decision_equity(
|
||||
execution_date,
|
||||
decision_date,
|
||||
decision_date,
|
||||
decision_total_equity,
|
||||
&mut portfolio,
|
||||
&self.data,
|
||||
&auction_decision,
|
||||
@@ -2321,10 +2342,11 @@ where
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
let mut intraday_report = self.broker.execute_with_event_dates(
|
||||
let mut intraday_report = self.broker.execute_with_event_dates_and_decision_equity(
|
||||
execution_date,
|
||||
decision_date,
|
||||
decision_date,
|
||||
decision_total_equity,
|
||||
&mut portfolio,
|
||||
&self.data,
|
||||
&decision,
|
||||
@@ -2492,16 +2514,19 @@ where
|
||||
Some(minute_time),
|
||||
Some(minute_time),
|
||||
)?;
|
||||
let mut minute_report = self.broker.execute_between_with_event_dates(
|
||||
execution_date,
|
||||
decision_date,
|
||||
decision_date,
|
||||
&mut portfolio,
|
||||
&self.data,
|
||||
&minute_decision,
|
||||
Some(minute_time),
|
||||
Some(minute_time),
|
||||
)?;
|
||||
let mut minute_report = self
|
||||
.broker
|
||||
.execute_between_with_event_dates_and_decision_equity(
|
||||
execution_date,
|
||||
decision_date,
|
||||
decision_date,
|
||||
decision_total_equity,
|
||||
&mut portfolio,
|
||||
&self.data,
|
||||
&minute_decision,
|
||||
Some(minute_time),
|
||||
Some(minute_time),
|
||||
)?;
|
||||
let post_minute_open_orders = self.open_order_views();
|
||||
publish_process_events(
|
||||
&mut self.strategy,
|
||||
@@ -2841,16 +2866,21 @@ where
|
||||
.join(" | ");
|
||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||
let day_process_events = process_events.clone();
|
||||
let aggregate_initial_cash = self.aggregate_initial_cash();
|
||||
let aggregate_cash = self.aggregate_cash(&portfolio);
|
||||
let aggregate_market_value = self.aggregate_market_value(&portfolio);
|
||||
let aggregate_total_equity = self.aggregate_total_equity(&portfolio);
|
||||
let unit_nav = portfolio.unit_net_value();
|
||||
let external_cash_flow =
|
||||
portfolio.external_cash_flow_total() - previous_external_cash_flow_total;
|
||||
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||
|
||||
result.equity_curve.push(DailyEquityPoint {
|
||||
date: execution_date,
|
||||
cash: aggregate_cash,
|
||||
market_value: aggregate_market_value,
|
||||
total_equity: aggregate_total_equity,
|
||||
external_cash_flow,
|
||||
unit_nav,
|
||||
benchmark_close: benchmark.close,
|
||||
benchmark_prev_close: benchmark.prev_close,
|
||||
notes,
|
||||
@@ -2866,16 +2896,9 @@ where
|
||||
cash: latest.cash,
|
||||
market_value: latest.market_value,
|
||||
total_equity: latest.total_equity,
|
||||
unit_nav: if aggregate_initial_cash.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
latest.total_equity / aggregate_initial_cash
|
||||
},
|
||||
total_return: if aggregate_initial_cash.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(latest.total_equity / aggregate_initial_cash) - 1.0
|
||||
},
|
||||
external_cash_flow: latest.external_cash_flow,
|
||||
unit_nav: latest.unit_nav,
|
||||
total_return: latest.unit_nav - 1.0,
|
||||
benchmark_close: latest.benchmark_close,
|
||||
daily_fill_count,
|
||||
cumulative_trade_count: result.fills.len(),
|
||||
@@ -2888,6 +2911,7 @@ where
|
||||
process_events: day_process_events,
|
||||
});
|
||||
result.process_events.extend(process_events);
|
||||
stock_equity_by_date.insert(execution_date, portfolio.total_equity());
|
||||
}
|
||||
|
||||
if let Some(last_date) = execution_dates.last().copied() {
|
||||
@@ -2897,6 +2921,7 @@ where
|
||||
&result.equity_curve,
|
||||
&result.fills,
|
||||
&result.daily_holdings,
|
||||
&result.account_events,
|
||||
self.aggregate_initial_cash(),
|
||||
);
|
||||
|
||||
@@ -3145,6 +3170,7 @@ where
|
||||
gross_amount: reinvest_cash,
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
net_cash_flow: -reinvest_cash,
|
||||
reason: "dividend_reinvestment".to_string(),
|
||||
});
|
||||
@@ -3194,9 +3220,12 @@ where
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
) -> BrokerExecutionReport {
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
for flow in portfolio.settle_pending_cash_flows(date) {
|
||||
for flow in portfolio
|
||||
.settle_pending_cash_flows(date)
|
||||
.map_err(BacktestError::Execution)?
|
||||
{
|
||||
let cash_before = portfolio.cash() - flow.amount;
|
||||
let note = format!(
|
||||
"deposit_withdraw_settled amount={:.2} payable_date={} reason={}",
|
||||
@@ -3211,7 +3240,7 @@ where
|
||||
note,
|
||||
});
|
||||
}
|
||||
report
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn settle_futures_expirations(&mut self, date: NaiveDate) -> BrokerExecutionReport {
|
||||
@@ -3375,13 +3404,13 @@ where
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn settle_delisted_positions(
|
||||
fn audit_unresolved_delisted_positions(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
portfolio: &PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
) -> BrokerExecutionReport {
|
||||
let report = BrokerExecutionReport::default();
|
||||
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
|
||||
for symbol in symbols {
|
||||
let Some(position) = portfolio.position(&symbol) else {
|
||||
@@ -3393,98 +3422,29 @@ where
|
||||
let Some(instrument) = self.data.instrument(&symbol) else {
|
||||
continue;
|
||||
};
|
||||
let should_settle = instrument.is_delisted_on_or_before(date)
|
||||
let is_unresolved = instrument.is_delisted_on_or_before(date)
|
||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
||||
&& instrument.delisted_at.is_none()
|
||||
&& self.data.market(date, &symbol).is_none());
|
||||
if !should_settle {
|
||||
if !is_unresolved {
|
||||
continue;
|
||||
}
|
||||
|
||||
let quantity = position.quantity;
|
||||
let settlement_price = if position.last_price.is_finite() && position.last_price > 0.0 {
|
||||
position.last_price
|
||||
} else if position.average_cost.is_finite() && position.average_cost > 0.0 {
|
||||
position.average_cost
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let effective_delisted_at = instrument
|
||||
.delisted_at
|
||||
.or_else(|| self.data.calendar().previous_day(date))
|
||||
.unwrap_or(date);
|
||||
if !settlement_price.is_finite() || settlement_price <= 0.0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"missing delisting settlement price for {} on {}",
|
||||
symbol, date
|
||||
)));
|
||||
}
|
||||
|
||||
let cash_before = portfolio.cash();
|
||||
let gross_amount = settlement_price * quantity as f64;
|
||||
let realized_pnl_delta = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&symbol)
|
||||
.expect("position exists for delisting settlement");
|
||||
position
|
||||
.sell(quantity, settlement_price)
|
||||
.map_err(BacktestError::Execution)?
|
||||
};
|
||||
portfolio.apply_cash_delta(gross_amount);
|
||||
portfolio.prune_flat_positions();
|
||||
|
||||
let reason = format!(
|
||||
"delisted_cash_settlement effective_date={} status={}",
|
||||
effective_delisted_at, instrument.status
|
||||
concat!(
|
||||
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ",
|
||||
"settlement_action=missing valuation_policy=zero no_order=true"
|
||||
),
|
||||
symbol, position.quantity, effective_delisted_at, instrument.status
|
||||
);
|
||||
notes.push(reason.clone());
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: None,
|
||||
symbol: symbol.clone(),
|
||||
side: OrderSide::Sell,
|
||||
requested_quantity: quantity,
|
||||
filled_quantity: quantity,
|
||||
status: OrderStatus::Filled,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
report.fill_events.push(FillEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: None,
|
||||
symbol: symbol.clone(),
|
||||
side: OrderSide::Sell,
|
||||
quantity,
|
||||
price: settlement_price,
|
||||
gross_amount,
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
net_cash_flow: gross_amount,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
delta_quantity: -(quantity as i32),
|
||||
quantity_after: 0,
|
||||
average_cost: 0.0,
|
||||
realized_pnl_delta,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note: reason,
|
||||
});
|
||||
if instrument.delisted_at == Some(date) || instrument.delisted_at.is_none() {
|
||||
notes.push(reason.clone());
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
report
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4286,6 +4246,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScheduledTargetPortfolioSmartStrategy {
|
||||
rule: ScheduleRule,
|
||||
decision_date: NaiveDate,
|
||||
target_weights: BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
impl Strategy for ScheduledTargetPortfolioSmartStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"scheduled_target_portfolio_smart"
|
||||
}
|
||||
|
||||
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||
vec![self.rule.clone()]
|
||||
}
|
||||
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
rule: &ScheduleRule,
|
||||
) -> Result<StrategyDecision, super::BacktestError> {
|
||||
assert_eq!(rule.name, self.rule.name);
|
||||
if ctx.decision_date != self.decision_date {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::TargetPortfolioSmart {
|
||||
target_weights: self.target_weights.clone(),
|
||||
order_prices: None,
|
||||
valuation_prices: None,
|
||||
reason: "scheduled_target_portfolio_smart".to_string(),
|
||||
}],
|
||||
..StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScheduledTargetPercentStrategy {
|
||||
first_decision_date: NaiveDate,
|
||||
second_decision_date: NaiveDate,
|
||||
}
|
||||
|
||||
impl Strategy for ScheduledTargetPercentStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"scheduled_target_percent"
|
||||
}
|
||||
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, super::BacktestError> {
|
||||
let order_intents = if ctx.decision_date == self.first_decision_date {
|
||||
vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
quantity: 1_000,
|
||||
reason: "initial_position".to_string(),
|
||||
}]
|
||||
} else if ctx.decision_date == self.second_decision_date {
|
||||
vec![OrderIntent::TargetPercent {
|
||||
symbol: SYMBOL.to_string(),
|
||||
target_percent: 0.5,
|
||||
reason: "frozen_target_percent".to_string(),
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(StrategyDecision {
|
||||
order_intents,
|
||||
..StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScheduledEligibleUniverseBuyStrategy {
|
||||
rule: ScheduleRule,
|
||||
@@ -4921,6 +4955,94 @@ mod tests {
|
||||
assert_eq!(result.fills[0].price, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_target_portfolio_smart_sizes_with_execution_day_open() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let dataset = DataSet::from_components(
|
||||
vec![default_instrument()],
|
||||
vec![market(first, 10.0, 10.0), market(second, 12.0, 12.0)],
|
||||
vec![factor(first), factor(second)],
|
||||
vec![candidate(first), candidate(second)],
|
||||
vec![benchmark(first), benchmark(second)],
|
||||
)
|
||||
.expect("dataset");
|
||||
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default());
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 100_000.0,
|
||||
benchmark_code: "000852.SH".to_string(),
|
||||
start_date: Some(first),
|
||||
end_date: Some(second),
|
||||
decision_lag_trading_days: 1,
|
||||
execution_price_field: PriceField::Open,
|
||||
};
|
||||
let mut target_weights = BTreeMap::new();
|
||||
target_weights.insert(SYMBOL.to_string(), 1.0);
|
||||
|
||||
let result = BacktestEngine::new(
|
||||
dataset,
|
||||
ScheduledTargetPortfolioSmartStrategy {
|
||||
rule: ScheduleRule::daily("daily_target_portfolio", ScheduleStage::OnDay),
|
||||
decision_date: first,
|
||||
target_weights,
|
||||
},
|
||||
broker,
|
||||
config,
|
||||
)
|
||||
.run()
|
||||
.expect("backtest run");
|
||||
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].date, second);
|
||||
assert_eq!(result.fills[0].decision_date, Some(first));
|
||||
assert_eq!(result.fills[0].execution_date, Some(second));
|
||||
assert_eq!(result.fills[0].price, 12.0);
|
||||
assert_eq!(result.fills[0].quantity, 8_300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_target_percent_freezes_decision_day_equity() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let third = d(2025, 1, 6);
|
||||
let dataset = dataset_from_market_and_candidates(
|
||||
vec![
|
||||
market(first, 10.0, 10.0),
|
||||
market(second, 10.0, 10.0),
|
||||
market(third, 20.0, 20.0),
|
||||
],
|
||||
vec![candidate(first), candidate(second), candidate(third)],
|
||||
);
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 100_000.0,
|
||||
benchmark_code: "000852.SH".to_string(),
|
||||
start_date: Some(first),
|
||||
end_date: Some(third),
|
||||
decision_lag_trading_days: 1,
|
||||
execution_price_field: PriceField::Open,
|
||||
};
|
||||
|
||||
let result = BacktestEngine::new(
|
||||
dataset,
|
||||
ScheduledTargetPercentStrategy {
|
||||
first_decision_date: first,
|
||||
second_decision_date: second,
|
||||
},
|
||||
scheduled_next_open_broker(FidcRiskControlConfig::default()),
|
||||
config,
|
||||
)
|
||||
.run()
|
||||
.expect("backtest run");
|
||||
|
||||
assert_eq!(result.fills.len(), 2, "fills={:?}", result.fills);
|
||||
assert_eq!(result.fills[0].date, second);
|
||||
assert_eq!(result.fills[0].quantity, 1_000);
|
||||
assert_eq!(result.fills[1].date, third);
|
||||
assert_eq!(result.fills[1].price, 20.0);
|
||||
assert_eq!(result.fills[1].quantity, 1_400);
|
||||
assert_eq!(result.fills[1].decision_date, Some(second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() {
|
||||
let first = d(2025, 1, 2);
|
||||
|
||||
@@ -125,6 +125,7 @@ pub struct FillEvent {
|
||||
pub gross_amount: f64,
|
||||
pub commission: f64,
|
||||
pub stamp_tax: f64,
|
||||
pub transfer_fee: f64,
|
||||
pub net_cash_flow: f64,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@ impl FuturesAccountState {
|
||||
gross_amount: notional,
|
||||
commission: intent.transaction_cost.max(0.0),
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
net_cash_flow: cash_delta,
|
||||
reason: format!(
|
||||
"{} direction={} effect={}",
|
||||
|
||||
@@ -52,8 +52,9 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||
pub use platform_expr_strategy::{
|
||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
||||
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformSelectionQuotePlan,
|
||||
PlatformTradeAction, PlatformUniverseActionKind,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
||||
PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
||||
PlatformUniverseActionKind,
|
||||
};
|
||||
pub use platform_runtime_schema::{
|
||||
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
|
||||
@@ -66,8 +67,9 @@ pub use platform_strategy_spec::{
|
||||
StrategyExpressionActionConfig, StrategyExpressionAllocationConfig,
|
||||
StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig,
|
||||
StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig,
|
||||
StrategyExpressionTradingConfig, StrategyRuntimeEnvironment, StrategyRuntimeExpressions,
|
||||
StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value,
|
||||
StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig,
|
||||
StrategyRuntimeEnvironment, StrategyRuntimeExpressions, StrategyRuntimeSpec,
|
||||
platform_expr_config_from_spec, platform_expr_config_from_value,
|
||||
};
|
||||
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
||||
pub use risk_control::{
|
||||
|
||||
+156
-19
@@ -4,7 +4,7 @@ use chrono::{Datelike, NaiveDate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::engine::DailyEquityPoint;
|
||||
use crate::events::FillEvent;
|
||||
use crate::events::{AccountEvent, FillEvent};
|
||||
use crate::portfolio::HoldingSummary;
|
||||
|
||||
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
|
||||
@@ -47,6 +47,11 @@ pub struct BacktestMetrics {
|
||||
pub cash_balance: f64,
|
||||
pub unit_nav: f64,
|
||||
pub initial_cash: f64,
|
||||
/// Sum of external deposits (positive) and withdrawals (negative). This
|
||||
/// is reported separately so callers cannot mistake a cash transfer for
|
||||
/// trading performance.
|
||||
#[serde(default)]
|
||||
pub external_cash_flow_total: f64,
|
||||
pub excess_win_rate: f64,
|
||||
pub monthly_sharpe: f64,
|
||||
pub monthly_volatility: f64,
|
||||
@@ -56,6 +61,7 @@ pub fn compute_backtest_metrics(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
fills: &[FillEvent],
|
||||
daily_holdings: &[HoldingSummary],
|
||||
account_events: &[AccountEvent],
|
||||
initial_cash: f64,
|
||||
) -> BacktestMetrics {
|
||||
let Some(first_point) = equity_curve.first() else {
|
||||
@@ -81,12 +87,28 @@ pub fn compute_backtest_metrics(
|
||||
} else {
|
||||
first_point.benchmark_close
|
||||
};
|
||||
let mut returns = Vec::with_capacity(equity_curve.len());
|
||||
returns.push(pct_change(initial_cash, first_point.total_equity));
|
||||
returns.extend(
|
||||
let explicit_unit_nav = equity_curve.iter().any(|point| {
|
||||
point.external_cash_flow.abs() > f64::EPSILON
|
||||
|| (point.unit_nav.is_finite()
|
||||
&& point.unit_nav > 0.0
|
||||
&& (point.unit_nav - safe_div(point.total_equity, initial_cash, 1.0)).abs() > 1e-12)
|
||||
});
|
||||
let portfolio_nav = if explicit_unit_nav {
|
||||
equity_curve
|
||||
.iter()
|
||||
.map(|point| point_nav(point, initial_cash))
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
flow_neutral_nav_series(equity_curve, account_events, initial_cash)
|
||||
};
|
||||
let mut returns = Vec::with_capacity(portfolio_nav.len());
|
||||
if let Some(first_nav) = portfolio_nav.first().copied() {
|
||||
returns.push(pct_change(1.0, first_nav));
|
||||
}
|
||||
returns.extend(
|
||||
portfolio_nav
|
||||
.windows(2)
|
||||
.map(|window| pct_change(window[0].total_equity, window[1].total_equity)),
|
||||
.map(|window| pct_change(window[0], window[1])),
|
||||
);
|
||||
let mut benchmark_returns = Vec::with_capacity(equity_curve.len());
|
||||
benchmark_returns.push(pct_change(benchmark_start, first_point.benchmark_close));
|
||||
@@ -107,15 +129,12 @@ pub fn compute_backtest_metrics(
|
||||
last_point.benchmark_close / benchmark_start
|
||||
};
|
||||
let benchmark_cumulative_return = benchmark_net_value - 1.0;
|
||||
let total_return = if initial_cash.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(last_point.total_equity / initial_cash) - 1.0
|
||||
};
|
||||
let final_nav = portfolio_nav.last().copied().unwrap_or(1.0);
|
||||
let total_return = final_nav - 1.0;
|
||||
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
|
||||
total_return
|
||||
} else {
|
||||
(last_point.total_equity / initial_cash) / benchmark_net_value - 1.0
|
||||
portfolio_nav.last().copied().unwrap_or(0.0) / benchmark_net_value - 1.0
|
||||
};
|
||||
let excess_return = total_return - benchmark_cumulative_return;
|
||||
let annual_return = annualize_return(total_return, trade_days);
|
||||
@@ -132,10 +151,7 @@ pub fn compute_backtest_metrics(
|
||||
let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
||||
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
|
||||
|
||||
let equity_nav = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.total_equity, initial_cash, 1.0))
|
||||
.collect::<Vec<_>>();
|
||||
let equity_nav = portfolio_nav;
|
||||
let benchmark_nav_series = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0))
|
||||
@@ -154,8 +170,7 @@ pub fn compute_backtest_metrics(
|
||||
let win_rate = ratio(winning_days, returns.len());
|
||||
let excess_win_rate = ratio(excess_winning_days, excess_returns.len());
|
||||
|
||||
let monthly_portfolio_returns =
|
||||
group_monthly_returns(equity_curve, initial_cash, |point| point.total_equity);
|
||||
let monthly_portfolio_returns = group_monthly_returns_from_values(equity_curve, &equity_nav);
|
||||
let monthly_benchmark_returns =
|
||||
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
|
||||
let monthly_excess_returns = monthly_portfolio_returns
|
||||
@@ -257,14 +272,30 @@ pub fn compute_backtest_metrics(
|
||||
average_daily_turnover,
|
||||
total_assets: last_point.total_equity,
|
||||
cash_balance: last_point.cash,
|
||||
unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0),
|
||||
unit_nav: final_nav,
|
||||
initial_cash,
|
||||
external_cash_flow_total: if explicit_unit_nav {
|
||||
equity_curve
|
||||
.iter()
|
||||
.map(|point| point.external_cash_flow)
|
||||
.sum()
|
||||
} else {
|
||||
external_flow_total_from_events(account_events)
|
||||
},
|
||||
excess_win_rate,
|
||||
monthly_sharpe,
|
||||
monthly_volatility,
|
||||
}
|
||||
}
|
||||
|
||||
fn point_nav(point: &DailyEquityPoint, initial_cash: f64) -> f64 {
|
||||
if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
|
||||
point.unit_nav
|
||||
} else {
|
||||
safe_div(point.total_equity, initial_cash, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn pct_change(previous: f64, current: f64) -> f64 {
|
||||
if previous.abs() < f64::EPSILON {
|
||||
0.0
|
||||
@@ -384,6 +415,80 @@ fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
||||
(max_drawdown, max_duration)
|
||||
}
|
||||
|
||||
fn flow_neutral_nav_series(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
account_events: &[AccountEvent],
|
||||
initial_cash: f64,
|
||||
) -> Vec<f64> {
|
||||
let mut external_flow_by_date = BTreeMap::<NaiveDate, f64>::new();
|
||||
for event in account_events {
|
||||
if !(event.note.starts_with("deposit_withdraw amount=")
|
||||
|| event.note.starts_with("deposit_withdraw_settled amount="))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
*external_flow_by_date.entry(event.date).or_default() +=
|
||||
event.cash_after - event.cash_before;
|
||||
}
|
||||
|
||||
let mut units = initial_cash;
|
||||
let mut previous_equity = initial_cash;
|
||||
let mut navs = Vec::with_capacity(equity_curve.len());
|
||||
for point in equity_curve {
|
||||
let unit_nav_before_flow = safe_div(previous_equity, units, 1.0);
|
||||
let external_flow = external_flow_by_date
|
||||
.get(&point.date)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
if external_flow.abs() > f64::EPSILON && unit_nav_before_flow.is_finite() {
|
||||
units += external_flow / unit_nav_before_flow;
|
||||
}
|
||||
let unit_nav = safe_div(point.total_equity, units, 0.0);
|
||||
navs.push(unit_nav);
|
||||
previous_equity = point.total_equity;
|
||||
}
|
||||
navs
|
||||
}
|
||||
|
||||
fn external_flow_total_from_events(account_events: &[AccountEvent]) -> f64 {
|
||||
account_events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.note.starts_with("deposit_withdraw amount=")
|
||||
|| event.note.starts_with("deposit_withdraw_settled amount=")
|
||||
})
|
||||
.map(|event| event.cash_after - event.cash_before)
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn group_monthly_returns_from_values(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
values: &[f64],
|
||||
) -> Vec<f64> {
|
||||
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
|
||||
let mut month_first = BTreeMap::<(i32, u32), f64>::new();
|
||||
let mut previous_value = 1.0;
|
||||
for (point, value) in equity_curve.iter().zip(values.iter().copied()) {
|
||||
let key = (point.date.year(), point.date.month());
|
||||
month_first.entry(key).or_insert(previous_value);
|
||||
month_last.insert(key, value);
|
||||
previous_value = value;
|
||||
}
|
||||
let mut keys = month_last.keys().copied().collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
keys.into_iter()
|
||||
.filter_map(|key| {
|
||||
let first = month_first.get(&key).copied().unwrap_or_default();
|
||||
let last = month_last.get(&key).copied().unwrap_or_default();
|
||||
if first.abs() < f64::EPSILON {
|
||||
None
|
||||
} else {
|
||||
Some((last / first) - 1.0)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn group_monthly_returns<F>(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
initial_value: f64,
|
||||
@@ -486,6 +591,8 @@ mod tests {
|
||||
cash: total_equity,
|
||||
market_value: 0.0,
|
||||
total_equity,
|
||||
external_cash_flow: 0.0,
|
||||
unit_nav: total_equity / 100.0,
|
||||
benchmark_close,
|
||||
benchmark_prev_close,
|
||||
notes: String::new(),
|
||||
@@ -499,8 +606,38 @@ mod tests {
|
||||
equity_point("2025-01-02", 100.0, 5797.089, 5957.717),
|
||||
equity_point("2025-12-31", 120.0, 7595.285, 7597.299),
|
||||
];
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], 100.0);
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0);
|
||||
let expected = 7595.285 / 5957.717 - 1.0;
|
||||
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
|
||||
let curve = vec![
|
||||
equity_point("2025-01-02", 100.0, 100.0, 100.0),
|
||||
DailyEquityPoint {
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||
cash: 220.0,
|
||||
market_value: 0.0,
|
||||
total_equity: 220.0,
|
||||
external_cash_flow: 100.0,
|
||||
unit_nav: 1.1,
|
||||
benchmark_close: 100.0,
|
||||
benchmark_prev_close: 100.0,
|
||||
notes: String::new(),
|
||||
diagnostics: String::new(),
|
||||
},
|
||||
];
|
||||
let events = vec![AccountEvent {
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||
cash_before: 100.0,
|
||||
cash_after: 200.0,
|
||||
total_equity: 200.0,
|
||||
note: "deposit_withdraw amount=100.00 reason=test".to_string(),
|
||||
}];
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0);
|
||||
assert!((metrics.total_return - 0.1).abs() < 1e-12);
|
||||
assert!((metrics.unit_nav - 1.1).abs() < 1e-12);
|
||||
assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,7 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
|
||||
// day-level
|
||||
"signal_close",
|
||||
"benchmark_close",
|
||||
"benchmark_signal_close",
|
||||
"signal_ma5",
|
||||
"signal_ma10",
|
||||
"signal_ma20",
|
||||
@@ -227,6 +228,8 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
|
||||
"day_factor",
|
||||
"rolling_mean",
|
||||
"rolling_mean_current",
|
||||
"rolling_max_current",
|
||||
"rolling_return_stddev_current",
|
||||
"ma",
|
||||
"sma",
|
||||
"vma",
|
||||
@@ -326,6 +329,7 @@ mod tests {
|
||||
for required in [
|
||||
"signal_close",
|
||||
"benchmark_close",
|
||||
"benchmark_signal_close",
|
||||
"close",
|
||||
"avg_cost",
|
||||
"current_price",
|
||||
|
||||
@@ -7,8 +7,9 @@ use serde_json::Value;
|
||||
use crate::{
|
||||
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
|
||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
|
||||
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
||||
PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind,
|
||||
RebalanceCashMode, ScheduleTimeRule, SlippageModel,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -66,6 +67,8 @@ pub struct StrategyRebalanceSpec {
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyExecutionSpec {
|
||||
#[serde(default)]
|
||||
pub frequency: Option<String>,
|
||||
#[serde(default, alias = "matching_type")]
|
||||
pub matching_type: Option<String>,
|
||||
#[serde(default, alias = "slippage_model")]
|
||||
@@ -87,6 +90,8 @@ pub struct StrategyExecutionSpec {
|
||||
alias = "minCommission"
|
||||
)]
|
||||
pub minimum_commission: Option<f64>,
|
||||
#[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")]
|
||||
pub transfer_fee_rate: Option<f64>,
|
||||
#[serde(default, alias = "stamp_tax_rate")]
|
||||
pub stamp_tax_rate: Option<f64>,
|
||||
#[serde(default, alias = "stamp_tax_rate_before_change")]
|
||||
@@ -114,12 +119,10 @@ pub struct StrategyExecutionSpec {
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyEngineConfig {
|
||||
#[serde(default)]
|
||||
pub frequency: Option<String>,
|
||||
#[serde(default)]
|
||||
pub template_id: Option<String>,
|
||||
#[serde(default, alias = "profile_name")]
|
||||
pub profile_name: Option<String>,
|
||||
#[serde(default, alias = "compatibility_profile")]
|
||||
pub compatibility_profile: Option<String>,
|
||||
#[serde(default, alias = "benchmark_symbol")]
|
||||
pub benchmark_symbol: Option<String>,
|
||||
#[serde(default, alias = "signal_symbol")]
|
||||
@@ -161,6 +164,8 @@ pub struct StrategyEngineConfig {
|
||||
alias = "minCommission"
|
||||
)]
|
||||
pub minimum_commission: Option<f64>,
|
||||
#[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")]
|
||||
pub transfer_fee_rate: Option<f64>,
|
||||
#[serde(default, alias = "stamp_tax_rate")]
|
||||
pub stamp_tax_rate: Option<f64>,
|
||||
#[serde(default, alias = "stamp_tax_rate_before_change")]
|
||||
@@ -281,6 +286,8 @@ pub struct StrategyRiskPolicySpec {
|
||||
alias = "minCommission"
|
||||
)]
|
||||
pub minimum_commission: Option<f64>,
|
||||
#[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")]
|
||||
pub transfer_fee_rate: Option<f64>,
|
||||
#[serde(default, alias = "stamp_tax_rate")]
|
||||
pub stamp_tax_rate: Option<f64>,
|
||||
#[serde(default, alias = "stamp_tax_rate_before_change")]
|
||||
@@ -342,6 +349,7 @@ const RISK_POLICY_VALUE_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||
"minimumCommission",
|
||||
&["minimum_commission", "min_commission", "minCommission"],
|
||||
),
|
||||
("transferFeeRate", &["transfer_fee_rate"]),
|
||||
("stampTaxRate", &["stamp_tax_rate"]),
|
||||
(
|
||||
"stampTaxRateBeforeChange",
|
||||
@@ -639,6 +647,8 @@ pub struct StrategyExpressionSelectionConfig {
|
||||
#[serde(default)]
|
||||
pub limit_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub candidate_limit_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub market_cap_field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub market_cap_lower_expr: Option<String>,
|
||||
@@ -661,9 +671,32 @@ pub struct StrategyExpressionRiskConfig {
|
||||
#[serde(default)]
|
||||
pub exposure_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
||||
#[serde(default)]
|
||||
pub stop_loss_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub take_profit_expr: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "referencePriceMode",
|
||||
alias = "stop_take_reference_price_mode"
|
||||
)]
|
||||
pub stop_take_reference_price_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyPortfolioDrawdownControlConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub drawdown_trigger: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub floor_exposure: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub cooldown_trading_days: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -691,6 +724,18 @@ pub struct StrategyExpressionTradingConfig {
|
||||
#[serde(default)]
|
||||
pub daily_top_up: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub daily_position_target_adjust: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub target_portfolio_daily: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub rebalance_existing_positions: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub hold_until_exit: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub selection_buffer_multiple: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub daily_replacement_limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub retry_empty_rebalance: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub weak_market_shrink_overweight_threshold: Option<f64>,
|
||||
@@ -773,6 +818,7 @@ pub fn platform_expr_config_from_value(
|
||||
return platform_expr_config_from_spec(strategy_id, signal_symbol, None)
|
||||
.map_err(platform_config_error);
|
||||
}
|
||||
reject_removed_compatibility_fields(value).map_err(platform_config_error)?;
|
||||
let mut value = value.clone();
|
||||
normalize_risk_policy_aliases_in_value(&mut value).map_err(platform_config_error)?;
|
||||
let spec = serde_json::from_value::<StrategyRuntimeSpec>(value)?;
|
||||
@@ -780,6 +826,40 @@ pub fn platform_expr_config_from_value(
|
||||
.map_err(platform_config_error)
|
||||
}
|
||||
|
||||
fn reject_removed_compatibility_fields(value: &Value) -> Result<(), String> {
|
||||
const SECTION_NAMES: [&str; 3] = ["engineConfig", "engine_config", "execution"];
|
||||
const FIELD_NAMES: [&str; 4] = [
|
||||
"compatibilityProfile",
|
||||
"compatibility_profile",
|
||||
"compatProfile",
|
||||
"compat_profile",
|
||||
];
|
||||
for section_name in SECTION_NAMES {
|
||||
let Some(section) = value.get(section_name).and_then(Value::as_object) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(field_name) = FIELD_NAMES
|
||||
.iter()
|
||||
.find(|field_name| section.contains_key(**field_name))
|
||||
{
|
||||
return Err(format!(
|
||||
"{section_name}.{field_name} has been removed; configure matching, risk, fees and scheduling explicitly"
|
||||
));
|
||||
}
|
||||
if section_name != "execution" {
|
||||
if let Some(field_name) = ["profileName", "profile_name"]
|
||||
.iter()
|
||||
.find(|field_name| section.contains_key(**field_name))
|
||||
{
|
||||
return Err(format!(
|
||||
"{section_name}.{field_name} has been removed; configure matching, risk, fees and scheduling explicitly"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn platform_config_error(message: String) -> serde_json::Error {
|
||||
serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
@@ -811,16 +891,29 @@ fn parse_policy_date(value: Option<&str>) -> Option<NaiveDate> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn is_aiquant_profile(value: Option<&str>) -> bool {
|
||||
value
|
||||
.map(|item| item.trim().to_ascii_lowercase().replace('-', "_"))
|
||||
.is_some_and(|item| item == "aiquant" || item == "aiquant_rqalpha" || item == "rqalpha")
|
||||
fn parse_stop_take_reference_price_mode(
|
||||
value: &str,
|
||||
) -> Result<PlatformStopTakeReferencePriceMode, String> {
|
||||
match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
"position_cost_basis" | "position_cost" | "execution_cost_basis" => {
|
||||
Ok(PlatformStopTakeReferencePriceMode::PositionCostBasis)
|
||||
}
|
||||
"signal_day_post_adjusted_close"
|
||||
| "signal_post_adjusted_close"
|
||||
| "model_signal_post_adjusted_close" => {
|
||||
Ok(PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose)
|
||||
}
|
||||
other => Err(format!(
|
||||
"runtimeExpressions.risk.stopTakeReferencePriceMode unsupported: {other}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_cost_overrides(
|
||||
cfg: &mut PlatformExprStrategyConfig,
|
||||
commission_rate: Option<f64>,
|
||||
minimum_commission: Option<f64>,
|
||||
transfer_fee_rate: Option<f64>,
|
||||
stamp_tax_rate: Option<f64>,
|
||||
stamp_tax_rate_before_change: Option<f64>,
|
||||
stamp_tax_rate_after_change: Option<f64>,
|
||||
@@ -834,6 +927,9 @@ fn apply_cost_overrides(
|
||||
cfg.minimum_commission = Some(value);
|
||||
cfg.risk_config.trading_constraints.minimum_commission = value;
|
||||
}
|
||||
if let Some(value) = valid_non_negative(transfer_fee_rate) {
|
||||
cfg.risk_config.trading_constraints.transfer_fee_rate = value;
|
||||
}
|
||||
if let Some(value) = valid_non_negative(stamp_tax_rate) {
|
||||
cfg.stamp_tax_rate_before_change = Some(value);
|
||||
cfg.stamp_tax_rate_after_change = Some(value);
|
||||
@@ -989,6 +1085,7 @@ fn apply_risk_policy_overrides(
|
||||
cfg,
|
||||
policy.commission_rate,
|
||||
policy.minimum_commission,
|
||||
policy.transfer_fee_rate,
|
||||
policy.stamp_tax_rate,
|
||||
policy.stamp_tax_rate_before_change,
|
||||
policy.stamp_tax_rate_after_change,
|
||||
@@ -1001,6 +1098,22 @@ fn normalize_model_name(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase().replace('-', "_")
|
||||
}
|
||||
|
||||
fn normalize_slippage_model_name(value: &str) -> String {
|
||||
match normalize_model_name(value).as_str() {
|
||||
"percent"
|
||||
| "percentage"
|
||||
| "rate"
|
||||
| "ratio"
|
||||
| "price_percent"
|
||||
| "price_percentage"
|
||||
| "price_rate"
|
||||
| "price_ratio_slippage"
|
||||
| "priceratioslippage" => "price_ratio".to_string(),
|
||||
"dynamic_volume_volatility" => "dynamic".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_matching_type(value: Option<&str>) -> Result<Option<MatchingType>, String> {
|
||||
let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else {
|
||||
return Ok(None);
|
||||
@@ -1047,7 +1160,7 @@ fn parse_slippage_model(
|
||||
let volatility_coefficient = valid_non_negative(volatility_coefficient);
|
||||
let max_value = valid_non_negative(max_value);
|
||||
let model = model
|
||||
.map(normalize_model_name)
|
||||
.map(normalize_slippage_model_name)
|
||||
.filter(|item| !item.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
if value.is_some_and(|item| item > 0.0) {
|
||||
@@ -1062,13 +1175,11 @@ fn parse_slippage_model(
|
||||
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
|
||||
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))),
|
||||
"limit_price" => Some(SlippageModel::LimitPrice),
|
||||
"dynamic" | "dynamic_volume_volatility" => {
|
||||
Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
|
||||
impact_coefficient.unwrap_or(0.5),
|
||||
volatility_coefficient.unwrap_or(0.3),
|
||||
max_value.or(value).unwrap_or(0.01),
|
||||
)))
|
||||
}
|
||||
"dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
|
||||
impact_coefficient.unwrap_or(0.5),
|
||||
volatility_coefficient.unwrap_or(0.3),
|
||||
max_value.or(value).unwrap_or(0.01),
|
||||
))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1110,9 +1221,10 @@ fn apply_execution_behavior_overrides(
|
||||
cfg.slippage_model = parsed;
|
||||
}
|
||||
}
|
||||
if let Some(enabled) = strict_value_budget {
|
||||
cfg.strict_value_budget = enabled;
|
||||
if strict_value_budget == Some(false) {
|
||||
return Err("strictValueBudget=false is not supported".to_string());
|
||||
}
|
||||
cfg.strict_value_budget = true;
|
||||
if let Some(rate) = sell_then_buy_delay_slippage_rate {
|
||||
if !rate.is_finite() || !(0.0..1.0).contains(&rate) {
|
||||
return Err(
|
||||
@@ -1395,6 +1507,7 @@ pub fn platform_expr_config_from_spec(
|
||||
&mut cfg,
|
||||
engine.commission_rate,
|
||||
engine.minimum_commission,
|
||||
engine.transfer_fee_rate,
|
||||
engine.stamp_tax_rate,
|
||||
engine.stamp_tax_rate_before_change,
|
||||
engine.stamp_tax_rate_after_change,
|
||||
@@ -1508,6 +1621,13 @@ pub fn platform_expr_config_from_spec(
|
||||
{
|
||||
cfg.selection_limit_expr = expr.clone();
|
||||
}
|
||||
if let Some(expr) = selection
|
||||
.candidate_limit_expr
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
cfg.selection_candidate_limit_expr = expr.clone();
|
||||
}
|
||||
if let Some(field) = selection
|
||||
.market_cap_field
|
||||
.as_ref()
|
||||
@@ -1562,6 +1682,43 @@ pub fn platform_expr_config_from_spec(
|
||||
expr.clone()
|
||||
};
|
||||
}
|
||||
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
|
||||
&& control.enabled.unwrap_or(true)
|
||||
{
|
||||
let drawdown_trigger = control
|
||||
.drawdown_trigger
|
||||
.filter(|value| value.is_finite() && *value > 0.0 && *value < 1.0)
|
||||
.ok_or_else(|| {
|
||||
"runtimeExpressions.risk.portfolioDrawdownControl.drawdownTrigger must be between 0 and 1"
|
||||
.to_string()
|
||||
})?;
|
||||
let floor_exposure = control
|
||||
.floor_exposure
|
||||
.filter(|value| value.is_finite() && (0.0..=1.0).contains(value))
|
||||
.ok_or_else(|| {
|
||||
"runtimeExpressions.risk.portfolioDrawdownControl.floorExposure must be between 0 and 1"
|
||||
.to_string()
|
||||
})?;
|
||||
let cooldown_trading_days = control
|
||||
.cooldown_trading_days
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or_else(|| {
|
||||
"runtimeExpressions.risk.portfolioDrawdownControl.cooldownTradingDays must be positive"
|
||||
.to_string()
|
||||
})?;
|
||||
cfg.portfolio_drawdown_control = Some(PlatformPortfolioDrawdownControlConfig {
|
||||
mode: control
|
||||
.mode
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("portfolio_drawdown")
|
||||
.to_string(),
|
||||
drawdown_trigger,
|
||||
floor_exposure,
|
||||
cooldown_trading_days,
|
||||
});
|
||||
}
|
||||
if let Some(expr) = risk
|
||||
.stop_loss_expr
|
||||
.as_ref()
|
||||
@@ -1576,6 +1733,14 @@ pub fn platform_expr_config_from_spec(
|
||||
{
|
||||
cfg.take_profit_expr = expr.clone();
|
||||
}
|
||||
if let Some(mode) = risk
|
||||
.stop_take_reference_price_mode
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
cfg.stop_take_reference_price_mode = parse_stop_take_reference_price_mode(mode)?;
|
||||
}
|
||||
}
|
||||
if let Some(ordering) = runtime_expr.ordering.as_ref() {
|
||||
if let Some(rank_by) = ordering
|
||||
@@ -1614,6 +1779,30 @@ pub fn platform_expr_config_from_spec(
|
||||
if let Some(enabled) = trading.daily_top_up {
|
||||
cfg.daily_top_up_enabled = enabled;
|
||||
}
|
||||
if let Some(enabled) = trading.daily_position_target_adjust {
|
||||
cfg.daily_position_target_adjust_enabled = enabled;
|
||||
}
|
||||
if let Some(enabled) = trading.target_portfolio_daily {
|
||||
cfg.target_portfolio_daily_enabled = enabled;
|
||||
if enabled {
|
||||
cfg.daily_position_target_adjust_enabled = true;
|
||||
}
|
||||
}
|
||||
if let Some(enabled) = trading.rebalance_existing_positions {
|
||||
cfg.rebalance_existing_positions = enabled;
|
||||
}
|
||||
if let Some(enabled) = trading.hold_until_exit {
|
||||
cfg.hold_until_exit_enabled = enabled;
|
||||
}
|
||||
if let Some(multiple) = trading
|
||||
.selection_buffer_multiple
|
||||
.filter(|value| value.is_finite() && *value >= 1.0)
|
||||
{
|
||||
cfg.selection_buffer_multiple = multiple;
|
||||
}
|
||||
if let Some(limit) = trading.daily_replacement_limit {
|
||||
cfg.daily_replacement_limit = limit;
|
||||
}
|
||||
if let Some(enabled) = trading.retry_empty_rebalance {
|
||||
cfg.retry_empty_rebalance = enabled;
|
||||
}
|
||||
@@ -1770,57 +1959,16 @@ pub fn platform_expr_config_from_spec(
|
||||
if !cfg.benchmark_symbol.trim().is_empty() {
|
||||
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
|
||||
}
|
||||
let aiquant_profile = spec.engine_config.as_ref().is_some_and(|engine| {
|
||||
is_aiquant_profile(engine.profile_name.as_deref())
|
||||
|| is_aiquant_profile(engine.compatibility_profile.as_deref())
|
||||
});
|
||||
if aiquant_profile {
|
||||
cfg.aiquant_transaction_cost = true;
|
||||
cfg.strict_value_budget = true;
|
||||
if !cfg.universe_exclude.iter().any(|item| item == "bjse") {
|
||||
cfg.universe_exclude.push("bjse".to_string());
|
||||
}
|
||||
let trading = spec
|
||||
.runtime_expressions
|
||||
.as_ref()
|
||||
.and_then(|runtime_expr| runtime_expr.trading.as_ref());
|
||||
if trading.and_then(|item| item.daily_top_up).is_none() {
|
||||
cfg.daily_top_up_enabled = true;
|
||||
}
|
||||
if trading
|
||||
.and_then(|item| item.retry_empty_rebalance)
|
||||
.is_none()
|
||||
{
|
||||
cfg.retry_empty_rebalance = true;
|
||||
}
|
||||
}
|
||||
let trade_times = spec_trade_times(spec);
|
||||
if let Some(main_trade_time) = trade_times.last().copied() {
|
||||
cfg.intraday_execution_time = Some(main_trade_time);
|
||||
}
|
||||
let delayed_limit_open_exit_explicit = spec
|
||||
.runtime_expressions
|
||||
.as_ref()
|
||||
.and_then(|runtime_expr| runtime_expr.trading.as_ref())
|
||||
.and_then(|trading| trading.delayed_limit_open_exit)
|
||||
.is_some();
|
||||
if aiquant_profile && !delayed_limit_open_exit_explicit && trade_times.len() > 1 {
|
||||
let delayed_time = trade_times[0];
|
||||
if trade_times
|
||||
.last()
|
||||
.copied()
|
||||
.map(|main_time| main_time != delayed_time)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
cfg.delayed_limit_open_exit_enabled = true;
|
||||
cfg.delayed_limit_open_exit_time = Some(delayed_time);
|
||||
}
|
||||
}
|
||||
if let Some(execution) = spec.execution.as_ref() {
|
||||
apply_cost_overrides(
|
||||
&mut cfg,
|
||||
execution.commission_rate,
|
||||
execution.minimum_commission,
|
||||
execution.transfer_fee_rate,
|
||||
execution.stamp_tax_rate,
|
||||
execution.stamp_tax_rate_before_change,
|
||||
execution.stamp_tax_rate_after_change,
|
||||
@@ -1847,16 +1995,7 @@ pub fn platform_expr_config_from_spec(
|
||||
)?;
|
||||
sync_quote_quantity_limit(&mut cfg);
|
||||
}
|
||||
if cfg.aiquant_transaction_cost
|
||||
&& cfg
|
||||
.minimum_commission
|
||||
.is_some_and(|value| value.is_finite() && value <= 0.0)
|
||||
{
|
||||
cfg.minimum_commission = None;
|
||||
}
|
||||
if aiquant_profile {
|
||||
cfg.strict_value_budget = true;
|
||||
}
|
||||
cfg.strict_value_budget = true;
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
@@ -1891,7 +2030,15 @@ fn signal_rebalance_dates(rebalance: &StrategyRebalanceSpec) -> Option<BTreeSet<
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if frequency != "signal_dates" && frequency != "signal-dates" && frequency != "signal dates" {
|
||||
if !matches!(
|
||||
frequency.as_str(),
|
||||
"signal_dates"
|
||||
| "signal-dates"
|
||||
| "signal dates"
|
||||
| "daily_model_score_rank"
|
||||
| "dynamic_model_score_rank"
|
||||
| "model_rank_rotation"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let dates = rebalance
|
||||
@@ -2298,7 +2445,6 @@ mod tests {
|
||||
"signalSymbol": "000852.SH",
|
||||
"benchmark": { "instrumentId": "000852.SH" },
|
||||
"universe": { "exclude": ["paused", "st", "kcb", "one_yuan"] },
|
||||
"engineConfig": { "profileName": "aiquant" },
|
||||
"runtimeExpressions": {
|
||||
"prelude": "let stocknum = 8;",
|
||||
"selection": {
|
||||
@@ -2332,16 +2478,12 @@ mod tests {
|
||||
assert_eq!(cfg.signal_symbol, "000852.SH");
|
||||
assert_eq!(cfg.selection_limit_expr, "stocknum");
|
||||
assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20");
|
||||
assert_eq!(
|
||||
cfg.universe_exclude,
|
||||
["paused", "st", "kcb", "one_yuan", "bjse"]
|
||||
);
|
||||
assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
|
||||
assert!(!cfg.rotation_enabled);
|
||||
assert!(cfg.daily_top_up_enabled);
|
||||
assert!(cfg.retry_empty_rebalance);
|
||||
assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.1));
|
||||
assert!(!cfg.calendar_rebalance_interval);
|
||||
assert!(cfg.aiquant_transaction_cost);
|
||||
assert_eq!(cfg.explicit_actions.len(), 1);
|
||||
assert_eq!(
|
||||
cfg.explicit_action_stage,
|
||||
@@ -2380,6 +2522,35 @@ mod tests {
|
||||
assert_eq!(cfg.max_holding_days, Some(90));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_lifecycle_target_portfolio_contract() {
|
||||
let spec = serde_json::json!({
|
||||
"runtimeExpressions": {
|
||||
"selection": {
|
||||
"limitExpr": "30",
|
||||
"candidateLimitExpr": "50"
|
||||
},
|
||||
"trading": {
|
||||
"dailyPositionTargetAdjust": true,
|
||||
"targetPortfolioDaily": true,
|
||||
"rebalanceExistingPositions": true,
|
||||
"holdUntilExit": true,
|
||||
"releaseSlotOnExitSignal": true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert_eq!(cfg.selection_limit_expr, "30");
|
||||
assert_eq!(cfg.selection_candidate_limit_expr, "50");
|
||||
assert!(cfg.daily_position_target_adjust_enabled);
|
||||
assert!(cfg.target_portfolio_daily_enabled);
|
||||
assert!(cfg.rebalance_existing_positions);
|
||||
assert!(cfg.hold_until_exit_enabled);
|
||||
assert!(cfg.release_slot_on_exit_signal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_signal_dates_rebalance_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -2402,6 +2573,26 @@ mod tests {
|
||||
assert_eq!(cfg.signal_rebalance_dates.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_dynamic_model_score_dates_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
"rebalance": {
|
||||
"frequency": "daily_model_score_rank",
|
||||
"dates": ["2025-11-10", "2025-11-17"]
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert_eq!(
|
||||
cfg.signal_rebalance_dates,
|
||||
BTreeSet::from([
|
||||
NaiveDate::from_ymd_opt(2025, 11, 10).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2025, 11, 17).unwrap(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_execution_cost_overrides_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -2413,14 +2604,12 @@ mod tests {
|
||||
"stampTaxChangeDate": "2024-01-02"
|
||||
},
|
||||
"engineConfig": {
|
||||
"profileName": "aiquant",
|
||||
"commissionRate": 0.0008
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert!(cfg.aiquant_transaction_cost);
|
||||
assert_eq!(cfg.commission_rate, Some(0.0003));
|
||||
assert_eq!(cfg.minimum_commission, Some(5.0));
|
||||
assert_eq!(cfg.stamp_tax_rate_before_change, Some(0.0005));
|
||||
@@ -2703,11 +2892,10 @@ mod tests {
|
||||
"strictValueBudget": true
|
||||
},
|
||||
"engineConfig": {
|
||||
"profileName": "aiquant",
|
||||
"matchingType": "current_bar_close",
|
||||
"slippageModel": "none",
|
||||
"slippageValue": 0.0,
|
||||
"strictValueBudget": false
|
||||
"strictValueBudget": true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2718,6 +2906,20 @@ mod tests {
|
||||
assert!(cfg.strict_value_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_percent_slippage_alias_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
"execution": {
|
||||
"slippageModel": "percent",
|
||||
"slippageValue": 0.001
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert_eq!(cfg.slippage_model, SlippageModel::PriceRatio(0.001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rebalance_cash_mode_and_forces_minute_to_actual_sequence() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -2867,74 +3069,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiquant_profile_defaults_to_daily_top_up_and_empty_retry() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"profileName": "aiquant"
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert!(cfg.aiquant_transaction_cost);
|
||||
assert!(cfg.daily_top_up_enabled);
|
||||
assert!(cfg.retry_empty_rebalance);
|
||||
assert!(cfg.strict_value_budget);
|
||||
|
||||
let explicit_off = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"profileName": "aiquant"
|
||||
},
|
||||
"runtimeExpressions": {
|
||||
"trading": {
|
||||
"dailyTopUp": false,
|
||||
"retryEmptyRebalance": false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &explicit_off).expect("config");
|
||||
|
||||
assert!(!cfg.daily_top_up_enabled);
|
||||
assert!(!cfg.retry_empty_rebalance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_config_profile_name_enables_aiquant_semantics() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"profileName": "aiquant"
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert!(cfg.aiquant_transaction_cost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_config_profile_name_accepts_aiquant_rqalpha_alias() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"profileName": "aiquant_rqalpha"
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert!(cfg.aiquant_transaction_cost);
|
||||
assert!(cfg.daily_top_up_enabled);
|
||||
assert!(cfg.retry_empty_rebalance);
|
||||
assert!(cfg.strict_value_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_expressions_infer_ma_windows_from_literal_strategy_logic() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"profileName": "aiquant"
|
||||
},
|
||||
"runtimeExpressions": {
|
||||
"selection": {
|
||||
"stockFilterExpr": "rolling_mean(\"close\", 5) > rolling_mean(\"close\", 10) && rolling_mean(\"close\", 10) > rolling_mean(\"close\", 30)"
|
||||
@@ -2992,7 +3129,6 @@ mod tests {
|
||||
"instrumentId": "932000.CSI"
|
||||
},
|
||||
"engineConfig": {
|
||||
"profileName": "aiquant",
|
||||
"indexThrottle": {
|
||||
"shortDays": 10,
|
||||
"longDays": 30,
|
||||
@@ -3019,9 +3155,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_daily_schedule_time_for_aiquant_execution_quotes() {
|
||||
fn parses_daily_schedule_time_for_execution_quotes() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": { "profileName": "aiquant" },
|
||||
"runtimeExpressions": {
|
||||
"schedule": { "frequency": "daily", "time": "09:33" }
|
||||
}
|
||||
@@ -3035,13 +3170,11 @@ mod tests {
|
||||
Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap())
|
||||
);
|
||||
assert!(!cfg.calendar_rebalance_interval);
|
||||
assert!(cfg.aiquant_transaction_cost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_aiquant_rebalance_trade_times_for_delayed_limit_exit() {
|
||||
fn multiple_trade_times_do_not_imply_delayed_limit_exit() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": { "profileName": "aiquant" },
|
||||
"rebalance": { "tradeTimes": ["10:31", "10:40"] },
|
||||
"runtimeExpressions": {
|
||||
"schedule": { "frequency": "daily", "time": "10:40" }
|
||||
@@ -3054,18 +3187,14 @@ mod tests {
|
||||
cfg.intraday_execution_time,
|
||||
Some(NaiveTime::from_hms_opt(10, 40, 0).unwrap())
|
||||
);
|
||||
assert!(cfg.delayed_limit_open_exit_enabled);
|
||||
assert_eq!(
|
||||
cfg.delayed_limit_open_exit_time,
|
||||
Some(NaiveTime::from_hms_opt(10, 31, 0).unwrap())
|
||||
);
|
||||
assert!(!cfg.delayed_limit_open_exit_enabled);
|
||||
assert_eq!(cfg.delayed_limit_open_exit_time, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_aiquant_compatibility_profile_for_delayed_limit_exit() {
|
||||
fn rejects_removed_compatibility_profile() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"profileName": "cn_a_microcap_v1",
|
||||
"compatibilityProfile": "aiquant_rqalpha"
|
||||
},
|
||||
"rebalance": { "tradeTimes": ["09:31", "10:15"] },
|
||||
@@ -3074,23 +3203,29 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
let error = platform_expr_config_from_value("", "", &spec).expect_err("removed field");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("compatibilityProfile has been removed")
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
cfg.intraday_execution_time,
|
||||
Some(NaiveTime::from_hms_opt(10, 15, 0).unwrap())
|
||||
);
|
||||
assert!(cfg.delayed_limit_open_exit_enabled);
|
||||
assert_eq!(
|
||||
cfg.delayed_limit_open_exit_time,
|
||||
Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap())
|
||||
);
|
||||
#[test]
|
||||
fn rejects_removed_engine_profile_name() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": {
|
||||
"profileName": "aiquant"
|
||||
}
|
||||
});
|
||||
|
||||
let error = platform_expr_config_from_value("", "", &spec).expect_err("removed field");
|
||||
assert!(error.to_string().contains("profileName has been removed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_explicit_delayed_limit_open_exit() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": { "profileName": "aiquant" },
|
||||
"runtimeExpressions": {
|
||||
"schedule": { "frequency": "daily", "time": "10:40" },
|
||||
"trading": {
|
||||
@@ -3114,9 +3249,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_delayed_limit_open_exit_false_overrides_aiquant_trade_times() {
|
||||
fn explicit_delayed_limit_open_exit_false_overrides_trade_times() {
|
||||
let spec = serde_json::json!({
|
||||
"engineConfig": { "profileName": "aiquant" },
|
||||
"rebalance": { "tradeTimes": ["10:31", "10:40"] },
|
||||
"runtimeExpressions": {
|
||||
"schedule": { "frequency": "daily", "time": "10:40" },
|
||||
@@ -3132,4 +3266,82 @@ mod tests {
|
||||
assert!(!cfg.delayed_limit_open_exit_enabled);
|
||||
assert_eq!(cfg.delayed_limit_open_exit_time, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_portfolio_drawdown_control_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
"runtimeExpressions": {
|
||||
"risk": {
|
||||
"exposureExpr": "1.0",
|
||||
"portfolioDrawdownControl": {
|
||||
"enabled": true,
|
||||
"mode": "portfolio_dd3_floor10_cool30",
|
||||
"drawdownTrigger": 0.03,
|
||||
"floorExposure": 0.10,
|
||||
"cooldownTradingDays": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
let control = cfg.portfolio_drawdown_control.expect("drawdown control");
|
||||
assert_eq!(control.mode, "portfolio_dd3_floor10_cool30");
|
||||
assert_eq!(control.drawdown_trigger, 0.03);
|
||||
assert_eq!(control.floor_exposure, 0.10);
|
||||
assert_eq!(control.cooldown_trading_days, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_portfolio_drawdown_control() {
|
||||
let spec = serde_json::json!({
|
||||
"runtimeExpressions": {
|
||||
"risk": {
|
||||
"portfolioDrawdownControl": {
|
||||
"drawdownTrigger": 0.0,
|
||||
"floorExposure": 0.10,
|
||||
"cooldownTradingDays": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let error = platform_expr_config_from_value("", "", &spec).expect_err("invalid trigger");
|
||||
assert!(error.to_string().contains("drawdownTrigger"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_signal_day_post_adjusted_stop_take_reference_price_mode() {
|
||||
let spec = serde_json::json!({
|
||||
"runtimeExpressions": {
|
||||
"risk": {
|
||||
"stopLossExpr": "0.92",
|
||||
"takeProfitExpr": "1.16",
|
||||
"stopTakeReferencePriceMode": "signal_day_post_adjusted_close"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert_eq!(
|
||||
cfg.stop_take_reference_price_mode,
|
||||
PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_stop_take_reference_price_mode() {
|
||||
let spec = serde_json::json!({
|
||||
"runtimeExpressions": {
|
||||
"risk": {
|
||||
"stopTakeReferencePriceMode": "future_price"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let error = platform_expr_config_from_value("", "", &spec).expect_err("invalid mode");
|
||||
|
||||
assert!(error.to_string().contains("stopTakeReferencePriceMode"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ impl Position {
|
||||
return;
|
||||
}
|
||||
|
||||
let previous_quantity = self.quantity;
|
||||
let previous_average_cost = self.average_cost;
|
||||
self.lots.push(PositionLot {
|
||||
acquired_date: date,
|
||||
quantity,
|
||||
@@ -93,7 +95,18 @@ impl Position {
|
||||
self.day_trade_quantity_delta += quantity as i32;
|
||||
self.day_buy_quantity += quantity;
|
||||
self.day_buy_value += execution_price * quantity as f64;
|
||||
self.recalculate_average_cost();
|
||||
if previous_quantity > 0
|
||||
&& previous_average_cost.is_finite()
|
||||
&& previous_average_cost > 0.0
|
||||
&& execution_price.is_finite()
|
||||
&& execution_price > 0.0
|
||||
{
|
||||
self.average_cost = (previous_average_cost * previous_quantity as f64
|
||||
+ execution_price * quantity as f64)
|
||||
/ self.quantity as f64;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
self.refresh_day_pnl();
|
||||
}
|
||||
|
||||
@@ -259,7 +272,11 @@ impl Position {
|
||||
}
|
||||
if let Some(lot) = self.lots.last_mut() {
|
||||
lot.price += cost / quantity as f64;
|
||||
self.recalculate_average_cost();
|
||||
if self.quantity > 0 && self.average_cost.is_finite() && self.average_cost > 0.0 {
|
||||
self.average_cost += cost / self.quantity as f64;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
}
|
||||
self.day_trade_cost += cost;
|
||||
self.refresh_day_pnl();
|
||||
@@ -377,7 +394,11 @@ impl Position {
|
||||
self.lots = scaled_lots;
|
||||
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
||||
self.last_price /= ratio;
|
||||
self.recalculate_average_cost();
|
||||
if self.average_cost.is_finite() && self.average_cost > 0.0 {
|
||||
self.average_cost /= ratio;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
self.day_split_ratio *= ratio;
|
||||
self.refresh_day_pnl();
|
||||
self.quantity as i32 - old_quantity as i32
|
||||
@@ -411,6 +432,11 @@ pub struct PortfolioState {
|
||||
initial_cash: f64,
|
||||
units: f64,
|
||||
cash: f64,
|
||||
/// Cumulative external cash flow (deposits positive, withdrawals negative).
|
||||
/// Trading proceeds, dividends, fees and financing are deliberately not
|
||||
/// included. The value is used by the engine to build a cash-flow-neutral
|
||||
/// equity curve and is not a return measure itself.
|
||||
external_cash_flow_total: f64,
|
||||
cash_liabilities: f64,
|
||||
management_fee_rate: f64,
|
||||
management_fees: f64,
|
||||
@@ -444,6 +470,7 @@ impl PortfolioState {
|
||||
initial_cash,
|
||||
units: initial_cash,
|
||||
cash: initial_cash,
|
||||
external_cash_flow_total: 0.0,
|
||||
cash_liabilities: 0.0,
|
||||
management_fee_rate: 0.0,
|
||||
management_fees: 0.0,
|
||||
@@ -455,7 +482,9 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn starting_cash(&self) -> f64 {
|
||||
self.units
|
||||
// Keep the configured opening capital stable. External flows change
|
||||
// `units`, not the meaning of this reporting field.
|
||||
self.initial_cash
|
||||
}
|
||||
|
||||
pub fn initial_cash(&self) -> f64 {
|
||||
@@ -470,6 +499,10 @@ impl PortfolioState {
|
||||
self.cash
|
||||
}
|
||||
|
||||
pub fn external_cash_flow_total(&self) -> f64 {
|
||||
self.external_cash_flow_total
|
||||
}
|
||||
|
||||
pub fn cash_liabilities(&self) -> f64 {
|
||||
self.cash_liabilities
|
||||
}
|
||||
@@ -528,15 +561,17 @@ impl PortfolioState {
|
||||
if !amount.is_finite() {
|
||||
return Err("deposit_withdraw amount must be finite".to_string());
|
||||
}
|
||||
if amount < 0.0 && self.cash + amount < -1e-6 {
|
||||
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
|
||||
let available_cash = self.cash - self.pending_withdrawal_total();
|
||||
return Err(format!(
|
||||
"insufficient cash for withdrawal amount={:.2} cash={:.2}",
|
||||
amount, self.cash
|
||||
"insufficient cash for withdrawal amount={:.2} available_cash={:.2}",
|
||||
amount, available_cash
|
||||
));
|
||||
}
|
||||
|
||||
let unit_net_value = self.unit_net_value();
|
||||
self.cash += amount;
|
||||
self.external_cash_flow_total += amount;
|
||||
self.rebase_units_after_external_cash_flow(unit_net_value);
|
||||
Ok(())
|
||||
}
|
||||
@@ -550,10 +585,11 @@ impl PortfolioState {
|
||||
if !amount.is_finite() {
|
||||
return Err("deposit_withdraw amount must be finite".to_string());
|
||||
}
|
||||
if amount < 0.0 && self.cash + amount < -1e-6 {
|
||||
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
|
||||
let available_cash = self.cash - self.pending_withdrawal_total();
|
||||
return Err(format!(
|
||||
"insufficient cash for scheduled withdrawal amount={:.2} cash={:.2}",
|
||||
amount, self.cash
|
||||
"insufficient cash for scheduled withdrawal amount={:.2} available_cash={:.2}",
|
||||
amount, available_cash
|
||||
));
|
||||
}
|
||||
self.pending_cash_flows.push(PendingCashFlow {
|
||||
@@ -566,27 +602,74 @@ impl PortfolioState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn settle_pending_cash_flows(&mut self, date: NaiveDate) -> Vec<PendingCashFlow> {
|
||||
let mut settled = Vec::new();
|
||||
pub fn settle_pending_cash_flows(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
) -> Result<Vec<PendingCashFlow>, String> {
|
||||
let mut due = Vec::new();
|
||||
let mut pending = Vec::new();
|
||||
for flow in std::mem::take(&mut self.pending_cash_flows) {
|
||||
if flow.payable_date <= date {
|
||||
let unit_net_value = self.unit_net_value();
|
||||
self.cash += flow.amount;
|
||||
self.rebase_units_after_external_cash_flow(unit_net_value);
|
||||
settled.push(flow);
|
||||
due.push(flow);
|
||||
} else {
|
||||
pending.push(flow);
|
||||
}
|
||||
}
|
||||
|
||||
// A delayed withdrawal must not be allowed to make the account
|
||||
// negative after trades on an earlier day. Validate the complete due
|
||||
// batch before mutating either cash or the pending queue so a failed
|
||||
// settlement is atomic and can be diagnosed/retried safely.
|
||||
let incoming = due
|
||||
.iter()
|
||||
.filter(|flow| flow.amount > 0.0)
|
||||
.map(|flow| flow.amount)
|
||||
.sum::<f64>();
|
||||
let outgoing = due
|
||||
.iter()
|
||||
.filter(|flow| flow.amount < 0.0)
|
||||
.map(|flow| flow.amount)
|
||||
.sum::<f64>();
|
||||
if self.cash + incoming + outgoing < -1e-6 {
|
||||
self.pending_cash_flows = due.into_iter().chain(pending).collect();
|
||||
self.pending_cash_flows
|
||||
.sort_by_key(|flow| flow.payable_date);
|
||||
return Err(format!(
|
||||
"insufficient cash to settle delayed cash flows on {date}: cash={:.2} net_due={:.2}",
|
||||
self.cash,
|
||||
incoming + outgoing
|
||||
));
|
||||
}
|
||||
|
||||
// There is no sub-day ordering in the strategy contract for flows
|
||||
// sharing a payable date. Apply deposits first, then withdrawals, so
|
||||
// a same-day net-zero batch is deterministic and never fails merely
|
||||
// because a withdrawal happened to be listed first.
|
||||
due.sort_by_key(|flow| (flow.payable_date, flow.amount < 0.0));
|
||||
let mut settled = Vec::with_capacity(due.len());
|
||||
for flow in due {
|
||||
let unit_net_value = self.unit_net_value();
|
||||
self.cash += flow.amount;
|
||||
self.external_cash_flow_total += flow.amount;
|
||||
self.rebase_units_after_external_cash_flow(unit_net_value);
|
||||
settled.push(flow);
|
||||
}
|
||||
self.pending_cash_flows = pending;
|
||||
settled
|
||||
Ok(settled)
|
||||
}
|
||||
|
||||
pub fn pending_cash_flows(&self) -> &[PendingCashFlow] {
|
||||
&self.pending_cash_flows
|
||||
}
|
||||
|
||||
pub fn pending_withdrawal_total(&self) -> f64 {
|
||||
self.pending_cash_flows
|
||||
.iter()
|
||||
.filter(|flow| flow.amount < 0.0)
|
||||
.map(|flow| -flow.amount)
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> {
|
||||
if !amount.is_finite() {
|
||||
return Err("finance_repay amount must be finite".to_string());
|
||||
@@ -677,6 +760,18 @@ impl PortfolioState {
|
||||
) -> Result<(), DataSetError> {
|
||||
let day_sold_symbols = self.day_sold_symbols.clone();
|
||||
for position in self.positions.values_mut() {
|
||||
let current_market_missing = data.market(date, &position.symbol).is_none();
|
||||
let unresolved_delisting = current_market_missing
|
||||
&& data.instrument(&position.symbol).is_some_and(|instrument| {
|
||||
instrument.is_delisted_on_or_before(date)
|
||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
||||
&& instrument.delisted_at.is_none())
|
||||
});
|
||||
if unresolved_delisting {
|
||||
position.last_price = 0.0;
|
||||
position.refresh_day_pnl();
|
||||
continue;
|
||||
}
|
||||
let sold_today =
|
||||
position.sold_quantity() > 0 || day_sold_symbols.contains(&position.symbol);
|
||||
if same_day_buy_close_mark_at_fill
|
||||
@@ -844,6 +939,7 @@ impl PortfolioState {
|
||||
|
||||
let old_quantity = old_position.quantity;
|
||||
let last_price = old_position.last_price;
|
||||
let old_average_cost = old_position.average_cost;
|
||||
let realized_pnl = old_position.realized_pnl;
|
||||
let realized_entry_pnl = old_position.realized_entry_pnl;
|
||||
let mut converted_lots = old_position
|
||||
@@ -879,6 +975,8 @@ impl PortfolioState {
|
||||
.positions
|
||||
.entry(new_symbol.to_string())
|
||||
.or_insert_with(|| Position::new(new_symbol));
|
||||
let successor_quantity_before = successor.quantity;
|
||||
let successor_average_cost_before = successor.average_cost;
|
||||
successor.lots.extend(converted_lots);
|
||||
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
||||
successor.realized_pnl += realized_pnl;
|
||||
@@ -886,7 +984,30 @@ impl PortfolioState {
|
||||
if converted_last_price > 0.0 {
|
||||
successor.last_price = converted_last_price;
|
||||
}
|
||||
successor.recalculate_average_cost();
|
||||
let converted_average_cost = if old_average_cost.is_finite()
|
||||
&& old_average_cost > 0.0
|
||||
&& ratio.is_finite()
|
||||
&& ratio > 0.0
|
||||
{
|
||||
Some(old_average_cost / ratio)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(converted_average_cost) = converted_average_cost {
|
||||
if successor_quantity_before > 0
|
||||
&& successor_average_cost_before.is_finite()
|
||||
&& successor_average_cost_before > 0.0
|
||||
{
|
||||
successor.average_cost = (successor_average_cost_before
|
||||
* successor_quantity_before as f64
|
||||
+ converted_average_cost * converted_quantity as f64)
|
||||
/ successor.quantity as f64;
|
||||
} else {
|
||||
successor.average_cost = converted_average_cost;
|
||||
}
|
||||
} else {
|
||||
successor.recalculate_average_cost();
|
||||
}
|
||||
successor.refresh_day_pnl();
|
||||
|
||||
Some(SuccessorConversionOutcome {
|
||||
@@ -982,6 +1103,25 @@ mod tests {
|
||||
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_after_partial_sell_continues_moving_average_cost_basis() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut position = Position::new("300405.SZ");
|
||||
position.buy(date, 100, 10.0);
|
||||
position.buy(date, 100, 5.0);
|
||||
assert!((position.average_cost - 7.5).abs() < 1e-12);
|
||||
|
||||
position.sell(100, 6.0).expect("partial sell");
|
||||
assert_eq!(position.quantity, 100);
|
||||
assert!((position.average_cost - 7.5).abs() < 1e-12);
|
||||
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||
|
||||
position.buy(date, 100, 5.0);
|
||||
assert_eq!(position.quantity, 200);
|
||||
assert!((position.average_cost - 6.25).abs() < 1e-12);
|
||||
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
@@ -1505,6 +1645,51 @@ mod tests {
|
||||
assert!((portfolio.total_returns() - (portfolio.unit_net_value() - 1.0)).abs() < 1e-6);
|
||||
assert_eq!(portfolio.cash_receivables().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_cash_flow_rebases_units_without_changing_nav() {
|
||||
let mut portfolio = PortfolioState::new(10_000.0);
|
||||
portfolio
|
||||
.deposit_withdraw(5_000.0)
|
||||
.expect("deposit should settle");
|
||||
assert!((portfolio.cash() - 15_000.0).abs() < 1e-6);
|
||||
assert!((portfolio.units() - 15_000.0).abs() < 1e-6);
|
||||
assert!((portfolio.unit_net_value() - 1.0).abs() < 1e-12);
|
||||
assert!((portfolio.external_cash_flow_total() - 5_000.0).abs() < 1e-6);
|
||||
portfolio
|
||||
.deposit_withdraw(-2_000.0)
|
||||
.expect("withdrawal should settle");
|
||||
assert!((portfolio.cash() - 13_000.0).abs() < 1e-6);
|
||||
assert!((portfolio.units() - 13_000.0).abs() < 1e-6);
|
||||
assert!((portfolio.unit_net_value() - 1.0).abs() < 1e-12);
|
||||
assert!((portfolio.external_cash_flow_total() - 3_000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_withdrawals_are_reserved_and_settled_atomically() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let mut portfolio = PortfolioState::new(10_000.0);
|
||||
portfolio
|
||||
.schedule_deposit_withdraw(date, -8_000.0, "first")
|
||||
.expect("first withdrawal should reserve cash");
|
||||
assert!((portfolio.pending_withdrawal_total() - 8_000.0).abs() < 1e-6);
|
||||
assert!(
|
||||
portfolio
|
||||
.schedule_deposit_withdraw(date, -3_000.0, "overcommit")
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// A strategy cannot spend the reserved cash by scheduling a second
|
||||
// withdrawal; settlement remains safe even if earlier trading reduced
|
||||
// the current cash balance.
|
||||
portfolio.apply_cash_delta(-3_000.0);
|
||||
let error = portfolio
|
||||
.settle_pending_cash_flows(date)
|
||||
.expect_err("settlement must reject an underfunded withdrawal batch");
|
||||
assert!(error.contains("insufficient cash"));
|
||||
assert_eq!(portfolio.pending_cash_flows().len(), 1);
|
||||
assert!((portfolio.cash() - 7_000.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@@ -82,6 +82,7 @@ pub struct TradingConstraintConfig {
|
||||
pub liquidity_limit_enabled: bool,
|
||||
pub commission_rate: f64,
|
||||
pub minimum_commission: f64,
|
||||
pub transfer_fee_rate: f64,
|
||||
pub stamp_tax_rate_before_change: f64,
|
||||
pub stamp_tax_rate_after_change: f64,
|
||||
pub stamp_tax_change_date: NaiveDate,
|
||||
@@ -95,6 +96,7 @@ impl Default for TradingConstraintConfig {
|
||||
liquidity_limit_enabled: true,
|
||||
commission_rate: 0.0003,
|
||||
minimum_commission: 5.0,
|
||||
transfer_fee_rate: 0.0,
|
||||
stamp_tax_rate_before_change: 0.001,
|
||||
stamp_tax_rate_after_change: 0.0005,
|
||||
stamp_tax_change_date: NaiveDate::from_ymd_opt(2023, 8, 28)
|
||||
@@ -199,7 +201,7 @@ impl ChinaAShareRiskControl {
|
||||
let terminal_status = matches!(
|
||||
status.as_str(),
|
||||
"inactive" | "delisted" | "terminated" | "expired"
|
||||
) || status.contains("delist");
|
||||
);
|
||||
if terminal_status && instrument.delisted_at.is_none() {
|
||||
return Some("inactive_or_delisted");
|
||||
}
|
||||
@@ -223,6 +225,21 @@ impl ChinaAShareRiskControl {
|
||||
Self::instrument_rejection_reason(instrument, date)
|
||||
}
|
||||
|
||||
pub fn active_status_rejection_reason_with_config(
|
||||
date: NaiveDate,
|
||||
candidate: Option<&CandidateEligibility>,
|
||||
instrument: Option<&Instrument>,
|
||||
config: &FidcRiskControlConfig,
|
||||
scope: RiskCheckScope,
|
||||
) -> Option<&'static str> {
|
||||
if let Some(reason) =
|
||||
Self::instrument_rejection_reason_with_config(instrument, date, config, scope)
|
||||
{
|
||||
return Some(reason);
|
||||
}
|
||||
candidate.and_then(|candidate| candidate_active_status_rejection(candidate, config, scope))
|
||||
}
|
||||
|
||||
pub fn selection_rejection_reason(
|
||||
date: NaiveDate,
|
||||
candidate: &CandidateEligibility,
|
||||
@@ -750,6 +767,45 @@ mod tests {
|
||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
||||
}
|
||||
|
||||
fn instrument(status: &str, delisted_at: Option<NaiveDate>) -> Instrument {
|
||||
Instrument {
|
||||
symbol: "002898.SZ".to_string(),
|
||||
name: "赛隆退".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2017, 9, 12)),
|
||||
delisted_at,
|
||||
status: status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delisting_period_status_is_not_terminal_without_delisted_date() {
|
||||
let date = d(2025, 3, 20);
|
||||
|
||||
assert_eq!(
|
||||
ChinaAShareRiskControl::instrument_rejection_reason(
|
||||
Some(&instrument("delisting", None)),
|
||||
date,
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ChinaAShareRiskControl::instrument_rejection_reason(
|
||||
Some(&instrument("delisted", None)),
|
||||
date,
|
||||
),
|
||||
Some("inactive_or_delisted")
|
||||
);
|
||||
assert_eq!(
|
||||
ChinaAShareRiskControl::instrument_rejection_reason(
|
||||
Some(&instrument("delisting", Some(d(2026, 7, 20)))),
|
||||
d(2026, 7, 20),
|
||||
),
|
||||
Some("inactive_or_delisted")
|
||||
);
|
||||
}
|
||||
|
||||
fn candidate(date: NaiveDate) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
|
||||
@@ -315,7 +315,7 @@ impl StrategyContext<'_> {
|
||||
let gross_amount = fills.iter().map(|fill| fill.gross_amount).sum::<f64>();
|
||||
let transaction_cost = fills
|
||||
.iter()
|
||||
.map(|fill| fill.commission + fill.stamp_tax)
|
||||
.map(|fill| fill.commission + fill.stamp_tax + fill.transfer_fee)
|
||||
.sum::<f64>();
|
||||
let avg_price = if filled_quantity == 0 {
|
||||
0.0
|
||||
@@ -1655,6 +1655,7 @@ pub struct OmniMicroCapConfig {
|
||||
pub stock_long_ma_days: usize,
|
||||
pub stock_volume_short_ma_days: usize,
|
||||
pub stock_volume_long_ma_days: usize,
|
||||
pub stock_volume_filter_enabled: bool,
|
||||
pub rsi_rate: f64,
|
||||
pub trade_rate: f64,
|
||||
pub stop_loss_ratio: f64,
|
||||
@@ -1684,6 +1685,7 @@ impl OmniMicroCapConfig {
|
||||
stock_long_ma_days: 20,
|
||||
stock_volume_short_ma_days: 5,
|
||||
stock_volume_long_ma_days: 60,
|
||||
stock_volume_filter_enabled: true,
|
||||
rsi_rate: 1.0001,
|
||||
trade_rate: 0.5,
|
||||
stop_loss_ratio: 0.93,
|
||||
@@ -1695,35 +1697,6 @@ impl OmniMicroCapConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn aiquant_v104() -> Self {
|
||||
Self {
|
||||
strategy_name: "aiquant-v1.0.4".to_string(),
|
||||
refresh_rate: 120,
|
||||
stocknum: 5,
|
||||
xs: 4.0 / 500.0,
|
||||
base_index_level: 2000.0,
|
||||
base_cap_floor: 7.0,
|
||||
cap_span: 10.0,
|
||||
padding_ratio: 1.2,
|
||||
min_padding: 29.5,
|
||||
max_padding: 50.0,
|
||||
benchmark_signal_symbol: "000852.SH".to_string(),
|
||||
benchmark_short_ma_days: 5,
|
||||
benchmark_long_ma_days: 20,
|
||||
stock_short_ma_days: 5,
|
||||
stock_mid_ma_days: 10,
|
||||
stock_long_ma_days: 30,
|
||||
stock_volume_short_ma_days: 5,
|
||||
stock_volume_long_ma_days: 60,
|
||||
rsi_rate: 1.0001,
|
||||
trade_rate: 0.5,
|
||||
stop_loss_ratio: 0.92,
|
||||
take_profit_ratio: 1.16,
|
||||
skip_month_day_ranges: Vec::new(),
|
||||
risk_config: FidcRiskControlConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn in_skip_window(&self, date: NaiveDate) -> bool {
|
||||
let year = date.year() as u32;
|
||||
let month = date.month();
|
||||
@@ -2433,10 +2406,7 @@ impl OmniMicroCapStrategy {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.config.strategy_name.contains("aiquant")
|
||||
|| self.config.strategy_name.contains("AiQuant")
|
||||
|| self.config.strategy_name.contains("omni")
|
||||
{
|
||||
if self.config.stock_volume_filter_enabled {
|
||||
let Some(volume_ma5) = ctx.data.market_decision_volume_moving_average(
|
||||
date,
|
||||
symbol,
|
||||
@@ -3018,6 +2988,7 @@ mod tests {
|
||||
default_cfg.stock_short_ma_days = 1;
|
||||
default_cfg.stock_mid_ma_days = 2;
|
||||
default_cfg.stock_long_ma_days = 3;
|
||||
default_cfg.stock_volume_filter_enabled = false;
|
||||
let default_strategy = OmniMicroCapStrategy::new(default_cfg.clone());
|
||||
let (default_selected, _) = default_strategy
|
||||
.select_symbols(&ctx, dates[2], 0.0, 100.0)
|
||||
|
||||
@@ -118,7 +118,7 @@ pub struct StrategyAiOptimizeRequest {
|
||||
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
|
||||
}
|
||||
|
||||
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益 >= 150% 即视为满足收益目标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同。";
|
||||
const PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT: &str = "收益验收合同:收益、回撤、年度收益、样本外区间及比较运算符只能来自用户目标、请求约束或不可变 candidate/promotion contract;不得注入 120% 或其他默认数值,也不得提高、降低或替换已经明确的门槛。没有明确数值合同时只做策略有效性、数据时序和风险审计,禁止声称收益已经达标;存在冻结合同时必须逐项按原运算符验证,不能只看总收益。";
|
||||
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\"";
|
||||
|
||||
@@ -135,7 +135,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
"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(),
|
||||
"next_bar_open 会在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数;不得把执行日 open/high/low/close 当成下单前已知信息,也不得用 T+1 prev_close 或 T 日估算股数直接成交;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(),
|
||||
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
|
||||
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
|
||||
],
|
||||
@@ -250,7 +250,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
},
|
||||
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(),
|
||||
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;但源策略明确写出的业务选股排除属于策略本身,必须原样保留在 filter.stock_expr,并且不能反向修改冻结的 reject_*_selection 开关。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||
@@ -258,7 +258,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
},
|
||||
ManualSection {
|
||||
title: "execution.matching_type / execution.slippage".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(),
|
||||
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\");current_bar_close 使用决策日当日 close,next_bar_open 在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数,保证执行金额加手续费不超过分配金额;禁止把执行日 open/high/low/close 解释为下单前已知数据,也禁止用 T+1 prev_close 或 T 日估算股数直接成交;next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。金额预算始终严格,execution.strict_value_budget(false) 会被拒绝。分钟线回测使用当前分钟价格成交,只能写 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.002) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "期货提交校验".to_string(),
|
||||
@@ -280,6 +280,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
fields: vec![
|
||||
ManualField { name: "signal_open/signal_close".to_string(), field_type: "float".to_string(), detail: "信号指数当日开盘价与前一日收盘价。".to_string() },
|
||||
ManualField { name: "benchmark_open/benchmark_close".to_string(), field_type: "float".to_string(), detail: "基准当日开盘价与前一日收盘价。".to_string() },
|
||||
ManualField { name: "benchmark_signal_close".to_string(), field_type: "float".to_string(), detail: "信号日收盘后可见的基准当日收盘价;用于 T 日生成信号、T+1 开盘成交的条件,不能在盘中或 T 日开盘决策中使用。".to_string() },
|
||||
ManualField { name: "signal_ma5/signal_ma10/signal_ma20/signal_ma30".to_string(), field_type: "float".to_string(), detail: "信号指数滚动均线。".to_string() },
|
||||
ManualField { name: "benchmark_ma5/benchmark_ma10/benchmark_ma20/benchmark_ma30".to_string(), field_type: "float".to_string(), detail: "基准指数滚动均线。".to_string() },
|
||||
ManualField { name: "cash/available_cash/frozen_cash/market_value/total_equity".to_string(), field_type: "float".to_string(), detail: "账户可用资金、挂单冻结资金、市值与总权益;available_cash 会扣减当前买入挂单冻结估算。".to_string() },
|
||||
@@ -365,7 +366,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
ManualFunction { name: "get_dominant_future / dominant_future / dominant_future_price".to_string(), signature: "dominant_future(\"IF\") / dominant_future_price(\"IF\", \"close\", lookback=1)".to_string(), detail: "主力合约 API。dominant_future 返回当前日期匹配前缀的主力期货合约代码;dominant_future_price 读取该主力合约最近 N 个交易日指定字段的最新价格。Rust Context 可用 ctx.get_dominant_future(...) 和 ctx.get_dominant_future_price(...)。".to_string() },
|
||||
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: "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 与 management_fee 按账户合同结算。模拟盘只接受由 runtime 明确返回的即时 deposit_withdraw,并通过幂等现金流账本落库;延迟流、融资/管理费动作必须显式失败。实盘禁止策略侧改变现金,必须以券商资产和已核验资金流水为真相,策略返回上述动作会在下单前 fail-closed。".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: "任意数值字段滚动求和、最小值、最大值。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
|
||||
@@ -471,7 +472,7 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
|
||||
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`、`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("- `universe.exclude` 只用于用户明确要求的业务排除项;框架默认的 ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量、手续费和印花税等基础风控必须写 `risk.policy(...)` 或由运行态 RiskLimits 注入。源策略明确写出的业务选股排除必须保留为策略表达式,不能改写冻结的 selection 风控开关。\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");
|
||||
@@ -481,10 +482,11 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
|
||||
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("- `filter.stock_expr(...)` 只写 alpha 或策略明确声明的业务过滤条件;转换器不得自行把框架默认基础风控注入过滤表达式。源策略明确写出的 `!is_st`、`!is_star_st`、`!is_kcb`、`!is_bjse` 等业务选股排除必须原样保留,同时冻结的 `reject_*_selection` 值不得改变。\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("- 日线目标金额、目标比例和目标权重在 `next_bar_open` 下冻结 T 日收盘目标,T+1 按实际 open、滑点、卖后买延迟滑点、手续费和证券数量步长重算股数;禁止用 T+1 prev_close、T 日估算股数或 T+1 开盘后权益替代。金额预算始终严格,不能生成 `execution.strict_value_budget(false)`。\n");
|
||||
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
|
||||
out.push_str("## 语句块\n");
|
||||
for item in &manual.statement_blocks {
|
||||
@@ -560,15 +562,15 @@ pub fn build_generation_prompt(
|
||||
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_str(PERFORMANCE_ACCEPTANCE_CONTRACT_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、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("只允许使用这些可编译语句: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。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;日线 execution.rebalance_cash_mode 只允许 same_point_net、sell_then_buy、pre_open_cash,分钟线固定使用 sell_then_buy;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type;next_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图并在收盘冻结目标金额或目标权益,T+1 按实际 open、滑点、手续费和证券数量步长重算股数,不能用 T+1 prev_close 或 T 日估算股数直接成交;涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单;金额预算始终严格,禁止 execution.strict_value_budget(false);execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 请求滑点率,例如 0.002),并且请求里指定固定滑点时必须使用请求值。\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 && 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(")\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.002)\n}\n\n");
|
||||
prompt.push_str("用户目标:\n");
|
||||
prompt.push_str(&format!("- {}\n", request.user_goal));
|
||||
if !request.constraints.is_empty() {
|
||||
@@ -596,7 +598,7 @@ pub fn build_optimization_prompt(
|
||||
prompt.push_str("长度硬约束:策略代码目标 80 行以内,只保留必要 let/fn/strategy 块;不要复制下面的手册片段、历史策略全文或字段清单。\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_str(PERFORMANCE_ACCEPTANCE_CONTRACT_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");
|
||||
@@ -627,7 +629,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn generation_prompt_contains_three_year_return_acceptance_target() {
|
||||
fn generation_prompt_uses_explicit_performance_acceptance_contract() {
|
||||
let prompt = build_generation_prompt(
|
||||
"manual",
|
||||
&StrategyAiGenerateRequest {
|
||||
@@ -640,14 +642,18 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||
assert!(prompt.contains("不得把已达标策略判为失败"));
|
||||
assert!(prompt.contains("只能来自用户目标、请求约束或不可变 candidate/promotion contract"));
|
||||
assert!(prompt.contains("不得注入 120% 或其他默认数值"));
|
||||
assert!(!prompt.contains("总收益严格 > 120%"));
|
||||
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("execution.rebalance_cash_mode"));
|
||||
assert!(prompt.contains("same_point_net、sell_then_buy、pre_open_cash"));
|
||||
assert!(prompt.contains("分钟线固定使用 sell_then_buy"));
|
||||
assert!(prompt.contains("必须覆盖完整默认配置面"));
|
||||
assert!(prompt.contains("reject_inactive_buy=true"));
|
||||
assert!(prompt.contains("reject_inactive_sell=true"));
|
||||
@@ -661,7 +667,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimization_prompt_contains_three_year_return_acceptance_target() {
|
||||
fn manual_separates_explicit_business_selection_from_framework_risk_policy() {
|
||||
let markdown = render_manual_markdown(&built_in_strategy_manual());
|
||||
assert!(markdown.contains("源策略明确写出的业务选股排除属于策略本身"));
|
||||
assert!(markdown.contains("不能反向修改冻结的 reject_*_selection 开关"));
|
||||
assert!(markdown.contains("冻结的 `reject_*_selection` 值不得改变"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimization_prompt_uses_explicit_performance_acceptance_contract() {
|
||||
let prompt = build_optimization_prompt(
|
||||
"manual",
|
||||
&StrategyAiOptimizeRequest {
|
||||
@@ -673,8 +687,9 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||
assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性"));
|
||||
assert!(prompt.contains("只能来自用户目标、请求约束或不可变 candidate/promotion contract"));
|
||||
assert!(prompt.contains("不得注入 120% 或其他默认数值"));
|
||||
assert!(!prompt.contains("总收益严格 > 120%"));
|
||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
|
||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||
assert!(prompt.contains("ClickHouse"));
|
||||
|
||||
@@ -63,13 +63,20 @@ 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 - 80.0).abs() < 1e-9);
|
||||
assert!((sell.commission - 30.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();
|
||||
fn configured_cost_model_matches_declared_run_options() {
|
||||
let model =
|
||||
ChinaAShareCostModel::from_trading_constraints(fidc_core::TradingConstraintConfig {
|
||||
commission_rate: 0.0003,
|
||||
minimum_commission: 5.0,
|
||||
stamp_tax_rate_before_change: 0.0005,
|
||||
stamp_tax_rate_after_change: 0.0005,
|
||||
..fidc_core::TradingConstraintConfig::default()
|
||||
});
|
||||
|
||||
let buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 49_978.84);
|
||||
assert!((buy.commission - 14.993652).abs() < 1e-9);
|
||||
@@ -130,7 +137,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 - 12.6).abs() < 1e-9);
|
||||
assert!((third.commission - 1.6).abs() < 1e-9);
|
||||
assert!((another_order.commission - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
|
||||
@@ -368,7 +368,11 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
first_date: buy_date,
|
||||
},
|
||||
BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaAShareCostModel {
|
||||
commission_rate: 0.0008,
|
||||
minimum_commission: 0.0,
|
||||
..ChinaAShareCostModel::default()
|
||||
},
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
),
|
||||
|
||||
@@ -42,7 +42,7 @@ impl Strategy for BuyThenHoldStrategy {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() {
|
||||
fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
let date1 = d(2025, 1, 2);
|
||||
let delist_date = d(2025, 1, 3);
|
||||
let date2 = d(2025, 1, 6);
|
||||
@@ -306,24 +306,33 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
||||
);
|
||||
|
||||
let result = engine.run().expect("backtest succeeds");
|
||||
assert_eq!(result.fills.len(), 2);
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert!(
|
||||
result
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| fill.reason.contains("delisted_cash_settlement")
|
||||
&& fill.symbol == "000001.SZ")
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.all(|holding| holding.symbol != "000001.SZ")
|
||||
.all(|fill| !fill.reason.contains("delisted_cash_settlement"))
|
||||
);
|
||||
let unresolved = result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.find(|holding| holding.symbol == "000001.SZ")
|
||||
.expect("unresolved delisted holding remains auditable");
|
||||
assert_eq!(unresolved.quantity, 900);
|
||||
assert_eq!(unresolved.last_price, 0.0);
|
||||
assert_eq!(unresolved.market_value, 0.0);
|
||||
assert!(result.equity_curve.iter().any(|point| {
|
||||
point
|
||||
.notes
|
||||
.contains("unresolved_delisted_position symbol=000001.SZ")
|
||||
&& point.notes.contains("settlement_action=missing")
|
||||
&& point.notes.contains("valuation_policy=zero")
|
||||
&& point.notes.contains("no_order=true")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
let date1 = d(2025, 1, 2);
|
||||
let date2 = d(2025, 1, 3);
|
||||
let data = DataSet::from_components_with_actions(
|
||||
|
||||
@@ -2648,6 +2648,13 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
||||
assert!((result.equity_curve[0].total_equity - 10_458.0).abs() < 1e-6);
|
||||
assert!((result.equity_curve[1].cash - 12_416.0).abs() < 1e-6);
|
||||
assert!((result.equity_curve[1].total_equity - 11_416.0).abs() < 1e-6);
|
||||
assert!((result.equity_curve[0].external_cash_flow - 500.0).abs() < 1e-6);
|
||||
assert!((result.equity_curve[1].external_cash_flow - 1_000.0).abs() < 1e-6);
|
||||
assert!((result.metrics.external_cash_flow_total - 1_500.0).abs() < 1e-6);
|
||||
// The 1,500 external cash contribution must not be reported as a
|
||||
// strategy return. Only the explicit management fee affects NAV here.
|
||||
assert!(result.metrics.total_return < 0.0);
|
||||
assert!(result.metrics.total_return > -0.01);
|
||||
assert!(result.account_events.iter().any(|event| {
|
||||
event
|
||||
.note
|
||||
|
||||
@@ -3321,7 +3321,8 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
||||
.order_events
|
||||
.iter()
|
||||
.all(|event| !(event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy)),
|
||||
"optimizer should skip unfunded rebalance buy when locked holding cannot be sold"
|
||||
"optimizer should skip unfunded rebalance buy when locked holding cannot be sold: {:#?}",
|
||||
report
|
||||
);
|
||||
assert_eq!(
|
||||
portfolio
|
||||
@@ -3349,7 +3350,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebalance_uses_prev_close_for_open_auction_valuation() {
|
||||
fn rebalance_uses_day_open_for_open_auction_valuation() {
|
||||
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
|
||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let data = DataSet::from_components(
|
||||
@@ -3515,7 +3516,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
|
||||
let held = portfolio.position("000001.SZ").expect("held position");
|
||||
let target = portfolio.position("000002.SZ").expect("target position");
|
||||
assert_eq!(held.quantity, 500);
|
||||
assert_eq!(target.quantity, 400);
|
||||
assert_eq!(target.quantity, 900);
|
||||
assert_eq!(report.fill_events.len(), 2);
|
||||
assert!(
|
||||
report
|
||||
@@ -3531,7 +3532,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
|
||||
.iter()
|
||||
.any(|fill| fill.symbol == "000002.SZ"
|
||||
&& fill.side == fidc_core::OrderSide::Buy
|
||||
&& fill.quantity == 400)
|
||||
&& fill.quantity == 900)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3714,14 +3715,179 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
||||
.iter()
|
||||
.any(|event| event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy)
|
||||
);
|
||||
assert!(
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|line| line.contains("rebalance_safety_scaled")
|
||||
|| line.contains("rebalance_buy_reduced")),
|
||||
"expected rebalance diagnostics when cash is tight, got {:?}",
|
||||
report.diagnostics
|
||||
assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "TargetA".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
},
|
||||
Instrument {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
name: "TargetB".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2024-01-10 10:18:00".to_string()),
|
||||
day_open: 82.0,
|
||||
open: 82.0,
|
||||
high: 83.0,
|
||||
low: 81.0,
|
||||
close: 82.0,
|
||||
last_price: 82.0,
|
||||
bid1: 81.99,
|
||||
ask1: 82.01,
|
||||
prev_close: 82.0,
|
||||
volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 80_000,
|
||||
ask1_volume: 80_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 90.2,
|
||||
lower_limit: 73.8,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: Some("2024-01-10 10:18:00".to_string()),
|
||||
day_open: 82.0,
|
||||
open: 82.0,
|
||||
high: 83.0,
|
||||
low: 81.0,
|
||||
close: 82.0,
|
||||
last_price: 82.0,
|
||||
bid1: 81.99,
|
||||
ask1: 82.01,
|
||||
prev_close: 82.0,
|
||||
volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 80_000,
|
||||
ask1_volume: 80_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 90.2,
|
||||
lower_limit: 73.8,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 50.0,
|
||||
free_float_cap_bn: 45.0,
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
market_cap_bn: 60.0,
|
||||
free_float_cap_bn: 50.0,
|
||||
pe_ttm: 18.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
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,
|
||||
},
|
||||
CandidateEligibility {
|
||||
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,
|
||||
},
|
||||
],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
let mut portfolio = PortfolioState::new(100_000.0);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
|
||||
broker
|
||||
.execute(
|
||||
date,
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([
|
||||
("000001.SZ".to_string(), 0.48),
|
||||
("000002.SZ".to_string(), 0.48),
|
||||
]),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
risk_decisions: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("broker execution");
|
||||
|
||||
assert_eq!(
|
||||
portfolio
|
||||
.position("000001.SZ")
|
||||
.map(|position| position.quantity),
|
||||
Some(500)
|
||||
);
|
||||
assert_eq!(
|
||||
portfolio
|
||||
.position("000002.SZ")
|
||||
.map(|position| position.quantity),
|
||||
Some(500)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ futures path. Confirmed aligned areas:
|
||||
pending limit orders, cancellation, open order views, and final order lookup.
|
||||
- Stock account and portfolio runtime fields including cash, frozen cash, total
|
||||
value, transaction cost, trading/position PnL, management fees, financing
|
||||
liability, deposit/withdraw, and position aliases.
|
||||
liability, deposit/withdraw, and position aliases. External deposits and
|
||||
withdrawals are unitized separately from trading PnL; delayed withdrawals
|
||||
are preflighted atomically at settlement.
|
||||
- Scheduler, dynamic universe, subscription guard, `history_bars`,
|
||||
`current_snapshot`, `get_price`, instruments, trading-date APIs, suspension
|
||||
and ST helpers.
|
||||
|
||||
@@ -49,7 +49,10 @@ PY
|
||||
|
||||
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 platform_selection_uses_complete_static_risk_policy_before_universe_output
|
||||
run_core_test platform_next_open_defers_complete_static_selection_risk_before_universe_output
|
||||
run_core_test platform_next_open_selection_ignores_execution_day_limit_state
|
||||
run_core_test platform_next_open_selection_applies_explicit_signal_day_risk_filter
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user