Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f60bfac1d |
Generated
-52
@@ -99,43 +99,12 @@ 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"
|
||||
@@ -148,7 +117,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"indexmap",
|
||||
"rayon",
|
||||
"rhai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -326,26 +294,6 @@ 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,7 +14,6 @@ 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,7 +14,6 @@
|
||||
- Broker 支持目标权重、显式金额、目标股数、限价、VWAP/TWAP、挂单、撤单和订单查询。
|
||||
- 期货账户支持多空持仓、开平仓、今昨仓、保证金、手续费、结算和到期处理。
|
||||
- 报告输出支持权益曲线、成交、持仓、月度收益、风险指标、基准序列和 JSON 分析包。
|
||||
- 账户出入金以独立外部现金流记录保存;权益曲线同时输出 `externalCashFlow` 和现金流中性 `unitNav`,收益指标不把入金/出金计入交易收益。延迟出金在结算前做整批资金校验,不能把账户现金变成负数。
|
||||
- 内置 `OmniMicroCapStrategy`,覆盖动态市值带、均线过滤、止损止盈、固定频率再平衡和盘中执行近似。
|
||||
|
||||
## Workspace 布局
|
||||
@@ -81,10 +80,6 @@
|
||||
|
||||
任意数据库指标和自定义因子通过 `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,7 +8,6 @@ authors.workspace = true
|
||||
[dependencies]
|
||||
chrono.workspace = true
|
||||
indexmap.workspace = true
|
||||
rayon.workspace = true
|
||||
rhai.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
+310
-3420
File diff suppressed because it is too large
Load Diff
@@ -5,16 +5,17 @@ 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.transfer_fee
|
||||
self.commission + self.stamp_tax
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,16 +41,30 @@ 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::from_trading_constraints(TradingConstraintConfig::default())
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -57,7 +72,6 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,13 +97,6 @@ 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,
|
||||
@@ -128,24 +135,30 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,18 +174,15 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,8 +192,8 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_matches_configurable_trading_constraints() {
|
||||
let model = ChinaAShareCostModel::default();
|
||||
fn aiquant_default_matches_current_backtest_fee_model() {
|
||||
let model = ChinaAShareCostModel::aiquant_default();
|
||||
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
|
||||
|
||||
assert!((model.commission_for(248_059.812) - 74.4179436).abs() < 1e-9);
|
||||
@@ -197,7 +207,6 @@ 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"),
|
||||
@@ -205,8 +214,6 @@ 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"),
|
||||
|
||||
+156
-676
File diff suppressed because it is too large
Load Diff
+143
-528
@@ -78,13 +78,6 @@ 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,
|
||||
@@ -203,7 +196,7 @@ impl BacktestResult {
|
||||
quantity: fill.quantity,
|
||||
price: fill.price,
|
||||
gross_amount: fill.gross_amount,
|
||||
transaction_cost: fill.commission + fill.stamp_tax + fill.transfer_fee,
|
||||
transaction_cost: fill.commission + fill.stamp_tax,
|
||||
net_cash_flow: fill.net_cash_flow,
|
||||
reason: fill.reason.clone(),
|
||||
})
|
||||
@@ -237,34 +230,27 @@ 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 = 1.0;
|
||||
let mut previous_equity = self.metrics.initial_cash;
|
||||
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_nav;
|
||||
*end_equity = point.total_equity;
|
||||
*end_benchmark = point.benchmark_close;
|
||||
})
|
||||
.or_insert((
|
||||
previous_equity,
|
||||
previous_benchmark,
|
||||
point_nav,
|
||||
point.total_equity,
|
||||
point.benchmark_close,
|
||||
));
|
||||
previous_equity = point_nav;
|
||||
previous_equity = point.total_equity;
|
||||
previous_benchmark = point.benchmark_close;
|
||||
}
|
||||
month_points
|
||||
@@ -313,8 +299,6 @@ 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,
|
||||
@@ -1208,9 +1192,6 @@ where
|
||||
});
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: Some(order_id),
|
||||
symbol: self
|
||||
.futures_open_orders
|
||||
@@ -1248,9 +1229,6 @@ where
|
||||
let mut report = FuturesExecutionReport::default();
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: Some(order_id),
|
||||
symbol: intent.symbol.clone(),
|
||||
side,
|
||||
@@ -1668,7 +1646,7 @@ where
|
||||
let mut portfolio = PortfolioState::new(self.config.initial_cash);
|
||||
let scheduler_calendar = self.data.calendar().clone();
|
||||
let scheduler = Scheduler::new(&scheduler_calendar);
|
||||
let calendar_dates = self
|
||||
let execution_dates = self
|
||||
.data
|
||||
.calendar()
|
||||
.iter()
|
||||
@@ -1679,41 +1657,11 @@ where
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.filter(|date| self.config.end_date.map(|end| *date <= end).unwrap_or(true))
|
||||
.filter(|date| {
|
||||
!self.data.factor_snapshots_on(*date).is_empty()
|
||||
&& !self.data.candidate_snapshots_on(*date).is_empty()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let has_decision_inputs = |date: NaiveDate| {
|
||||
!self.data.factor_snapshots_on(date).is_empty()
|
||||
&& !self.data.candidate_snapshots_on(date).is_empty()
|
||||
};
|
||||
let has_execution_market =
|
||||
|date: NaiveDate| !self.data.market_snapshots_on(date).is_empty();
|
||||
let mut execution_dates = Vec::new();
|
||||
let mut decision_slots = Vec::new();
|
||||
for (calendar_idx, execution_date) in calendar_dates.iter().copied().enumerate() {
|
||||
if self.config.decision_lag_trading_days == 0 {
|
||||
if has_decision_inputs(execution_date) {
|
||||
execution_dates.push(execution_date);
|
||||
decision_slots.push(Some((calendar_idx, execution_date)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !has_execution_market(execution_date) {
|
||||
continue;
|
||||
}
|
||||
let decision_slot = calendar_idx
|
||||
.checked_sub(self.config.decision_lag_trading_days)
|
||||
.map(|decision_idx| (decision_idx, calendar_dates[decision_idx]));
|
||||
match decision_slot {
|
||||
Some((_, decision_date)) if has_decision_inputs(decision_date) => {
|
||||
execution_dates.push(execution_date);
|
||||
decision_slots.push(decision_slot);
|
||||
}
|
||||
None => {
|
||||
execution_dates.push(execution_date);
|
||||
decision_slots.push(None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut result = BacktestResult {
|
||||
strategy_name: self.strategy.name().to_string(),
|
||||
benchmark_series: self
|
||||
@@ -1744,8 +1692,6 @@ 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();
|
||||
@@ -1757,55 +1703,32 @@ where
|
||||
execution_date,
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
)?;
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
pending_cash_flow_report,
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
self.extend_result(&mut result, pending_cash_flow_report);
|
||||
let corporate_action_report = self.apply_corporate_actions(
|
||||
execution_date,
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
)?;
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
corporate_action_report,
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
self.extend_result(&mut result, corporate_action_report);
|
||||
let receivable_report = self.settle_cash_receivables(
|
||||
execution_date,
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
)?;
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
receivable_report,
|
||||
self.extend_result(&mut result, receivable_report);
|
||||
let delisting_report = self.settle_delisted_positions(
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
let delisting_report = self.audit_unresolved_delisted_positions(
|
||||
execution_date,
|
||||
&portfolio,
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
);
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
delisting_report,
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
)?;
|
||||
self.extend_result(&mut result, delisting_report);
|
||||
let futures_open_order_report = self.process_futures_open_orders(execution_date);
|
||||
self.extend_result(
|
||||
&mut result,
|
||||
futures_open_order_report,
|
||||
execution_date,
|
||||
execution_date,
|
||||
);
|
||||
self.extend_result(&mut result, futures_open_order_report);
|
||||
|
||||
let decision_slot = decision_slots.get(execution_idx).copied().flatten();
|
||||
let decision_slot = execution_idx
|
||||
.checked_sub(self.config.decision_lag_trading_days)
|
||||
.map(|decision_idx| (decision_idx, execution_dates[decision_idx]));
|
||||
let Some((decision_index, decision_date)) = decision_slot else {
|
||||
let mut process_events = Vec::new();
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
@@ -1827,7 +1750,7 @@ where
|
||||
let day_fills = report.fill_events.clone();
|
||||
let broker_diagnostics = report.diagnostics.clone();
|
||||
let execution_risk_decisions = risk_decisions_from_order_events(&day_orders);
|
||||
self.extend_result(&mut result, report, execution_date, execution_date);
|
||||
self.extend_result(&mut result, report);
|
||||
result.risk_decisions.extend(execution_risk_decisions);
|
||||
|
||||
let benchmark =
|
||||
@@ -1846,21 +1769,16 @@ 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,
|
||||
@@ -1876,9 +1794,16 @@ where
|
||||
cash: latest.cash,
|
||||
market_value: latest.market_value,
|
||||
total_equity: latest.total_equity,
|
||||
external_cash_flow: latest.external_cash_flow,
|
||||
unit_nav: latest.unit_nav,
|
||||
total_return: latest.unit_nav - 1.0,
|
||||
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
|
||||
},
|
||||
benchmark_close: latest.benchmark_close,
|
||||
daily_fill_count,
|
||||
cumulative_trade_count: result.fills.len(),
|
||||
@@ -1891,12 +1816,8 @@ 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();
|
||||
@@ -2093,11 +2014,8 @@ where
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
let mut report = self.broker.execute_with_event_dates_and_decision_equity(
|
||||
let mut report = self.broker.execute(
|
||||
execution_date,
|
||||
decision_date,
|
||||
decision_date,
|
||||
decision_total_equity,
|
||||
&mut portfolio,
|
||||
&self.data,
|
||||
&auction_decision,
|
||||
@@ -2342,15 +2260,9 @@ where
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
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,
|
||||
)?;
|
||||
let mut intraday_report =
|
||||
self.broker
|
||||
.execute(execution_date, &mut portfolio, &self.data, &decision)?;
|
||||
let post_intraday_open_orders = self.open_order_views();
|
||||
publish_process_events(
|
||||
&mut self.strategy,
|
||||
@@ -2514,19 +2426,14 @@ where
|
||||
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 mut minute_report = self.broker.execute_between(
|
||||
execution_date,
|
||||
&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,
|
||||
@@ -2843,7 +2750,7 @@ where
|
||||
let day_fills = report.fill_events.clone();
|
||||
let broker_diagnostics = report.diagnostics.clone();
|
||||
let execution_risk_decisions = risk_decisions_from_order_events(&day_orders);
|
||||
self.extend_result(&mut result, report, decision_date, execution_date);
|
||||
self.extend_result(&mut result, report);
|
||||
result.risk_decisions.extend(decision.risk_decisions);
|
||||
result.risk_decisions.extend(execution_risk_decisions);
|
||||
|
||||
@@ -2866,21 +2773,16 @@ 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,
|
||||
@@ -2896,9 +2798,16 @@ where
|
||||
cash: latest.cash,
|
||||
market_value: latest.market_value,
|
||||
total_equity: latest.total_equity,
|
||||
external_cash_flow: latest.external_cash_flow,
|
||||
unit_nav: latest.unit_nav,
|
||||
total_return: latest.unit_nav - 1.0,
|
||||
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
|
||||
},
|
||||
benchmark_close: latest.benchmark_close,
|
||||
daily_fill_count,
|
||||
cumulative_trade_count: result.fills.len(),
|
||||
@@ -2911,7 +2820,6 @@ 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() {
|
||||
@@ -2921,7 +2829,6 @@ where
|
||||
&result.equity_curve,
|
||||
&result.fills,
|
||||
&result.daily_holdings,
|
||||
&result.account_events,
|
||||
self.aggregate_initial_cash(),
|
||||
);
|
||||
|
||||
@@ -2931,11 +2838,8 @@ where
|
||||
fn extend_result(
|
||||
&self,
|
||||
result: &mut BacktestResult,
|
||||
mut report: BrokerExecutionReport,
|
||||
decision_date: NaiveDate,
|
||||
execution_date: NaiveDate,
|
||||
report: BrokerExecutionReport,
|
||||
) -> BrokerExecutionReport {
|
||||
annotate_broker_report_dates(&mut report, decision_date, decision_date, execution_date);
|
||||
result.order_events.extend(report.order_events.clone());
|
||||
result.fills.extend(report.fill_events.clone());
|
||||
result
|
||||
@@ -3159,9 +3063,6 @@ where
|
||||
);
|
||||
report.fill_events.push(FillEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: None,
|
||||
symbol: receivable.symbol.clone(),
|
||||
side: OrderSide::Buy,
|
||||
@@ -3170,7 +3071,6 @@ 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(),
|
||||
});
|
||||
@@ -3220,12 +3120,9 @@ where
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
) -> BrokerExecutionReport {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
for flow in portfolio
|
||||
.settle_pending_cash_flows(date)
|
||||
.map_err(BacktestError::Execution)?
|
||||
{
|
||||
for flow in portfolio.settle_pending_cash_flows(date) {
|
||||
let cash_before = portfolio.cash() - flow.amount;
|
||||
let note = format!(
|
||||
"deposit_withdraw_settled amount={:.2} payable_date={} reason={}",
|
||||
@@ -3240,7 +3137,7 @@ where
|
||||
note,
|
||||
});
|
||||
}
|
||||
Ok(report)
|
||||
report
|
||||
}
|
||||
|
||||
fn settle_futures_expirations(&mut self, date: NaiveDate) -> BrokerExecutionReport {
|
||||
@@ -3404,13 +3301,13 @@ where
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn audit_unresolved_delisted_positions(
|
||||
fn settle_delisted_positions(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &PortfolioState,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
) -> BrokerExecutionReport {
|
||||
let report = BrokerExecutionReport::default();
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
|
||||
for symbol in symbols {
|
||||
let Some(position) = portfolio.position(&symbol) else {
|
||||
@@ -3422,29 +3319,92 @@ where
|
||||
let Some(instrument) = self.data.instrument(&symbol) else {
|
||||
continue;
|
||||
};
|
||||
let is_unresolved = instrument.is_delisted_on_or_before(date)
|
||||
let should_settle = 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 !is_unresolved {
|
||||
if !should_settle {
|
||||
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);
|
||||
let reason = format!(
|
||||
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
|
||||
);
|
||||
if instrument.delisted_at == Some(date) || instrument.delisted_at.is_none() {
|
||||
notes.push(reason.clone());
|
||||
if !settlement_price.is_finite() || settlement_price <= 0.0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"missing delisting settlement price for {} on {}",
|
||||
symbol, date
|
||||
)));
|
||||
}
|
||||
|
||||
let cash_before = portfolio.cash();
|
||||
let gross_amount = settlement_price * quantity as f64;
|
||||
let realized_pnl_delta = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&symbol)
|
||||
.expect("position exists for delisting settlement");
|
||||
position
|
||||
.sell(quantity, settlement_price)
|
||||
.map_err(BacktestError::Execution)?
|
||||
};
|
||||
portfolio.apply_cash_delta(gross_amount);
|
||||
portfolio.prune_flat_positions();
|
||||
|
||||
let reason = format!(
|
||||
"delisted_cash_settlement effective_date={} status={}",
|
||||
effective_delisted_at, instrument.status
|
||||
);
|
||||
notes.push(reason.clone());
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
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,
|
||||
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,
|
||||
});
|
||||
}
|
||||
report
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3867,24 +3827,6 @@ fn merge_futures_report(target: &mut BrokerExecutionReport, incoming: FuturesExe
|
||||
target.diagnostics.extend(incoming.diagnostics);
|
||||
}
|
||||
|
||||
fn annotate_broker_report_dates(
|
||||
report: &mut BrokerExecutionReport,
|
||||
decision_date: NaiveDate,
|
||||
order_created_date: NaiveDate,
|
||||
execution_date: NaiveDate,
|
||||
) {
|
||||
for event in &mut report.order_events {
|
||||
event.decision_date.get_or_insert(decision_date);
|
||||
event.order_created_date.get_or_insert(order_created_date);
|
||||
event.execution_date.get_or_insert(execution_date);
|
||||
}
|
||||
for fill in &mut report.fill_events {
|
||||
fill.decision_date.get_or_insert(decision_date);
|
||||
fill.order_created_date.get_or_insert(order_created_date);
|
||||
fill.execution_date.get_or_insert(execution_date);
|
||||
}
|
||||
}
|
||||
|
||||
fn risk_decisions_from_order_events(order_events: &[OrderEvent]) -> Vec<FidcRiskDecisionAudit> {
|
||||
order_events
|
||||
.iter()
|
||||
@@ -4079,9 +4021,6 @@ fn futures_cancel_report(
|
||||
});
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id: Some(order.order_id),
|
||||
symbol: order.intent.symbol.clone(),
|
||||
side,
|
||||
@@ -4211,115 +4150,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScheduledBuyOnDecisionDateStrategy {
|
||||
rule: ScheduleRule,
|
||||
decision_date: NaiveDate,
|
||||
}
|
||||
|
||||
impl Strategy for ScheduledBuyOnDecisionDateStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"scheduled_buy_on_decision_date"
|
||||
}
|
||||
|
||||
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::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
quantity: 100,
|
||||
reason: "scheduled_decision_date_buy".to_string(),
|
||||
}],
|
||||
..StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -4949,146 +4779,9 @@ mod tests {
|
||||
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].date, d(2025, 1, 3));
|
||||
assert_eq!(result.fills[0].decision_date, Some(d(2025, 1, 2)));
|
||||
assert_eq!(result.fills[0].order_created_date, Some(d(2025, 1, 2)));
|
||||
assert_eq!(result.fills[0].execution_date, Some(d(2025, 1, 3)));
|
||||
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);
|
||||
let second = d(2025, 1, 3);
|
||||
let third = d(2025, 1, 6);
|
||||
let dataset = DataSet::from_components(
|
||||
vec![default_instrument()],
|
||||
vec![
|
||||
market(first, 10.0, 11.5),
|
||||
market(second, 12.0, 13.0),
|
||||
market(third, 14.0, 15.0),
|
||||
],
|
||||
vec![factor(first), factor(second)],
|
||||
vec![candidate(first), candidate(second), candidate(third)],
|
||||
vec![benchmark(first), benchmark(second), benchmark(third)],
|
||||
)
|
||||
.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(third),
|
||||
decision_lag_trading_days: 1,
|
||||
execution_price_field: PriceField::Open,
|
||||
};
|
||||
|
||||
let result = BacktestEngine::new(
|
||||
dataset,
|
||||
ScheduledBuyOnDecisionDateStrategy {
|
||||
rule: ScheduleRule::daily("daily_signal", ScheduleStage::OnDay),
|
||||
decision_date: second,
|
||||
},
|
||||
broker,
|
||||
config,
|
||||
)
|
||||
.run()
|
||||
.expect("backtest run");
|
||||
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].date, third);
|
||||
assert_eq!(result.fills[0].decision_date, Some(second));
|
||||
assert_eq!(result.fills[0].execution_date, Some(third));
|
||||
assert_eq!(result.fills[0].price, 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_strategy_context_data_helpers_use_decision_date() {
|
||||
let first = d(2025, 1, 2);
|
||||
@@ -5124,22 +4817,9 @@ mod tests {
|
||||
.map(|snapshot| snapshot.close),
|
||||
Some(11.5)
|
||||
);
|
||||
assert_eq!(
|
||||
dataset
|
||||
.eligible_universe_on(first)
|
||||
.iter()
|
||||
.map(|row| row.symbol.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![SYMBOL.to_string()],
|
||||
"raw DataSet helper should not apply default selection risk"
|
||||
);
|
||||
let mut selection_risk_config = FidcRiskControlConfig::default();
|
||||
selection_risk_config.static_rules.reject_paused_selection = true;
|
||||
assert!(
|
||||
dataset
|
||||
.eligible_universe_on_with_risk_config(first, &selection_risk_config)
|
||||
.is_empty(),
|
||||
"explicit raw DataSet selection risk can still filter the universe"
|
||||
dataset.eligible_universe_on(first).is_empty(),
|
||||
"raw DataSet helper remains a risk-filtered universe"
|
||||
);
|
||||
assert_eq!(
|
||||
manual_ctx
|
||||
@@ -5152,7 +4832,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
manual_ctx
|
||||
.eligible_universe_on_with_risk_config(first, &selection_risk_config)
|
||||
.eligible_universe_on_with_risk_config(first, &FidcRiskControlConfig::default())
|
||||
.into_iter()
|
||||
.map(|row| row.symbol)
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -5345,30 +5025,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_execution_risk_uses_open_not_close_for_upper_limit_buy() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
||||
market(first, 10.0, 11.5),
|
||||
market_with_state(second, 11.8, 12.0, false, 12.0, 1.0),
|
||||
candidate(first),
|
||||
candidate(second),
|
||||
));
|
||||
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].date, second);
|
||||
assert_eq!(result.fills[0].price, 11.8);
|
||||
assert!(
|
||||
result
|
||||
.order_events
|
||||
.iter()
|
||||
.all(|event| !event.reason.contains("upper limit")),
|
||||
"{:?}",
|
||||
result.order_events
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_execution_risk_rejects_execution_day_st_state() {
|
||||
let first = d(2025, 1, 2);
|
||||
@@ -5503,47 +5159,6 @@ mod tests {
|
||||
assert_round_trip_sell_canceled_with_reason(&result, "open at or below lower limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_sell_risk_uses_open_not_close_for_lower_limit_sell() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let third = d(2025, 1, 6);
|
||||
let fourth = d(2025, 1, 7);
|
||||
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
||||
dataset_from_market_and_candidates(
|
||||
vec![
|
||||
market(first, 10.0, 10.5),
|
||||
market(second, 11.0, 11.5),
|
||||
market(third, 12.0, 12.5),
|
||||
market_with_state(fourth, 9.2, 9.0, false, 20.0, 9.0),
|
||||
],
|
||||
vec![
|
||||
candidate(first),
|
||||
candidate(second),
|
||||
candidate(third),
|
||||
candidate(fourth),
|
||||
],
|
||||
),
|
||||
scheduled_next_open_broker(FidcRiskControlConfig::default()),
|
||||
);
|
||||
|
||||
let sell_fill = result
|
||||
.fills
|
||||
.iter()
|
||||
.find(|fill| fill.side == OrderSide::Sell)
|
||||
.expect("sell should execute when next-open is above lower limit");
|
||||
assert_eq!(sell_fill.date, fourth);
|
||||
assert_eq!(sell_fill.price, 9.2);
|
||||
assert!(
|
||||
result
|
||||
.order_events
|
||||
.iter()
|
||||
.all(|event| !event.reason.contains("lower limit")),
|
||||
"{:?}",
|
||||
result.order_events
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_sell_respects_allow_sell_policy_on_execution_day() {
|
||||
let first = d(2025, 1, 2);
|
||||
|
||||
@@ -23,33 +23,6 @@ mod date_format {
|
||||
}
|
||||
}
|
||||
|
||||
mod optional_date_format {
|
||||
use chrono::NaiveDate;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
const FORMAT: &str = "%Y-%m-%d";
|
||||
|
||||
pub fn serialize<S>(date: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match date {
|
||||
Some(date) => serializer.serialize_some(&date.format(FORMAT).to_string()),
|
||||
None => serializer.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
value
|
||||
.map(|text| NaiveDate::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum OrderSide {
|
||||
Buy,
|
||||
@@ -90,12 +63,6 @@ impl OrderStatus {
|
||||
pub struct OrderEvent {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub decision_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub order_created_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub execution_date: Option<NaiveDate>,
|
||||
#[serde(default)]
|
||||
pub order_id: Option<u64>,
|
||||
pub symbol: String,
|
||||
@@ -110,12 +77,6 @@ pub struct OrderEvent {
|
||||
pub struct FillEvent {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub decision_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub order_created_date: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub execution_date: Option<NaiveDate>,
|
||||
#[serde(default)]
|
||||
pub order_id: Option<u64>,
|
||||
pub symbol: String,
|
||||
@@ -125,7 +86,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -746,9 +746,6 @@ impl FuturesAccountState {
|
||||
);
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
@@ -826,9 +823,6 @@ impl FuturesAccountState {
|
||||
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier;
|
||||
report.fill_events.push(FillEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol.clone(),
|
||||
side,
|
||||
@@ -837,7 +831,6 @@ 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={}",
|
||||
@@ -896,9 +889,6 @@ impl FuturesAccountState {
|
||||
});
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
@@ -925,9 +915,6 @@ impl FuturesAccountState {
|
||||
);
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
order_id,
|
||||
symbol: intent.symbol,
|
||||
side,
|
||||
|
||||
@@ -20,8 +20,7 @@ pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
|
||||
pub use broker::{
|
||||
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, RebalanceCashMode,
|
||||
SlippageModel,
|
||||
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, SlippageModel,
|
||||
};
|
||||
pub use calendar::TradingCalendar;
|
||||
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
|
||||
@@ -52,9 +51,8 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||
pub use platform_expr_strategy::{
|
||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
||||
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
||||
PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
||||
PlatformUniverseActionKind,
|
||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformSelectionQuotePlan,
|
||||
PlatformTradeAction, PlatformUniverseActionKind,
|
||||
};
|
||||
pub use platform_runtime_schema::{
|
||||
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
|
||||
@@ -67,9 +65,8 @@ pub use platform_strategy_spec::{
|
||||
StrategyExpressionActionConfig, StrategyExpressionAllocationConfig,
|
||||
StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig,
|
||||
StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig,
|
||||
StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig,
|
||||
StrategyRuntimeEnvironment, StrategyRuntimeExpressions, StrategyRuntimeSpec,
|
||||
platform_expr_config_from_spec, platform_expr_config_from_value,
|
||||
StrategyExpressionTradingConfig, 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::{
|
||||
@@ -87,10 +84,9 @@ pub use strategy::{
|
||||
};
|
||||
pub use strategy_ai::{
|
||||
ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction,
|
||||
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiHoldingCountContract,
|
||||
StrategyAiManual, StrategyAiOptimizeRequest, build_generation_prompt,
|
||||
build_optimization_prompt, built_in_strategy_manual, merge_catalog_into_manual,
|
||||
render_manual_markdown,
|
||||
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiManual,
|
||||
StrategyAiOptimizeRequest, build_generation_prompt, build_optimization_prompt,
|
||||
built_in_strategy_manual, merge_catalog_into_manual, render_manual_markdown,
|
||||
};
|
||||
pub use universe::{
|
||||
BandRegime, DynamicMarketCapBandSelector, SelectionContext, SelectionDiagnostics,
|
||||
|
||||
+19
-156
@@ -4,7 +4,7 @@ use chrono::{Datelike, NaiveDate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::engine::DailyEquityPoint;
|
||||
use crate::events::{AccountEvent, FillEvent};
|
||||
use crate::events::FillEvent;
|
||||
use crate::portfolio::HoldingSummary;
|
||||
|
||||
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
|
||||
@@ -47,11 +47,6 @@ 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,
|
||||
@@ -61,7 +56,6 @@ 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 {
|
||||
@@ -87,28 +81,12 @@ pub fn compute_backtest_metrics(
|
||||
} else {
|
||||
first_point.benchmark_close
|
||||
};
|
||||
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));
|
||||
}
|
||||
let mut returns = Vec::with_capacity(equity_curve.len());
|
||||
returns.push(pct_change(initial_cash, first_point.total_equity));
|
||||
returns.extend(
|
||||
portfolio_nav
|
||||
equity_curve
|
||||
.windows(2)
|
||||
.map(|window| pct_change(window[0], window[1])),
|
||||
.map(|window| pct_change(window[0].total_equity, window[1].total_equity)),
|
||||
);
|
||||
let mut benchmark_returns = Vec::with_capacity(equity_curve.len());
|
||||
benchmark_returns.push(pct_change(benchmark_start, first_point.benchmark_close));
|
||||
@@ -129,12 +107,15 @@ pub fn compute_backtest_metrics(
|
||||
last_point.benchmark_close / benchmark_start
|
||||
};
|
||||
let benchmark_cumulative_return = benchmark_net_value - 1.0;
|
||||
let final_nav = portfolio_nav.last().copied().unwrap_or(1.0);
|
||||
let total_return = final_nav - 1.0;
|
||||
let total_return = if initial_cash.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(last_point.total_equity / initial_cash) - 1.0
|
||||
};
|
||||
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
|
||||
total_return
|
||||
} else {
|
||||
portfolio_nav.last().copied().unwrap_or(0.0) / benchmark_net_value - 1.0
|
||||
(last_point.total_equity / initial_cash) / benchmark_net_value - 1.0
|
||||
};
|
||||
let excess_return = total_return - benchmark_cumulative_return;
|
||||
let annual_return = annualize_return(total_return, trade_days);
|
||||
@@ -151,7 +132,10 @@ 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 = portfolio_nav;
|
||||
let equity_nav = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.total_equity, initial_cash, 1.0))
|
||||
.collect::<Vec<_>>();
|
||||
let benchmark_nav_series = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0))
|
||||
@@ -170,7 +154,8 @@ 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_from_values(equity_curve, &equity_nav);
|
||||
let monthly_portfolio_returns =
|
||||
group_monthly_returns(equity_curve, initial_cash, |point| point.total_equity);
|
||||
let monthly_benchmark_returns =
|
||||
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
|
||||
let monthly_excess_returns = monthly_portfolio_returns
|
||||
@@ -272,30 +257,14 @@ pub fn compute_backtest_metrics(
|
||||
average_daily_turnover,
|
||||
total_assets: last_point.total_equity,
|
||||
cash_balance: last_point.cash,
|
||||
unit_nav: final_nav,
|
||||
unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0),
|
||||
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
|
||||
@@ -415,80 +384,6 @@ 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,
|
||||
@@ -591,8 +486,6 @@ 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(),
|
||||
@@ -606,38 +499,8 @@ 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,7 +67,6 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
|
||||
// day-level
|
||||
"signal_close",
|
||||
"benchmark_close",
|
||||
"benchmark_signal_close",
|
||||
"signal_ma5",
|
||||
"signal_ma10",
|
||||
"signal_ma20",
|
||||
@@ -141,7 +140,6 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
|
||||
"ask1_volume",
|
||||
"turnover_ratio",
|
||||
"effective_turnover_ratio",
|
||||
"up_days_stock",
|
||||
"open",
|
||||
"high",
|
||||
"low",
|
||||
@@ -158,7 +156,6 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
|
||||
"is_st",
|
||||
"is_star_st",
|
||||
"is_kcb",
|
||||
"is_bjse",
|
||||
"is_one_yuan",
|
||||
"is_new_listing",
|
||||
"allow_buy",
|
||||
@@ -228,8 +225,6 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
|
||||
"day_factor",
|
||||
"rolling_mean",
|
||||
"rolling_mean_current",
|
||||
"rolling_max_current",
|
||||
"rolling_return_stddev_current",
|
||||
"ma",
|
||||
"sma",
|
||||
"vma",
|
||||
@@ -329,12 +324,10 @@ mod tests {
|
||||
for required in [
|
||||
"signal_close",
|
||||
"benchmark_close",
|
||||
"benchmark_signal_close",
|
||||
"close",
|
||||
"avg_cost",
|
||||
"current_price",
|
||||
"stock_ma_short",
|
||||
"up_days_stock",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(required),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,8 +82,6 @@ impl Position {
|
||||
return;
|
||||
}
|
||||
|
||||
let previous_quantity = self.quantity;
|
||||
let previous_average_cost = self.average_cost;
|
||||
self.lots.push(PositionLot {
|
||||
acquired_date: date,
|
||||
quantity,
|
||||
@@ -95,18 +93,7 @@ impl Position {
|
||||
self.day_trade_quantity_delta += quantity as i32;
|
||||
self.day_buy_quantity += quantity;
|
||||
self.day_buy_value += execution_price * quantity as f64;
|
||||
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.recalculate_average_cost();
|
||||
self.refresh_day_pnl();
|
||||
}
|
||||
|
||||
@@ -130,7 +117,6 @@ impl Position {
|
||||
let mut remaining = quantity;
|
||||
let mut realized = 0.0;
|
||||
let mut realized_entry = 0.0;
|
||||
let average_cost_before_sell = self.average_cost;
|
||||
|
||||
while remaining > 0 {
|
||||
let Some(first_lot) = self.lots.first_mut() else {
|
||||
@@ -155,13 +141,7 @@ impl Position {
|
||||
self.day_trade_quantity_delta -= quantity as i32;
|
||||
self.day_sell_quantity += quantity;
|
||||
self.day_sell_value += execution_price * quantity as f64;
|
||||
if self.quantity == 0 {
|
||||
self.recalculate_average_cost();
|
||||
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
|
||||
self.average_cost = average_cost_before_sell;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
self.recalculate_average_cost();
|
||||
self.refresh_day_pnl();
|
||||
Ok(realized)
|
||||
}
|
||||
@@ -272,11 +252,7 @@ impl Position {
|
||||
}
|
||||
if let Some(lot) = self.lots.last_mut() {
|
||||
lot.price += cost / quantity as f64;
|
||||
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.recalculate_average_cost();
|
||||
}
|
||||
self.day_trade_cost += cost;
|
||||
self.refresh_day_pnl();
|
||||
@@ -394,11 +370,7 @@ impl Position {
|
||||
self.lots = scaled_lots;
|
||||
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
||||
self.last_price /= ratio;
|
||||
if self.average_cost.is_finite() && self.average_cost > 0.0 {
|
||||
self.average_cost /= ratio;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
self.recalculate_average_cost();
|
||||
self.day_split_ratio *= ratio;
|
||||
self.refresh_day_pnl();
|
||||
self.quantity as i32 - old_quantity as i32
|
||||
@@ -432,11 +404,6 @@ 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,
|
||||
@@ -470,7 +437,6 @@ 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,
|
||||
@@ -482,9 +448,7 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn starting_cash(&self) -> f64 {
|
||||
// Keep the configured opening capital stable. External flows change
|
||||
// `units`, not the meaning of this reporting field.
|
||||
self.initial_cash
|
||||
self.units
|
||||
}
|
||||
|
||||
pub fn initial_cash(&self) -> f64 {
|
||||
@@ -499,10 +463,6 @@ 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
|
||||
}
|
||||
@@ -561,17 +521,15 @@ impl PortfolioState {
|
||||
if !amount.is_finite() {
|
||||
return Err("deposit_withdraw amount must be finite".to_string());
|
||||
}
|
||||
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
|
||||
let available_cash = self.cash - self.pending_withdrawal_total();
|
||||
if amount < 0.0 && self.cash + amount < -1e-6 {
|
||||
return Err(format!(
|
||||
"insufficient cash for withdrawal amount={:.2} available_cash={:.2}",
|
||||
amount, available_cash
|
||||
"insufficient cash for withdrawal amount={:.2} cash={:.2}",
|
||||
amount, self.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(())
|
||||
}
|
||||
@@ -585,11 +543,10 @@ impl PortfolioState {
|
||||
if !amount.is_finite() {
|
||||
return Err("deposit_withdraw amount must be finite".to_string());
|
||||
}
|
||||
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
|
||||
let available_cash = self.cash - self.pending_withdrawal_total();
|
||||
if amount < 0.0 && self.cash + amount < -1e-6 {
|
||||
return Err(format!(
|
||||
"insufficient cash for scheduled withdrawal amount={:.2} available_cash={:.2}",
|
||||
amount, available_cash
|
||||
"insufficient cash for scheduled withdrawal amount={:.2} cash={:.2}",
|
||||
amount, self.cash
|
||||
));
|
||||
}
|
||||
self.pending_cash_flows.push(PendingCashFlow {
|
||||
@@ -602,74 +559,27 @@ impl PortfolioState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn settle_pending_cash_flows(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
) -> Result<Vec<PendingCashFlow>, String> {
|
||||
let mut due = Vec::new();
|
||||
pub fn settle_pending_cash_flows(&mut self, date: NaiveDate) -> Vec<PendingCashFlow> {
|
||||
let mut settled = Vec::new();
|
||||
let mut pending = Vec::new();
|
||||
for flow in std::mem::take(&mut self.pending_cash_flows) {
|
||||
if flow.payable_date <= date {
|
||||
due.push(flow);
|
||||
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);
|
||||
} 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;
|
||||
Ok(settled)
|
||||
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());
|
||||
@@ -760,18 +670,6 @@ 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
|
||||
@@ -939,7 +837,6 @@ 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
|
||||
@@ -975,8 +872,6 @@ 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;
|
||||
@@ -984,30 +879,7 @@ impl PortfolioState {
|
||||
if converted_last_price > 0.0 {
|
||||
successor.last_price = converted_last_price;
|
||||
}
|
||||
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.recalculate_average_cost();
|
||||
successor.refresh_day_pnl();
|
||||
|
||||
Some(SuccessorConversionOutcome {
|
||||
@@ -1083,45 +955,6 @@ mod tests {
|
||||
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_sell_preserves_remaining_average_cost() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut position = Position::new("603958.SH");
|
||||
position.buy(date, 800, 18.0981);
|
||||
position.record_buy_trade_cost(800, 5.0);
|
||||
position.buy(date, 1700, 19.4694);
|
||||
position.record_buy_trade_cost(1700, 8.27451625);
|
||||
position.buy(date, 200, 18.4584);
|
||||
position.record_buy_trade_cost(200, 5.0);
|
||||
position.buy(date, 100, 17.8378);
|
||||
position.record_buy_trade_cost(100, 5.0);
|
||||
let average_cost_before = position.average_cost;
|
||||
|
||||
position.sell(2700, 16.8331).expect("partial sell");
|
||||
|
||||
assert_eq!(position.quantity, 100);
|
||||
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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();
|
||||
@@ -1645,51 +1478,6 @@ 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)]
|
||||
|
||||
@@ -45,27 +45,27 @@ pub struct StaticRiskRuleConfig {
|
||||
impl Default for StaticRiskRuleConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
reject_st_selection: false,
|
||||
reject_st_selection: true,
|
||||
reject_st_buy: true,
|
||||
reject_star_st_selection: false,
|
||||
reject_star_st_selection: true,
|
||||
reject_star_st_buy: true,
|
||||
reject_paused_selection: false,
|
||||
reject_paused_selection: true,
|
||||
reject_paused_buy: true,
|
||||
reject_paused_sell: true,
|
||||
reject_inactive_selection: false,
|
||||
reject_inactive_selection: true,
|
||||
reject_inactive_buy: true,
|
||||
reject_inactive_sell: true,
|
||||
reject_new_listing_selection: false,
|
||||
reject_new_listing_selection: true,
|
||||
reject_new_listing_buy: true,
|
||||
reject_kcb_selection: false,
|
||||
reject_kcb_selection: true,
|
||||
reject_kcb_buy: true,
|
||||
reject_bjse_selection: false,
|
||||
reject_bjse_selection: true,
|
||||
reject_bjse_buy: true,
|
||||
reject_one_yuan_selection: false,
|
||||
reject_one_yuan_selection: true,
|
||||
reject_one_yuan_buy: true,
|
||||
respect_allow_buy_sell: true,
|
||||
reject_upper_limit_selection: false,
|
||||
reject_lower_limit_selection: false,
|
||||
reject_upper_limit_selection: true,
|
||||
reject_lower_limit_selection: true,
|
||||
reject_upper_limit_buy: true,
|
||||
reject_lower_limit_sell: true,
|
||||
forbid_same_day_rebuy_after_sell: true,
|
||||
@@ -82,7 +82,6 @@ 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,
|
||||
@@ -96,7 +95,6 @@ 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)
|
||||
@@ -201,7 +199,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");
|
||||
}
|
||||
@@ -225,21 +223,6 @@ 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,
|
||||
@@ -272,6 +255,11 @@ impl ChinaAShareRiskControl {
|
||||
) {
|
||||
return Some(reason);
|
||||
}
|
||||
if config.static_rules.respect_allow_buy_sell
|
||||
&& (!candidate.allow_buy || !candidate.allow_sell)
|
||||
{
|
||||
return Some("trade_disabled");
|
||||
}
|
||||
let selection_price = market.price(PriceField::Last);
|
||||
if config.static_rules.reject_upper_limit_selection
|
||||
&& market.is_at_upper_limit_price(selection_price)
|
||||
@@ -518,9 +506,10 @@ impl ChinaAShareRiskControl {
|
||||
if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) {
|
||||
return Some("paused");
|
||||
}
|
||||
if config.static_rules.respect_allow_buy_sell && !candidate.allow_sell {
|
||||
return Some("sell_disabled");
|
||||
}
|
||||
// `allow_sell` is derived from the daily candidate snapshot and may
|
||||
// reflect an open/close fallback rather than the actual execution price.
|
||||
// A sell order must be blocked by the execution price lower-limit check
|
||||
// below, while suspension and delisting are handled above.
|
||||
if config.static_rules.reject_lower_limit_sell
|
||||
&& market.is_at_lower_limit_price(check_price)
|
||||
{
|
||||
@@ -601,7 +590,8 @@ fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlCon
|
||||
|| config.static_rules.reject_bjse_selection
|
||||
|| config.static_rules.reject_one_yuan_selection
|
||||
|| config.static_rules.reject_upper_limit_selection
|
||||
|| config.static_rules.reject_lower_limit_selection;
|
||||
|| config.static_rules.reject_lower_limit_selection
|
||||
|| config.static_rules.respect_allow_buy_sell;
|
||||
}
|
||||
missing_field_rejected(&fields, config, RiskCheckScope::Selection)
|
||||
}
|
||||
@@ -697,13 +687,15 @@ fn missing_single_field_rejected(
|
||||
RiskCheckScope::Sell => false,
|
||||
},
|
||||
"allow_buy" => match scope {
|
||||
RiskCheckScope::Selection => false,
|
||||
RiskCheckScope::Buy => config.static_rules.respect_allow_buy_sell,
|
||||
RiskCheckScope::Selection | RiskCheckScope::Buy => {
|
||||
config.static_rules.respect_allow_buy_sell
|
||||
}
|
||||
RiskCheckScope::Sell => false,
|
||||
},
|
||||
"allow_sell" => match scope {
|
||||
RiskCheckScope::Selection => false,
|
||||
RiskCheckScope::Sell => config.static_rules.respect_allow_buy_sell,
|
||||
RiskCheckScope::Selection | RiskCheckScope::Sell => {
|
||||
config.static_rules.respect_allow_buy_sell
|
||||
}
|
||||
RiskCheckScope::Buy => false,
|
||||
},
|
||||
"upper_limit" | "upper_limit_price" | "high_limit" | "high_limit_price" => match scope {
|
||||
@@ -728,6 +720,7 @@ fn missing_single_field_rejected(
|
||||
|| config.static_rules.reject_one_yuan_selection
|
||||
|| config.static_rules.reject_upper_limit_selection
|
||||
|| config.static_rules.reject_lower_limit_selection
|
||||
|| config.static_rules.respect_allow_buy_sell
|
||||
}
|
||||
RiskCheckScope::Buy => {
|
||||
config.static_rules.reject_st_buy
|
||||
@@ -767,45 +760,6 @@ 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,
|
||||
@@ -855,7 +809,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
|
||||
fn sell_rejection_uses_execution_price_not_stale_allow_sell() {
|
||||
let prev_date = d(2024, 4, 16);
|
||||
let date = d(2024, 4, 17);
|
||||
let candidate = candidate(date);
|
||||
@@ -871,29 +825,14 @@ mod tests {
|
||||
6.27,
|
||||
);
|
||||
|
||||
assert_eq!(reason, Some("sell_disabled"));
|
||||
|
||||
let mut relaxed = FidcRiskControlConfig::default();
|
||||
relaxed.static_rules.respect_allow_buy_sell = false;
|
||||
let relaxed_reason = ChinaAShareRiskControl::sell_rejection_reason_with_config(
|
||||
date,
|
||||
&candidate,
|
||||
&market,
|
||||
None,
|
||||
Some(&position),
|
||||
6.27,
|
||||
&relaxed,
|
||||
);
|
||||
|
||||
assert_eq!(relaxed_reason, None);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sell_rejection_blocks_execution_price_at_lower_limit() {
|
||||
let prev_date = d(2024, 4, 16);
|
||||
let date = d(2024, 4, 17);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.allow_sell = true;
|
||||
let candidate = candidate(date);
|
||||
let market = market(date, 5.63, 5.63);
|
||||
let position = position(prev_date);
|
||||
|
||||
@@ -940,19 +879,13 @@ mod tests {
|
||||
let default_reason =
|
||||
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_kcb_selection = true;
|
||||
let enabled_selection_reason =
|
||||
ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
config.static_rules.reject_kcb_selection = false;
|
||||
config.static_rules.reject_kcb_buy = false;
|
||||
let configured_reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, 6.27, &config,
|
||||
);
|
||||
|
||||
assert_eq!(default_reason, None);
|
||||
assert_eq!(enabled_selection_reason, Some("kcb"));
|
||||
assert_eq!(default_reason, Some("kcb"));
|
||||
assert_eq!(configured_reason, None);
|
||||
}
|
||||
|
||||
@@ -1061,10 +994,6 @@ mod tests {
|
||||
let default_buy =
|
||||
ChinaAShareRiskControl::buy_rejection_reason(date, &candidate, &market, None, 6.27);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_bjse_selection = true;
|
||||
let enabled_selection = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
config.static_rules.reject_bjse_selection = false;
|
||||
config.static_rules.reject_bjse_buy = false;
|
||||
let configured_selection = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
@@ -1074,9 +1003,8 @@ mod tests {
|
||||
date, &candidate, &market, None, 6.27, &config,
|
||||
);
|
||||
|
||||
assert_eq!(default_selection, None);
|
||||
assert_eq!(default_selection, Some("bjse"));
|
||||
assert_eq!(default_buy, Some("bjse"));
|
||||
assert_eq!(enabled_selection, Some("bjse"));
|
||||
assert_eq!(configured_selection, None);
|
||||
assert_eq!(configured_buy, None);
|
||||
}
|
||||
@@ -1088,11 +1016,13 @@ mod tests {
|
||||
candidate.symbol = "688506.SH".to_string();
|
||||
candidate.risk_level_code = Some("missing_risk_state".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_kcb_selection = true;
|
||||
|
||||
let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
date,
|
||||
&candidate,
|
||||
&market,
|
||||
None,
|
||||
&FidcRiskControlConfig::default(),
|
||||
)
|
||||
.expect("kcb selection rejection");
|
||||
|
||||
@@ -1107,13 +1037,9 @@ mod tests {
|
||||
candidate.allow_sell = true;
|
||||
candidate.risk_level_code = Some("inactive_or_delisted".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_inactive_selection = true;
|
||||
|
||||
assert_eq!(
|
||||
ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config
|
||||
),
|
||||
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None),
|
||||
Some("inactive_or_delisted")
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1139,10 +1065,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_risk_state_default_selection_ignores_allow_flags_but_buy_rejects() {
|
||||
fn missing_risk_state_rejects_selection_and_buy_when_static_filters_enabled() {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.allow_sell = true;
|
||||
candidate.risk_level_code = Some("missing_risk_state:is_st,allow_buy".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
|
||||
@@ -1159,28 +1084,11 @@ mod tests {
|
||||
6.27,
|
||||
);
|
||||
|
||||
assert_eq!(selection_reason, None);
|
||||
assert_eq!(selection_reason, Some("missing_risk_state"));
|
||||
assert_eq!(buy_reason, Some("missing_risk_state"));
|
||||
assert_eq!(sell_reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_risk_state_selection_still_respects_configured_static_filters() {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.allow_sell = true;
|
||||
candidate.risk_level_code = Some("missing_risk_state:is_st,allow_buy".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_st_selection = true;
|
||||
|
||||
let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
|
||||
assert_eq!(selection_reason, Some("missing_risk_state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_risk_state_selection_audit_keeps_missing_fields_in_reason() {
|
||||
let date = d(2025, 1, 2);
|
||||
@@ -1189,11 +1097,12 @@ mod tests {
|
||||
Some("missing_risk_state:is_st,allow_buy,upper_limit_price".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_st_selection = true;
|
||||
config.static_rules.reject_upper_limit_selection = true;
|
||||
let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
date,
|
||||
&candidate,
|
||||
&market,
|
||||
None,
|
||||
&FidcRiskControlConfig::default(),
|
||||
)
|
||||
.expect("missing risk state rejection");
|
||||
|
||||
@@ -1265,8 +1174,6 @@ mod tests {
|
||||
Some("missing_risk_state:upper_limit_price,lower_limit_price".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_upper_limit_selection = true;
|
||||
config.static_rules.reject_lower_limit_selection = true;
|
||||
config.static_rules.reject_upper_limit_selection = false;
|
||||
|
||||
let upper_disabled_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
@@ -1360,17 +1267,12 @@ mod tests {
|
||||
let default_reason =
|
||||
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_upper_limit_selection = true;
|
||||
let enabled_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
config.static_rules.reject_upper_limit_selection = false;
|
||||
let configured_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
|
||||
assert_eq!(default_reason, None);
|
||||
assert_eq!(enabled_reason, Some("upper_limit"));
|
||||
assert_eq!(default_reason, Some("upper_limit"));
|
||||
assert_eq!(configured_reason, None);
|
||||
}
|
||||
|
||||
@@ -1383,17 +1285,12 @@ mod tests {
|
||||
let default_reason =
|
||||
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.static_rules.reject_lower_limit_selection = true;
|
||||
let enabled_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
config.static_rules.reject_lower_limit_selection = false;
|
||||
let configured_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
);
|
||||
|
||||
assert_eq!(default_reason, None);
|
||||
assert_eq!(enabled_reason, Some("lower_limit"));
|
||||
assert_eq!(default_reason, Some("lower_limit"));
|
||||
assert_eq!(configured_reason, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + fill.transfer_fee)
|
||||
.map(|fill| fill.commission + fill.stamp_tax)
|
||||
.sum::<f64>();
|
||||
let avg_price = if filled_quantity == 0 {
|
||||
0.0
|
||||
@@ -1655,7 +1655,6 @@ 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,
|
||||
@@ -1685,7 +1684,6 @@ 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,
|
||||
@@ -1697,6 +1695,35 @@ 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();
|
||||
@@ -2008,6 +2035,7 @@ impl OmniMicroCapStrategy {
|
||||
Some(fill.quantity)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn projected_market_fillable_quantity(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
@@ -2019,19 +2047,18 @@ impl OmniMicroCapStrategy {
|
||||
minimum_order_quantity: u32,
|
||||
order_step_size: u32,
|
||||
allow_odd_lot_sell: bool,
|
||||
current_fill_quantity: u32,
|
||||
execution_state: &ProjectedExecutionState,
|
||||
) -> Option<u32> {
|
||||
if requested_qty == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
let snapshot = ctx.data.market(date, symbol)?;
|
||||
let constraints = self.config.risk_config.trading_constraints;
|
||||
if constraints.volume_limit_enabled && snapshot.minute_volume == 0 {
|
||||
if snapshot.minute_volume == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut max_fill = requested_qty;
|
||||
let constraints = self.config.risk_config.trading_constraints;
|
||||
if constraints.liquidity_limit_enabled {
|
||||
let top_level_liquidity = match side {
|
||||
OrderSide::Buy => snapshot.liquidity_for_buy(),
|
||||
@@ -2053,14 +2080,9 @@ impl OmniMicroCapStrategy {
|
||||
max_fill = max_fill.min(liquidity_limited);
|
||||
}
|
||||
|
||||
let consumed_turnover = execution_state
|
||||
.intraday_turnover
|
||||
.get(symbol)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(current_fill_quantity);
|
||||
let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0);
|
||||
if constraints.volume_limit_enabled {
|
||||
let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).floor()
|
||||
let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).round()
|
||||
as i64
|
||||
- consumed_turnover as i64;
|
||||
if raw_limit <= 0 {
|
||||
@@ -2108,23 +2130,6 @@ impl OmniMicroCapStrategy {
|
||||
return None;
|
||||
}
|
||||
|
||||
let requested_qty = self.projected_market_fillable_quantity(
|
||||
ctx,
|
||||
date,
|
||||
symbol,
|
||||
side,
|
||||
requested_qty,
|
||||
round_lot,
|
||||
minimum_order_quantity,
|
||||
order_step_size,
|
||||
allow_odd_lot_sell,
|
||||
0,
|
||||
execution_state,
|
||||
)?;
|
||||
if requested_qty == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(market) = ctx.data.market(date, symbol) {
|
||||
let execution_price = self.projected_execution_price(market, side);
|
||||
if execution_price.is_finite() && execution_price > 0.0 {
|
||||
@@ -2218,25 +2223,7 @@ impl OmniMicroCapStrategy {
|
||||
if remaining_qty == 0 {
|
||||
break;
|
||||
}
|
||||
let market_fillable_qty = self
|
||||
.projected_market_fillable_quantity(
|
||||
ctx,
|
||||
date,
|
||||
symbol,
|
||||
side,
|
||||
remaining_qty,
|
||||
round_lot,
|
||||
minimum_order_quantity,
|
||||
order_step_size,
|
||||
allow_odd_lot_sell,
|
||||
filled_qty,
|
||||
execution_state,
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if market_fillable_qty == 0 {
|
||||
break;
|
||||
}
|
||||
let mut take_qty = remaining_qty.min(available_qty).min(market_fillable_qty);
|
||||
let mut take_qty = remaining_qty.min(available_qty);
|
||||
if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) {
|
||||
take_qty =
|
||||
self.round_lot_quantity(take_qty, minimum_order_quantity, order_step_size);
|
||||
@@ -2406,7 +2393,10 @@ impl OmniMicroCapStrategy {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.config.stock_volume_filter_enabled {
|
||||
if self.config.strategy_name.contains("aiquant")
|
||||
|| self.config.strategy_name.contains("AiQuant")
|
||||
|| self.config.strategy_name.contains("omni")
|
||||
{
|
||||
let Some(volume_ma5) = ctx.data.market_decision_volume_moving_average(
|
||||
date,
|
||||
symbol,
|
||||
@@ -2484,7 +2474,11 @@ impl OmniMicroCapStrategy {
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
date: NaiveDate,
|
||||
defer_selection_risk: bool,
|
||||
) -> Vec<FidcRiskDecisionAudit> {
|
||||
if defer_selection_risk {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut decisions = Vec::new();
|
||||
for factor in ctx.data.factor_snapshots_on(date) {
|
||||
if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) {
|
||||
@@ -2544,8 +2538,13 @@ impl OmniMicroCapStrategy {
|
||||
date: NaiveDate,
|
||||
band_low: f64,
|
||||
band_high: f64,
|
||||
defer_selection_risk: bool,
|
||||
) -> Result<(Vec<String>, Vec<String>), BacktestError> {
|
||||
let universe = ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config);
|
||||
let universe = if defer_selection_risk {
|
||||
ctx.fundamental_universe_on(date)
|
||||
} else {
|
||||
ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config)
|
||||
};
|
||||
let mut diagnostics = Vec::new();
|
||||
let mut selected = Vec::new();
|
||||
let start = lower_bound_eligible(&universe, band_low);
|
||||
@@ -2554,8 +2553,12 @@ impl OmniMicroCapStrategy {
|
||||
if candidate.market_cap_bn > band_high {
|
||||
break;
|
||||
}
|
||||
let rejection = (!self.stock_passes_ma_filter(ctx, date, &candidate.symbol))
|
||||
.then_some("ma_filter".to_string());
|
||||
let rejection = if defer_selection_risk {
|
||||
(!self.stock_passes_ma_filter(ctx, date, &candidate.symbol))
|
||||
.then_some("ma_filter".to_string())
|
||||
} else {
|
||||
self.buy_rejection_reason(ctx, date, &candidate.symbol)?
|
||||
};
|
||||
if let Some(reason) = rejection {
|
||||
if diagnostics.len() < 12 {
|
||||
diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason));
|
||||
@@ -2581,7 +2584,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||
let signal_date = ctx.decision_date;
|
||||
let execution_date = ctx.execution_date;
|
||||
let lagged_execution = ctx.is_lagged_execution();
|
||||
let defer_selection_risk = ctx.is_lagged_execution();
|
||||
if self.config.in_skip_window(signal_date) {
|
||||
return Ok(StrategyDecision {
|
||||
rebalance: false,
|
||||
@@ -2592,12 +2595,9 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
.positions()
|
||||
.keys()
|
||||
.cloned()
|
||||
.map(|symbol| OrderIntent::TimedTargetValue {
|
||||
.map(|symbol| OrderIntent::TargetValue {
|
||||
symbol,
|
||||
target_value: 0.0,
|
||||
style: AlgoOrderStyle::Twap,
|
||||
start_time: Some(self.intraday_execution_start_time()),
|
||||
end_time: Some(self.intraday_execution_start_time()),
|
||||
reason: "seasonal_stop_window".to_string(),
|
||||
})
|
||||
.collect(),
|
||||
@@ -2630,8 +2630,8 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
// 使用前一交易日的指数价格计算市值区间(模拟实盘场景)
|
||||
let (band_low, band_high) = self.market_cap_band(prev_index_level);
|
||||
let (stock_list, selection_notes) =
|
||||
self.select_symbols(ctx, signal_date, band_low, band_high)?;
|
||||
let risk_decisions = self.selection_risk_decisions(ctx, signal_date);
|
||||
self.select_symbols(ctx, signal_date, band_low, band_high, defer_selection_risk)?;
|
||||
let risk_decisions = self.selection_risk_decisions(ctx, signal_date, defer_selection_risk);
|
||||
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
|
||||
let projection_date = signal_date;
|
||||
let mut projected = ctx.portfolio.clone();
|
||||
@@ -2656,8 +2656,8 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
<= position.average_cost * self.config.stop_loss_ratio
|
||||
+ self.stop_loss_tolerance(market);
|
||||
let profit_hit = current_price / position.average_cost > self.config.take_profit_ratio;
|
||||
let can_sell =
|
||||
lagged_execution || self.can_sell_position(ctx, execution_date, &position.symbol);
|
||||
let can_sell = defer_selection_risk
|
||||
|| self.can_sell_position(ctx, execution_date, &position.symbol);
|
||||
let at_upper_limit = market.is_at_upper_limit_price(current_price);
|
||||
if stop_hit || (profit_hit && !at_upper_limit) {
|
||||
let sell_reason = if stop_hit {
|
||||
@@ -2693,7 +2693,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !lagged_execution
|
||||
if !defer_selection_risk
|
||||
&& self
|
||||
.buy_rejection_reason(ctx, execution_date, symbol)?
|
||||
.is_some()
|
||||
@@ -2731,7 +2731,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
if stock_list.iter().any(|candidate| candidate == symbol) {
|
||||
continue;
|
||||
}
|
||||
if !lagged_execution && !self.can_sell_position(ctx, execution_date, symbol) {
|
||||
if !defer_selection_risk && !self.can_sell_position(ctx, execution_date, symbol) {
|
||||
continue;
|
||||
}
|
||||
order_intents.push(OrderIntent::TargetValue {
|
||||
@@ -2759,7 +2759,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !lagged_execution
|
||||
if !defer_selection_risk
|
||||
&& self
|
||||
.buy_rejection_reason(ctx, execution_date, symbol)?
|
||||
.is_some()
|
||||
@@ -2988,30 +2988,35 @@ 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)
|
||||
.select_symbols(&ctx, dates[2], 0.0, 100.0, false)
|
||||
.expect("default selection");
|
||||
assert_eq!(default_selected, vec![symbol.to_string()]);
|
||||
let default_risk_decisions = default_strategy.selection_risk_decisions(&ctx, dates[2]);
|
||||
assert!(default_risk_decisions.is_empty());
|
||||
|
||||
let mut cfg = default_cfg;
|
||||
cfg.risk_config.static_rules.reject_kcb_selection = true;
|
||||
let strict_strategy = OmniMicroCapStrategy::new(cfg);
|
||||
let (selected, _) = strict_strategy
|
||||
.select_symbols(&ctx, dates[2], 0.0, 100.0)
|
||||
.expect("strict selection");
|
||||
assert!(selected.is_empty());
|
||||
let strict_risk_decisions = strict_strategy.selection_risk_decisions(&ctx, dates[2]);
|
||||
assert_eq!(strict_risk_decisions.len(), 1);
|
||||
assert_eq!(strict_risk_decisions[0].symbol, symbol);
|
||||
assert_eq!(strict_risk_decisions[0].rule_code, "kcb");
|
||||
assert!(default_selected.is_empty());
|
||||
let default_risk_decisions =
|
||||
default_strategy.selection_risk_decisions(&ctx, dates[2], false);
|
||||
assert_eq!(default_risk_decisions.len(), 1);
|
||||
assert_eq!(default_risk_decisions[0].symbol, symbol);
|
||||
assert_eq!(default_risk_decisions[0].rule_code, "kcb");
|
||||
assert!(
|
||||
strict_risk_decisions[0]
|
||||
default_risk_decisions[0]
|
||||
.diagnostic_line()
|
||||
.starts_with("risk_decision=")
|
||||
);
|
||||
|
||||
let mut cfg = default_cfg;
|
||||
cfg.risk_config.static_rules.reject_kcb_selection = false;
|
||||
cfg.risk_config.static_rules.reject_kcb_buy = false;
|
||||
let configured_strategy = OmniMicroCapStrategy::new(cfg);
|
||||
let (selected, _) = configured_strategy
|
||||
.select_symbols(&ctx, dates[2], 0.0, 100.0, false)
|
||||
.expect("configured selection");
|
||||
assert!(
|
||||
configured_strategy
|
||||
.selection_risk_decisions(&ctx, dates[2], false)
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
assert_eq!(selected, vec![symbol.to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,29 +72,6 @@ pub struct StrategyAiCatalog {
|
||||
pub data_lake_fields: Vec<ManualFactorSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StrategyAiHoldingCountContract {
|
||||
#[serde(
|
||||
default,
|
||||
alias = "holdingCount",
|
||||
alias = "holding_count",
|
||||
alias = "targetHoldingCount",
|
||||
alias = "target_holding_count"
|
||||
)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub count: Option<i64>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "kind",
|
||||
alias = "holdingCountMode",
|
||||
alias = "holding_count_mode",
|
||||
alias = "targetHoldingCountMode",
|
||||
alias = "target_holding_count_mode"
|
||||
)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StrategyAiGenerateRequest {
|
||||
pub user_goal: String,
|
||||
@@ -102,9 +79,6 @@ pub struct StrategyAiGenerateRequest {
|
||||
pub market: String,
|
||||
pub benchmark_symbol: String,
|
||||
pub signal_symbol: String,
|
||||
#[serde(default, alias = "holdingCountContract")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -113,14 +87,9 @@ pub struct StrategyAiOptimizeRequest {
|
||||
pub objective: String,
|
||||
pub result_summary: serde_json::Value,
|
||||
pub diagnostics: Vec<String>,
|
||||
#[serde(default, alias = "holdingCountContract")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
|
||||
}
|
||||
|
||||
const 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\"";
|
||||
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益 >= 150% 即视为满足收益目标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同。";
|
||||
|
||||
pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
StrategyAiManual {
|
||||
@@ -135,7 +104,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 会在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数;不得把执行日 open/high/low/close 当成下单前已知信息,也不得用 T+1 prev_close 或 T 日估算股数直接成交;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(),
|
||||
"next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息。".to_string(),
|
||||
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
|
||||
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
|
||||
],
|
||||
@@ -250,7 +219,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;但源策略明确写出的业务选股排除属于策略本身,必须原样保留在 filter.stock_expr,并且不能反向修改冻结的 reject_*_selection 开关。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、blacklisted_symbols、volume_limit_enabled、liquidity_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。ST、*ST、停牌、退市、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不要写进 universe.exclude 或 filter.stock_expr。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||
@@ -258,7 +227,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 在 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(),
|
||||
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 解释为下单前已知数据。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "期货提交校验".to_string(),
|
||||
@@ -280,7 +249,6 @@ 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() },
|
||||
@@ -366,14 +334,14 @@ 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 与 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: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金;receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力。".to_string() },
|
||||
ManualFunction { name: "rolling_mean / sma / ma".to_string(), signature: "rolling_mean(\"field\", lookback) / ma(\"close\", 20)".to_string(), detail: "任意字段滚动均值,支持 close、volume、amount、turnover_ratio、effective_turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 和所有数值型 extra_factors。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".to_string() },
|
||||
ManualFunction { name: "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() },
|
||||
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;需要收益率波动时先使用已注册收益率字段或发布因子,不要写 rolling_stddev(pct_change(\"close\", 1), 20)。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
|
||||
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
|
||||
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
|
||||
ManualFunction { name: "Source Lake 指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "Strategy Factory Source Lake 中已完成 PIT/as-of 审计的 source rows 字段、已发布指标或因子 artifact 会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() },
|
||||
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },
|
||||
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs) / safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法,两参数形式默认 fallback=0。".to_string() },
|
||||
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },
|
||||
ManualFunction { name: "contains/starts_with/ends_with/lower/upper/trim/strlen".to_string(), signature: "starts_with(symbol, \"60\")".to_string(), detail: "字符串辅助函数。".to_string() },
|
||||
],
|
||||
factor_sources: vec![
|
||||
@@ -471,22 +439,20 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
|
||||
out.push_str("## AI 代码生成硬约束\n");
|
||||
out.push_str("- 只输出完整 `engine-script` 代码;第一行必须是 `strategy(\"...\")`、`let`、`fn`、`const` 或 `//`。\n");
|
||||
out.push_str("- 禁止输出 Markdown、解释、推理过程、JSON 包装、手册复述或结果报告。\n");
|
||||
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`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 注入。源策略明确写出的业务选股排除必须保留为策略表达式,不能改写冻结的 selection 风控开关。\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.slippage`、`universe.exclude`。\n");
|
||||
out.push_str("- `universe.exclude` 只用于用户明确要求的业务排除项;ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量、手续费和印花税等基础风控必须写 `risk.policy(...)` 或由运行态 RiskLimits 注入。\n");
|
||||
out.push_str("- 禁止伪 DSL:`filter(...)`、`rank(...)`、`select.top(...)`、`weight.equal(...)`、`sell_rule(...)`、`backtest(...)`、`risk.max_position(...)`。\n");
|
||||
out.push_str("- 市值表达式字段只能用 `market_cap` 或 `free_float_cap`;不要使用数据库原始字段 `float_market_cap`。\n");
|
||||
out.push_str("- 任意窗口价格均线使用 `rolling_mean(\"close\", n)` 或 `ma(\"close\", n)`;任意窗口均量使用 `rolling_mean(\"volume\", n)` 或 `vma(n)`;不要使用未列出的 `ma60`、`stock_ma60`、`signal_ma60` 或 `benchmark_ma60` 变量。\n");
|
||||
out.push_str("- `rolling_mean`、`rolling_sum/min/max/stddev/zscore`、`pct_change`、`factor_value` 等 helper 的第一个参数必须是字段名或字符串字段名;不要输出 `rolling_stddev(pct_change(\"close\", 1), 20)` 这类嵌套表达式。\n");
|
||||
out.push_str("- 自定义 `fn` 必须通过参数传入运行时字段;不要用 `fn score()` 这类零参数函数直接引用 `market_cap`、`close`、`ma5` 等股票字段。\n");
|
||||
out.push_str("- `selection.market_cap_band` 必须写命名参数:`field=\"market_cap\"` 或 `field=\"free_float_cap\"`,并包含 `lower=...` 与 `upper=...`。\n");
|
||||
out.push_str(
|
||||
"- `risk.index_exposure(...)` 只能传一个表达式;不要生成 `risk.exposure(...)`。\n",
|
||||
);
|
||||
out.push_str("- `filter.stock_expr(...)` 只写 alpha 或策略明确声明的业务过滤条件;转换器不得自行把框架默认基础风控注入过滤表达式。源策略明确写出的 `!is_st`、`!is_star_st`、`!is_kcb`、`!is_bjse` 等业务选股排除必须原样保留,同时冻结的 `reject_*_selection` 值不得改变。\n");
|
||||
out.push_str("- `filter.stock_expr(...)` 只写 alpha 或业务过滤条件;不要把 `!is_st`、`!paused`、`!at_upper_limit`、`!at_lower_limit` 这类基础风控散落在过滤表达式里。\n");
|
||||
out.push_str("- 完整三元表达式 `cond ? a : b` 可在表达式参数中使用;若当前运行环境报 `Unknown operator: '?'`,先重编译并重启回测服务,不要改写策略语义掩盖运行时漂移。\n");
|
||||
out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\n");
|
||||
out.push_str("- `next_bar_open` 必须区分信号日、订单创建日和实际成交日:T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须比较实际 next-open 成交价与涨跌停价,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
|
||||
out.push_str("- 日线目标金额、目标比例和目标权重在 `next_bar_open` 下冻结 T 日收盘目标,T+1 按实际 open、滑点、卖后买延迟滑点、手续费和证券数量步长重算股数;禁止用 T+1 prev_close、T 日估算股数或 T+1 开盘后权益替代。金额预算始终严格,不能生成 `execution.strict_value_budget(false)`。\n");
|
||||
out.push_str("- `next_bar_open` 必须区分信号日、订单创建日和实际成交日:T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断,禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
|
||||
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
|
||||
out.push_str("## 语句块\n");
|
||||
for item in &manual.statement_blocks {
|
||||
@@ -562,15 +528,13 @@ 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(PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT);
|
||||
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
|
||||
prompt.push('\n');
|
||||
prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\n");
|
||||
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、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("只允许使用这些可编译语句: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("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma60,60日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0;filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;risk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,例如 reject_st_selection=true、reject_st_buy=true、reject_star_st_selection=true、reject_star_st_buy=true、reject_paused_selection=true、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=true、reject_new_listing_selection=true、reject_kcb_selection=true、reject_bjse_selection=true、reject_one_yuan_selection=true、forbid_same_day_rebuy_after_sell=true、reject_upper_limit_selection=true、reject_lower_limit_selection=true、reject_upper_limit_buy=true、reject_lower_limit_sell=true、blacklist_enabled=true、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_after_change=0.0005,不要用它表达策略择时或收益规则;完整三元表达式 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、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断,禁止用 T 日执行状态拦截 T+1 可交易订单;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n");
|
||||
prompt.push_str("回测成功但 tradeCount=0 或 holdingCount=0 是无效策略;第一版必须保持稳定买入覆盖率,复杂因子只能在后续优化中逐步加严。\n");
|
||||
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && 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.002)\n}\n\n");
|
||||
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && close > 2)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.policy(reject_st_selection=true, reject_st_buy=true, reject_star_st_selection=true, reject_star_st_buy=true, reject_paused_selection=true, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=true, reject_new_listing_selection=true, reject_kcb_selection=true, reject_bjse_selection=true, reject_one_yuan_selection=true, reject_upper_limit_selection=true, reject_lower_limit_selection=true, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5)\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
|
||||
prompt.push_str("用户目标:\n");
|
||||
prompt.push_str(&format!("- {}\n", request.user_goal));
|
||||
if !request.constraints.is_empty() {
|
||||
@@ -598,9 +562,9 @@ 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(PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT);
|
||||
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
|
||||
prompt.push('\n');
|
||||
prompt.push_str("可以使用 Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计的日频 source rows 字段、已发布指标/因子 artifact 和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;这些滚动/因子 helper 的字段参数只能是字段名或字符串字段名,不要嵌套表达式;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
|
||||
prompt.push_str("可以使用 Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计的日频 source rows 字段、已发布指标/因子 artifact 和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
|
||||
prompt.push_str("优化目标:\n");
|
||||
prompt.push_str(&format!("- {}\n\n", request.objective));
|
||||
prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n");
|
||||
@@ -629,7 +593,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn generation_prompt_uses_explicit_performance_acceptance_contract() {
|
||||
fn generation_prompt_contains_three_year_return_acceptance_target() {
|
||||
let prompt = build_generation_prompt(
|
||||
"manual",
|
||||
&StrategyAiGenerateRequest {
|
||||
@@ -638,44 +602,21 @@ mod tests {
|
||||
market: "CN_A".to_string(),
|
||||
benchmark_symbol: "000852.SH".to_string(),
|
||||
signal_symbol: "000001.SH".to_string(),
|
||||
holding_count_contract: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(prompt.contains("只能来自用户目标、请求约束或不可变 candidate/promotion contract"));
|
||||
assert!(prompt.contains("不得注入 120% 或其他默认数值"));
|
||||
assert!(!prompt.contains("总收益严格 > 120%"));
|
||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||
assert!(prompt.contains("不得把已达标策略判为失败"));
|
||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
|
||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||
assert!(prompt.contains("ClickHouse"));
|
||||
assert!(prompt.contains("T 日只生成订单意图"));
|
||||
assert!(prompt.contains("按实际成交日判断"));
|
||||
assert!(prompt.contains("禁止用 T 日执行状态拦截 T+1 可交易订单"));
|
||||
assert!(prompt.contains("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"));
|
||||
assert!(prompt.contains("reject_new_listing_buy=true"));
|
||||
assert!(prompt.contains("reject_kcb_buy=true"));
|
||||
assert!(prompt.contains("reject_bjse_buy=true"));
|
||||
assert!(prompt.contains("reject_one_yuan_buy=true"));
|
||||
assert!(prompt.contains("respect_allow_buy_sell=true"));
|
||||
assert!(prompt.contains("stamp_tax_rate_before_change=0.001"));
|
||||
assert!(prompt.contains("stamp_tax_change_date=\"2023-08-28\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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() {
|
||||
fn optimization_prompt_contains_three_year_return_acceptance_target() {
|
||||
let prompt = build_optimization_prompt(
|
||||
"manual",
|
||||
&StrategyAiOptimizeRequest {
|
||||
@@ -683,13 +624,11 @@ mod tests {
|
||||
objective: "优化收益".to_string(),
|
||||
result_summary: json!({ "total_return": 1.49 }),
|
||||
diagnostics: Vec::new(),
|
||||
holding_count_contract: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(prompt.contains("只能来自用户目标、请求约束或不可变 candidate/promotion contract"));
|
||||
assert!(prompt.contains("不得注入 120% 或其他默认数值"));
|
||||
assert!(!prompt.contains("总收益严格 > 120%"));
|
||||
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
|
||||
assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性"));
|
||||
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
|
||||
assert!(prompt.contains("不要回退 ficlaw-data"));
|
||||
assert!(prompt.contains("ClickHouse"));
|
||||
|
||||
@@ -55,11 +55,15 @@ pub struct SelectionContext<'a> {
|
||||
|
||||
impl SelectionContext<'_> {
|
||||
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
|
||||
let eligible = match (self.risk_config, self.defer_selection_risk) {
|
||||
(Some(risk_config), false) => self
|
||||
.data
|
||||
.eligible_universe_on_with_risk_config(self.decision_date, risk_config),
|
||||
_ => self.data.eligible_universe_on(self.decision_date).to_vec(),
|
||||
let eligible = if self.defer_selection_risk {
|
||||
self.data.fundamental_universe_on(self.decision_date)
|
||||
} else {
|
||||
match self.risk_config {
|
||||
Some(risk_config) => self
|
||||
.data
|
||||
.eligible_universe_on_with_risk_config(self.decision_date, risk_config),
|
||||
None => self.data.eligible_universe_on(self.decision_date).to_vec(),
|
||||
}
|
||||
};
|
||||
match self.dynamic_universe {
|
||||
Some(symbols) if !symbols.is_empty() => eligible
|
||||
@@ -71,6 +75,9 @@ impl SelectionContext<'_> {
|
||||
}
|
||||
|
||||
fn selection_risk_decisions(&self) -> Vec<FidcRiskDecisionAudit> {
|
||||
if self.defer_selection_risk {
|
||||
return Vec::new();
|
||||
}
|
||||
let default_risk_config;
|
||||
let risk_config = match self.risk_config {
|
||||
Some(value) => value,
|
||||
@@ -397,16 +404,13 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
let (_selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&risk_config),
|
||||
risk_config: Some(&FidcRiskControlConfig::default()),
|
||||
defer_selection_risk: false,
|
||||
});
|
||||
|
||||
@@ -429,7 +433,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_applies_configured_selection_risk_on_decision_date() {
|
||||
fn selector_defers_static_execution_risk_for_lagged_execution() {
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
instrument("000001.SZ"),
|
||||
@@ -455,74 +459,13 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&risk_config),
|
||||
defer_selection_risk: false,
|
||||
});
|
||||
|
||||
let selected_symbols = selected
|
||||
.iter()
|
||||
.map(|candidate| candidate.symbol.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(!selected_symbols.contains("000001.SZ"));
|
||||
assert!(!selected_symbols.contains("688001.SH"));
|
||||
assert!(selected_symbols.contains("000002.SZ"));
|
||||
assert_eq!(diagnostics.not_eligible_count, 2);
|
||||
let rules = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.map(|decision| decision.rule_code.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
|
||||
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_can_defer_configured_selection_risk_without_losing_diagnostics() {
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
instrument("000001.SZ"),
|
||||
instrument("688001.SH"),
|
||||
instrument("000002.SZ"),
|
||||
],
|
||||
vec![
|
||||
market("000001.SZ", 10.0),
|
||||
market("688001.SH", 10.0),
|
||||
market("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
factor("000001.SZ", 8.0),
|
||||
factor("688001.SH", 9.0),
|
||||
factor("000002.SZ", 10.0),
|
||||
],
|
||||
vec![
|
||||
candidate("000001.SZ", true, false),
|
||||
candidate("688001.SH", false, true),
|
||||
candidate("000002.SZ", false, false),
|
||||
],
|
||||
vec![benchmark()],
|
||||
)
|
||||
.unwrap();
|
||||
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
|
||||
let mut risk_config = FidcRiskControlConfig::default();
|
||||
risk_config.static_rules.reject_st_selection = true;
|
||||
risk_config.static_rules.reject_kcb_selection = true;
|
||||
|
||||
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: d(),
|
||||
benchmark: &benchmark(),
|
||||
reference_level: 2000.0,
|
||||
data: &data,
|
||||
dynamic_universe: None,
|
||||
risk_config: Some(&risk_config),
|
||||
risk_config: Some(&FidcRiskControlConfig::default()),
|
||||
defer_selection_risk: true,
|
||||
});
|
||||
|
||||
@@ -533,12 +476,7 @@ mod tests {
|
||||
assert!(selected_symbols.contains("000001.SZ"));
|
||||
assert!(selected_symbols.contains("688001.SH"));
|
||||
assert!(selected_symbols.contains("000002.SZ"));
|
||||
let rules = diagnostics
|
||||
.risk_decisions
|
||||
.iter()
|
||||
.map(|decision| decision.rule_code.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
|
||||
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
|
||||
assert_eq!(diagnostics.not_eligible_count, 0);
|
||||
assert!(diagnostics.risk_decisions.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,20 +63,13 @@ fn china_cost_model_applies_minimum_commission_and_stamp_tax() {
|
||||
assert_eq!(buy.stamp_tax, 0.0);
|
||||
|
||||
let sell = model.calculate(d(2023, 8, 25), OrderSide::Sell, 100_000.0);
|
||||
assert!((sell.commission - 30.0).abs() < 1e-9);
|
||||
assert!((sell.commission - 80.0).abs() < 1e-9);
|
||||
assert!((sell.stamp_tax - 100.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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()
|
||||
});
|
||||
fn aiquant_cost_model_matches_alv_run_options() {
|
||||
let model = ChinaAShareCostModel::aiquant_default();
|
||||
|
||||
let buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 49_978.84);
|
||||
assert!((buy.commission - 14.993652).abs() < 1e-9);
|
||||
@@ -137,7 +130,7 @@ fn china_cost_model_tracks_minimum_commission_per_order_id() {
|
||||
|
||||
assert!((first.commission - 5.0).abs() < 1e-9);
|
||||
assert!(second.commission.abs() < 1e-9);
|
||||
assert!((third.commission - 1.6).abs() < 1e-9);
|
||||
assert!((third.commission - 12.6).abs() < 1e-9);
|
||||
assert!((another_order.commission - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
|
||||
@@ -368,11 +368,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
first_date: buy_date,
|
||||
},
|
||||
BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel {
|
||||
commission_rate: 0.0008,
|
||||
minimum_commission: 0.0,
|
||||
..ChinaAShareCostModel::default()
|
||||
},
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
),
|
||||
|
||||
@@ -42,7 +42,7 @@ impl Strategy for BuyThenHoldStrategy {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() {
|
||||
let date1 = d(2025, 1, 2);
|
||||
let delist_date = d(2025, 1, 3);
|
||||
let date2 = d(2025, 1, 6);
|
||||
@@ -306,33 +306,24 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
);
|
||||
|
||||
let result = engine.run().expect("backtest succeeds");
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills.len(), 2);
|
||||
assert!(
|
||||
result
|
||||
.fills
|
||||
.iter()
|
||||
.all(|fill| !fill.reason.contains("delisted_cash_settlement"))
|
||||
.any(|fill| fill.reason.contains("delisted_cash_settlement")
|
||||
&& fill.symbol == "000001.SZ")
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.all(|holding| holding.symbol != "000001.SZ")
|
||||
);
|
||||
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_unresolved_delisting_audit() {
|
||||
fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
|
||||
let date1 = d(2025, 1, 2);
|
||||
let date2 = d(2025, 1, 3);
|
||||
let data = DataSet::from_components_with_actions(
|
||||
|
||||
@@ -2648,13 +2648,6 @@ 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
|
||||
|
||||
@@ -265,7 +265,7 @@ fn broker_executes_explicit_order_value_buy() {
|
||||
ask1: 10.01,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -551,7 +551,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
||||
ask1: 10.01,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -985,7 +985,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
||||
bid1: 9.99,
|
||||
ask1_volume: 1,
|
||||
bid1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -998,7 +998,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
||||
bid1: 10.19,
|
||||
ask1_volume: 1,
|
||||
bid1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -1784,7 +1784,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
||||
ask1: 10.01,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -1937,7 +1937,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
||||
fn broker_executes_intraday_last_on_start_quote_without_trade_delta() {
|
||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
vec![Instrument {
|
||||
@@ -2013,7 +2013,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
||||
ask1: 15.21,
|
||||
bid1_volume: 8,
|
||||
ask1_volume: 8,
|
||||
volume_delta: 800,
|
||||
volume_delta: 0,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -2131,7 +2131,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
||||
ask1: 10.03,
|
||||
bid1_volume: 2,
|
||||
ask1_volume: 2,
|
||||
volume_delta: 800,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -2368,7 +2368,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
||||
ask1: 10.02,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2381,7 +2381,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
||||
ask1: 10.04,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2531,7 +2531,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
||||
ask1: 10.02,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2544,7 +2544,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
||||
ask1: 10.04,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2678,7 +2678,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
ask1: 9.99,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2691,7 +2691,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
ask1: 10.02,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2704,7 +2704,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
ask1: 10.04,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2717,7 +2717,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
ask1: 10.11,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2838,7 +2838,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
ask1: 12.01,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2851,7 +2851,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
ask1: 12.04,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2864,7 +2864,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
ask1: 12.07,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
@@ -2999,7 +2999,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
||||
ask1: 10.02,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -3116,7 +3116,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
||||
ask1: 10.02,
|
||||
bid1_volume: 1,
|
||||
ask1_volume: 1,
|
||||
volume_delta: 400,
|
||||
volume_delta: 1,
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
}],
|
||||
@@ -3321,8 +3321,7 @@ 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: {:#?}",
|
||||
report
|
||||
"optimizer should skip unfunded rebalance buy when locked holding cannot be sold"
|
||||
);
|
||||
assert_eq!(
|
||||
portfolio
|
||||
@@ -3350,7 +3349,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebalance_uses_day_open_for_open_auction_valuation() {
|
||||
fn rebalance_uses_prev_close_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(
|
||||
@@ -3516,7 +3515,7 @@ fn rebalance_uses_day_open_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, 900);
|
||||
assert_eq!(target.quantity, 400);
|
||||
assert_eq!(report.fill_events.len(), 2);
|
||||
assert!(
|
||||
report
|
||||
@@ -3532,7 +3531,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
|
||||
.iter()
|
||||
.any(|fill| fill.symbol == "000002.SZ"
|
||||
&& fill.side == fidc_core::OrderSide::Buy
|
||||
&& fill.quantity == 900)
|
||||
&& fill.quantity == 400)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3715,179 +3714,14 @@ 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.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)
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,7 @@ 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. External deposits and
|
||||
withdrawals are unitized separately from trading PnL; delayed withdrawals
|
||||
are preflighted atomically at settlement.
|
||||
liability, deposit/withdraw, and position aliases.
|
||||
- Scheduler, dynamic universe, subscription guard, `history_bars`,
|
||||
`current_snapshot`, `get_price`, instruments, trading-date APIs, suspension
|
||||
and ST helpers.
|
||||
|
||||
@@ -21,17 +21,16 @@ runtime_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'fidatacenter|FIDATACENTER|/v1/backtest/data|/v1/xuntou|ClickHouse|clickhouse|CLICKHOUSE|FIDC_BT_INSTRUMENT_METADATA_CSV|FIDC_BT_WRITE_SNAPSHOTS|FIDC_BT_WRITE_CSV_SNAPSHOTS|FIDC_BT_WRITE_COMBINED_SOURCE_ROW_CACHE|write_csv_snapshot_files|write_csv\(|FIDC_RISK_RUNTIME_FILE|FIDC_RISK_RUNTIME_URL|FIDC_FIRISK_RUNTIME_FILE|FiRisk runtime snapshot' \
|
||||
'fidatacenter|FIDATACENTER|/v1/backtest/data|/v1/xuntou|ClickHouse|clickhouse|CLICKHOUSE' \
|
||||
crates Cargo.toml \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
|
||||
if [[ -n "$runtime_hits" ]]; then
|
||||
fail "legacy fidatacenter/ClickHouse/CSV snapshot/FiRisk runtime data source references are not allowed in fidc-backtest-engine" "$runtime_hits"
|
||||
fail "legacy fidatacenter/ClickHouse runtime data source references are not allowed in fidc-backtest-engine" "$runtime_hits"
|
||||
fi
|
||||
|
||||
manifest_hits="$(
|
||||
@@ -49,11 +48,10 @@ local_only_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'FICLAW_DATA_AGENT_URL|ficlaw_data\.source_rows_v1|strategy-factory-source-lake://local|FIDC_BT_WRITE_COMBINED_SOURCE_ROW_CACHE' \
|
||||
'FICLAW_DATA_AGENT_URL|ficlaw_data\.source_rows_v1|strategy-factory-source-lake://local' \
|
||||
crates Cargo.toml \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
@@ -69,8 +67,7 @@ json_query_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'/v1/query/(source-rows|daily-execution-prices|minute-execution-prices|instruments|corporate-actions)\.json' \
|
||||
@@ -89,8 +86,7 @@ fused_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'exported_fused|fidc_fused|fusion|fused|wide_table|wide table|source_rows_export|exported_daily|merged_daily|daily_merged|merged_source|materialized[_ -]source|materialized_source_rows|source_rows_materialized|融合表|融合宽表' \
|
||||
@@ -109,8 +105,7 @@ feature_store_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'research_feature_store|feature_store|FEATURE_STORE|daily_minute_current|FIDC_STRATEGY_FACTORY_ENABLE_FEATURE_STORE_CACHE|ALPHA_FACTORY_ENABLE_FEATURE_STORE_CACHE|ENABLE_FEATURE_STORE_CACHE' \
|
||||
@@ -129,8 +124,7 @@ truth_csv_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!crates/fidc-core/src/strategy_ai.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'FIDC_BT_TRUTH_STOCK_LIST_CSV|OMNI_BT_TRUTH_STOCK_LIST_CSV|OMNI_BACKTEST_TRUTH_STOCK_LIST_CSV|selection_source=truth_csv|truth_stock_list|truth_csv' \
|
||||
@@ -149,8 +143,7 @@ csv_snapshot_loader_hits="$(
|
||||
--glob '!**/docs/**' \
|
||||
--glob '!**/*.md' \
|
||||
--glob '!**/tests/**' \
|
||||
--glob '!**/*.test.rs' \
|
||||
--glob '!**/*_test.rs' \
|
||||
--glob '!**/*test*.rs' \
|
||||
--glob '!scripts/verify-no-legacy-data-source.sh' \
|
||||
'from_csv_dir|from_partitioned_dir|instruments\.csv|candidate_flags\.csv|market\.csv|benchmark\.csv' \
|
||||
crates Cargo.toml \
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
|
||||
fail() {
|
||||
local message="$1"
|
||||
local details="${2:-}"
|
||||
printf '[FAIL] %s\n' "$message" >&2
|
||||
if [[ -n "$details" ]]; then
|
||||
printf '%s\n' "$details" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_core_test() {
|
||||
local filter="$1"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
printf '[INFO] cargo test -p fidc-core %s\n' "$filter"
|
||||
if cargo test -p fidc-core "$filter" -- --nocapture 2>&1 | tee "$tmp"; then
|
||||
local passed_count
|
||||
passed_count="$(
|
||||
"$PYTHON_BIN" - "$tmp" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
count = 0
|
||||
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
|
||||
match = re.search(r"test result: ok\. (\d+) passed;", line)
|
||||
if match:
|
||||
count += int(match.group(1))
|
||||
print(count)
|
||||
PY
|
||||
)"
|
||||
rm -f "$tmp"
|
||||
if [[ "$passed_count" -le 0 ]]; then
|
||||
fail "cargo test filter matched 0 tests: package=fidc-core filter=${filter}"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
local output
|
||||
output="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
fail "cargo test failed: package=fidc-core filter=${filter}" "$output"
|
||||
}
|
||||
|
||||
run_core_test eligible_universe_does_not_require_candidate_risk_state_when_selection_risk_is_disabled
|
||||
run_core_test next_bar_open_eligible_universe_helper_does_not_block_on_decision_day_risk
|
||||
run_core_test platform_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
|
||||
run_core_test next_open_sell_limit_risk_uses_open_not_close
|
||||
run_core_test next_bar_open_sell_respects_allow_sell_policy_on_execution_day
|
||||
run_core_test volume_limit_uses_floor_for_odd_lot_sell
|
||||
run_core_test configurable_upper_limit_buy_filter_can_be_disabled
|
||||
|
||||
printf '[OK] fidc-backtest-engine runtime risk contracts passed\n'
|
||||
Reference in New Issue
Block a user