Compare commits

..

67 Commits

Author SHA1 Message Date
boris 9db2a9f79c 分离过程事件分发与结果保留 2026-08-27 09:04:48 +08:00
boris 00ec7a6d55 让显式动作继承运行调度 2026-08-27 08:37:12 +08:00
boris 5a765766e3 增加类型化开放订单改单能力 2026-08-27 08:07:07 +08:00
boris cdbd8a67de 记录分钟成交精确时间 2026-08-27 02:46:38 +08:00
boris 21cfa65af2 共享回测分钟报价索引 2026-08-27 01:37:18 +08:00
boris 5c300f8181 修正跨调度撮合流动性重复消费 2026-08-27 00:56:48 +08:00
boris 32b3122457 完善统一策略规格元数据字段 2026-08-26 23:37:42 +08:00
boris 71b4ffcecf 导出统一基准调仓规格类型 2026-08-26 23:27:32 +08:00
boris 422e5f1021 统一基准与调仓规格字段 2026-08-26 23:24:20 +08:00
boris b92a09b5ed 统一引擎执行规格字段 2026-08-26 23:18:43 +08:00
boris 32693dad30 统一执行参数规格字段 2026-08-26 23:06:20 +08:00
boris b05bd3fc1b 导出统一风控策略规格类型 2026-08-26 23:01:33 +08:00
boris 861ed483b5 修正风控别名测试断言 2026-08-26 22:59:05 +08:00
boris a72a4518d3 统一风控策略执行别名 2026-08-26 22:54:11 +08:00
boris dbaf7b45af 限定订单有效期运行模式能力 2026-08-26 22:11:16 +08:00
boris 88f5a1a0ae 实现类型化订单有效期合同 2026-08-26 19:48:21 +08:00
boris 0793473210 修正回测部分成交终态合同 2026-08-26 18:28:19 +08:00
boris 935dd47e34 允许运行态追加结算交易日历 2026-08-26 14:27:00 +08:00
boris c18306aed9 保留延迟资金到账表达式 2026-08-26 13:59:18 +08:00
boris 33370fb694 增加回测结束边界状态审计 2026-08-26 13:34:36 +08:00
boris 283bf56e9f 下推分钟报价订阅过滤 2026-08-26 07:57:23 +08:00
boris 670686681d 减少每日诊断文本临时分配 2026-08-26 06:43:36 +08:00
boris 8b246a63f0 跳过零管理费无效集合复制 2026-08-26 06:38:19 +08:00
boris 782bc640ff 减少文本因子读取临时分配 2026-08-26 05:07:19 +08:00
boris 6604afd24f 减少日频数据读取临时分配 2026-08-26 04:46:08 +08:00
boris d071a8a190 优化数值表达式helper执行路径 2026-08-26 03:25:41 +08:00
boris afef38e45e 跳过干净因子映射重复规范化 2026-08-25 22:59:30 +08:00
boris ac30d86b6a 线性构建数据集价格序列 2026-08-25 21:55:18 +08:00
boris 01cffb947c 共享固定数值因子字段名 2026-08-25 20:25:05 +08:00
boris fac5078dbf 减少数据集按股票分组字符串分配 2026-08-25 19:20:23 +08:00
boris 68ebe76f24 约束期货策略生成的数据可用性 2026-08-25 17:22:45 +08:00
boris c284cc191e 增加通用期货策略动作并修正组合净值 2026-08-25 17:10:17 +08:00
boris 90da7f8a21 将期货现金账本切换为定点并修正日度盈亏 2026-08-25 16:38:45 +08:00
boris 2b94d5148f 将股票持仓盈亏切换为定点批次账本 2026-08-25 15:59:37 +08:00
boris 2574b9375d 按顺序结算多笔现金应收 2026-08-25 15:25:18 +08:00
boris e368bad7e4 移除净值读取定点重复转换 2026-08-25 15:08:33 +08:00
boris 5b6b3682dd 冻结交易费率定点配置 2026-08-25 14:38:51 +08:00
boris 92724c6ab0 将股票执行资金切换为定点账本 2026-08-25 14:36:15 +08:00
boris c9ddff46dd 支持任意交易阶段调度时间 2026-08-25 09:32:34 +08:00
boris 5ff8ddca92 共享选股状态并合并风控扫描 2026-08-25 08:53:34 +08:00
boris 85cfdca14c 移除引擎历史事件重复复制 2026-08-25 08:34:06 +08:00
boris 5482c8a52d 合并177回测引擎运行历史
# Conflicts:
#	crates/fidc-core/src/data.rs
2026-08-25 05:36:43 +08:00
boris 2a6bbb82a6 支持原生回测事实存储 2026-08-25 05:32:18 +08:00
boris 24e4ac9284 线性合并分钟行情窗口 2026-08-25 04:17:53 +08:00
boris 81d70f18b3 跳过无业务分钟回调 2026-08-25 04:02:33 +08:00
boris 85c9d03b99 校验分钟订阅行情覆盖 2026-08-25 03:07:56 +08:00
boris a147c495af 重构分钟线事件流与订阅加载 2026-08-25 01:41:50 +08:00
boris 4cf0224d2d 移除DataSet行级Arc分配 2026-08-24 21:53:00 +08:00
boris 7503dc8517 共享回测只读数据索引 2026-08-24 19:48:14 +08:00
boris 1c04318ecf 增加定点金额精度验收模型 2026-08-24 17:25:53 +08:00
boris 4b577517a9 增加数值表达式字节码虚拟机 2026-08-24 13:46:45 +08:00
boris c52478708f 用快速哈希优化回测内部索引 2026-08-24 12:09:09 +08:00
boris 1d7ac19886 移除回测稠密索引性能回归 2026-08-24 11:53:12 +08:00
boris 0686532be0 用稠密行索引和滚动游标加速回测 2026-08-24 11:46:55 +08:00
boris 911074ae95 优化日线候选和成交量窗口索引 2026-08-24 11:34:20 +08:00
boris 555f2ab9bd 按证券索引优化表达式数据访问 2026-08-24 11:21:39 +08:00
boris a79077af17 按表达式依赖裁剪策略前置声明 2026-08-24 10:05:46 +08:00
boris 61a4172bd4 统一策略表达式执行与默认配置 2026-08-24 09:28:33 +08:00
boris 589f94e5b2 增加逐日紧凑证券索引 2026-08-24 04:04:03 +08:00
boris 8254ebbb47 压缩类型化因子并减少运行分配 2026-08-24 03:55:03 +08:00
boris ea79fdae46 减少滚动窗口重复索引开销 2026-08-24 03:36:07 +08:00
boris 2013314e4f 区分指数与股票滚动复权口径 2026-08-24 03:19:16 +08:00
boris 869c14e2b0 改用真实行情验证滚动风控 2026-08-24 02:57:42 +08:00
boris cea079a770 统一复权滚动因子计算口径 2026-08-24 02:51:58 +08:00
boris 9a7e5c7903 前置校验策略表达式语法 2026-08-23 22:58:33 +08:00
boris 279d6a100f 统一成交量滚动有效样本口径 2026-08-23 13:10:46 +08:00
boris 7afb72dca8 统一成交量滚动有效样本口径 2026-08-23 13:09:18 +08:00
24 changed files with 11147 additions and 2091 deletions
Generated
+1
View File
@@ -146,6 +146,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
name = "fidc-core"
version = "0.1.0"
dependencies = [
"ahash",
"chrono",
"indexmap",
"rayon",
+1
View File
@@ -11,6 +11,7 @@ version = "0.1.0"
authors = ["OpenAI Codex"]
[workspace.dependencies]
ahash = "=0.8.12"
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"] }
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
authors.workspace = true
[dependencies]
ahash.workspace = true
chrono.workspace = true
indexmap.workspace = true
rayon.workspace = true
File diff suppressed because it is too large Load Diff
+153 -61
View File
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
use chrono::NaiveDate;
use crate::events::OrderSide;
use crate::fixed_point::{FixedChinaAShareCostModel, FixedMoney, FixedTradingCost};
use crate::risk_control::TradingConstraintConfig;
#[derive(Debug, Clone, Copy)]
@@ -14,7 +15,20 @@ pub struct TradingCost {
impl TradingCost {
pub fn total(self) -> f64 {
self.commission + self.stamp_tax + self.transfer_fee
self.fixed_total().to_f64()
}
pub fn fixed_total(self) -> FixedMoney {
FixedMoney::checked_sum_f64([self.commission, self.stamp_tax, self.transfer_fee])
.expect("trading costs must be finite fixed-point money")
}
fn from_fixed(value: FixedTradingCost) -> Self {
Self {
commission: value.commission.to_f64(),
stamp_tax: value.stamp_tax.to_f64(),
transfer_fee: value.transfer_fee.to_f64(),
}
}
}
@@ -35,12 +49,7 @@ pub trait CostModel {
#[derive(Debug, Clone, Copy)]
pub struct ChinaAShareCostModel {
pub commission_rate: f64,
pub stamp_tax_rate_before_change: f64,
pub stamp_tax_rate_after_change: f64,
pub stamp_tax_change_date: NaiveDate,
pub minimum_commission: f64,
pub transfer_fee_rate: f64,
fixed: FixedChinaAShareCostModel,
}
impl Default for ChinaAShareCostModel {
@@ -52,42 +61,121 @@ impl Default for ChinaAShareCostModel {
impl ChinaAShareCostModel {
pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self {
Self {
commission_rate: config.commission_rate,
stamp_tax_rate_before_change: config.stamp_tax_rate_before_change,
stamp_tax_rate_after_change: config.stamp_tax_rate_after_change,
stamp_tax_change_date: config.stamp_tax_change_date,
minimum_commission: config.minimum_commission,
transfer_fee_rate: config.transfer_fee_rate,
fixed: FixedChinaAShareCostModel {
commission_rate: Self::fixed_money(config.commission_rate, "commission rate"),
stamp_tax_rate_before_change: Self::fixed_money(
config.stamp_tax_rate_before_change,
"stamp tax rate before change",
),
stamp_tax_rate_after_change: Self::fixed_money(
config.stamp_tax_rate_after_change,
"stamp tax rate after change",
),
stamp_tax_change_date: config.stamp_tax_change_date,
minimum_commission: Self::fixed_money(
config.minimum_commission,
"minimum commission",
),
transfer_fee_rate: Self::fixed_money(config.transfer_fee_rate, "transfer fee rate"),
},
}
}
pub fn set_commission_rate(&mut self, value: f64) {
self.fixed.commission_rate = Self::fixed_money(value, "commission rate");
}
pub fn set_minimum_commission(&mut self, value: f64) {
self.fixed.minimum_commission = Self::fixed_money(value, "minimum commission");
}
pub fn set_transfer_fee_rate(&mut self, value: f64) {
self.fixed.transfer_fee_rate = Self::fixed_money(value, "transfer fee rate");
}
pub fn set_stamp_tax_rate_before_change(&mut self, value: f64) {
self.fixed.stamp_tax_rate_before_change =
Self::fixed_money(value, "stamp tax rate before change");
}
pub fn set_stamp_tax_rate_after_change(&mut self, value: f64) {
self.fixed.stamp_tax_rate_after_change =
Self::fixed_money(value, "stamp tax rate after change");
}
pub fn set_stamp_tax_change_date(&mut self, value: NaiveDate) {
self.fixed.stamp_tax_change_date = value;
}
pub fn commission_rate(&self) -> f64 {
self.fixed.commission_rate.to_f64()
}
pub fn minimum_commission(&self) -> f64 {
self.fixed.minimum_commission.to_f64()
}
pub fn transfer_fee_rate(&self) -> f64 {
self.fixed.transfer_fee_rate.to_f64()
}
pub fn stamp_tax_rate_before_change(&self) -> f64 {
self.fixed.stamp_tax_rate_before_change.to_f64()
}
pub fn stamp_tax_rate_after_change(&self) -> f64 {
self.fixed.stamp_tax_rate_after_change.to_f64()
}
pub fn stamp_tax_change_date(&self) -> NaiveDate {
self.fixed.stamp_tax_change_date
}
pub fn with_commission_rate(mut self, value: f64) -> Self {
self.set_commission_rate(value);
self
}
pub fn with_minimum_commission(mut self, value: f64) -> Self {
self.set_minimum_commission(value);
self
}
pub fn with_stamp_tax_rates(mut self, before: f64, after: f64) -> Self {
self.set_stamp_tax_rate_before_change(before);
self.set_stamp_tax_rate_after_change(after);
self
}
pub fn commission_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 {
return 0.0;
}
(gross_amount * self.commission_rate).max(self.minimum_commission)
self.fixed_model()
.commission_for(Self::fixed_money(gross_amount, "gross amount"))
.to_f64()
}
pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 {
if date < self.stamp_tax_change_date {
self.stamp_tax_rate_before_change
} else {
self.stamp_tax_rate_after_change
}
self.fixed.stamp_tax_rate_for(date).to_f64()
}
pub fn stamp_tax_for(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 || side == OrderSide::Buy {
return 0.0;
}
gross_amount * self.stamp_tax_rate_for(date)
self.fixed_model()
.stamp_tax_for(date, side, Self::fixed_money(gross_amount, "gross amount"))
.to_f64()
}
pub fn transfer_fee_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 {
return 0.0;
}
gross_amount * self.transfer_fee_rate
self.fixed_model()
.transfer_fee_for(Self::fixed_money(gross_amount, "gross amount"))
.to_f64()
}
pub fn commission_for_order_fill(
@@ -100,31 +188,29 @@ impl ChinaAShareCostModel {
return 0.0;
}
let raw_commission = gross_amount * self.commission_rate;
let Some(order_id) = order_id else {
return raw_commission.max(self.minimum_commission);
return self.commission_for(gross_amount);
};
let remaining_minimum = commission_state
.entry(order_id)
.or_insert(self.minimum_commission);
if raw_commission > *remaining_minimum {
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 {
raw_commission
} else {
raw_commission - *remaining_minimum
};
*remaining_minimum = 0.0;
charged
} else {
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 {
self.minimum_commission
} else {
0.0
};
*remaining_minimum -= raw_commission;
charged
}
.or_insert(self.fixed.minimum_commission.to_f64());
let mut fixed_remaining = Self::fixed_money(*remaining_minimum, "remaining commission");
let charged = self.fixed_model().commission_for_order_fill_remaining(
Self::fixed_money(gross_amount, "gross amount"),
&mut fixed_remaining,
);
*remaining_minimum = fixed_remaining.to_f64();
charged.to_f64()
}
fn fixed_money(value: f64, label: &str) -> FixedMoney {
FixedMoney::from_f64(value)
.unwrap_or_else(|| panic!("{label} is not representable as fixed-point money: {value}"))
}
fn fixed_model(&self) -> FixedChinaAShareCostModel {
self.fixed
}
}
@@ -138,15 +224,11 @@ impl CostModel for ChinaAShareCostModel {
};
}
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,
}
TradingCost::from_fixed(self.fixed_model().calculate(
date,
side,
Self::fixed_money(gross_amount, "gross amount"),
))
}
fn calculate_with_order_state(
@@ -165,15 +247,25 @@ impl CostModel for ChinaAShareCostModel {
};
}
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 {
let fixed_model = self.fixed_model();
let fixed_gross = Self::fixed_money(gross_amount, "gross amount");
let commission = if let Some(order_id) = order_id {
let remaining = commission_state
.entry(order_id)
.or_insert(self.fixed.minimum_commission.to_f64());
let mut fixed_remaining = Self::fixed_money(*remaining, "remaining commission");
let commission =
fixed_model.commission_for_order_fill_remaining(fixed_gross, &mut fixed_remaining);
*remaining = fixed_remaining.to_f64();
commission
} else {
fixed_model.commission_for(fixed_gross)
};
TradingCost::from_fixed(FixedTradingCost {
commission,
stamp_tax,
transfer_fee,
}
stamp_tax: fixed_model.stamp_tax_for(date, side, fixed_gross),
transfer_fee: fixed_model.transfer_fee_for(fixed_gross),
})
}
}
@@ -182,13 +274,13 @@ mod tests {
use super::*;
#[test]
fn default_matches_configurable_trading_constraints() {
fn default_quantizes_fees_to_micro_yuan() {
let model = ChinaAShareCostModel::default();
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
assert!((model.commission_for(248_059.812) - 74.4179436).abs() < 1e-9);
assert!((model.commission_for(248_059.812) - 74.417944).abs() < 1e-12);
assert!(
(model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.8735035).abs() < 1e-9
(model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.873504).abs() < 1e-12
);
}
+1097 -381
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9
View File
@@ -125,6 +125,15 @@ impl ProcessEventBus {
loader.install_enabled(self, enabled_names)
}
pub fn has_listeners_for(&self, kinds: &[ProcessEventKind]) -> bool {
!self.any_listeners.is_empty()
|| kinds.iter().any(|kind| {
self.listeners
.get(kind)
.is_some_and(|listeners| !listeners.is_empty())
})
}
pub fn publish(&mut self, event: &ProcessEvent) {
if let Some(listeners) = self.listeners.get_mut(&event.kind) {
for listener in listeners {
+276 -1
View File
@@ -1,4 +1,4 @@
use chrono::NaiveDate;
use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
mod date_format {
@@ -50,6 +50,35 @@ mod optional_date_format {
}
}
mod optional_datetime_format {
use chrono::NaiveDateTime;
use serde::{self, Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f";
pub fn serialize<S>(datetime: &Option<NaiveDateTime>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match datetime {
Some(datetime) => serializer.serialize_some(&datetime.format(FORMAT).to_string()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDateTime>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
value
.map(|text| {
NaiveDateTime::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom)
})
.transpose()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum OrderSide {
Buy,
@@ -72,6 +101,7 @@ pub enum OrderStatus {
PartiallyFilled,
Canceled,
Rejected,
Expired,
}
impl OrderStatus {
@@ -82,6 +112,7 @@ impl OrderStatus {
Self::PartiallyFilled => "partially_filled",
Self::Canceled => "canceled",
Self::Rejected => "rejected",
Self::Expired => "expired",
}
}
}
@@ -106,6 +137,50 @@ pub struct OrderEvent {
pub reason: String,
}
impl OrderEvent {
pub fn validate(&self) -> Result<(), String> {
if self.symbol.trim().is_empty() || self.requested_quantity == 0 {
return Err(format!(
"invalid order identity/quantity order_id={:?} symbol={} requested={}",
self.order_id, self.symbol, self.requested_quantity
));
}
if self.filled_quantity > self.requested_quantity {
return Err(format!(
"order overfill order_id={:?} requested={} filled={}",
self.order_id, self.requested_quantity, self.filled_quantity
));
}
let quantity_valid = match self.status {
OrderStatus::Pending => self.filled_quantity < self.requested_quantity,
OrderStatus::Filled => self.filled_quantity == self.requested_quantity,
OrderStatus::PartiallyFilled => {
self.filled_quantity > 0 && self.filled_quantity < self.requested_quantity
}
OrderStatus::Canceled => self.filled_quantity < self.requested_quantity,
OrderStatus::Rejected => self.filled_quantity == 0,
OrderStatus::Expired => self.filled_quantity < self.requested_quantity,
};
if !quantity_valid {
return Err(format!(
"order status/quantity mismatch order_id={:?} status={} requested={} filled={}",
self.order_id,
self.status.as_str(),
self.requested_quantity,
self.filled_quantity
));
}
if self.reason.trim().is_empty() {
return Err(format!(
"order reason is empty order_id={:?} status={}",
self.order_id,
self.status.as_str()
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FillEvent {
#[serde(with = "date_format")]
@@ -116,6 +191,18 @@ pub struct FillEvent {
pub order_created_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub execution_date: Option<NaiveDate>,
#[serde(
default,
with = "optional_datetime_format",
skip_serializing_if = "Option::is_none"
)]
pub execution_start_timestamp: Option<NaiveDateTime>,
#[serde(
default,
with = "optional_datetime_format",
skip_serializing_if = "Option::is_none"
)]
pub execution_timestamp: Option<NaiveDateTime>,
#[serde(default)]
pub order_id: Option<u64>,
pub symbol: String,
@@ -130,6 +217,42 @@ pub struct FillEvent {
pub reason: String,
}
impl FillEvent {
pub fn validate(&self) -> Result<(), String> {
if self.symbol.trim().is_empty()
|| self.quantity == 0
|| !self.price.is_finite()
|| self.price <= 0.0
{
return Err(format!(
"invalid fill identity/quantity/price order_id={:?} symbol={} quantity={} price={}",
self.order_id, self.symbol, self.quantity, self.price
));
}
if let (Some(start), Some(end)) = (self.execution_start_timestamp, self.execution_timestamp)
{
if start > end {
return Err(format!(
"fill execution timestamp order is invalid order_id={:?} start={} end={}",
self.order_id, start, end
));
}
if start.date() != self.date || end.date() != self.date {
return Err(format!(
"fill execution timestamp date mismatch order_id={:?} fill_date={} start={} end={}",
self.order_id, self.date, start, end
));
}
} else if self.execution_start_timestamp.is_some() || self.execution_timestamp.is_some() {
return Err(format!(
"fill execution timestamp range is incomplete order_id={:?}",
self.order_id
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PositionEvent {
#[serde(with = "date_format")]
@@ -183,6 +306,9 @@ pub enum ProcessEventKind {
OrderPendingCancel,
OrderCancellationPass,
OrderCancellationReject,
OrderPendingUpdate,
OrderUpdatePass,
OrderUpdateReject,
OrderUnsolicitedUpdate,
Trade,
UniverseUpdated,
@@ -225,6 +351,9 @@ impl ProcessEventKind {
Self::OrderPendingCancel => "order_pending_cancel",
Self::OrderCancellationPass => "order_cancellation_pass",
Self::OrderCancellationReject => "order_cancellation_reject",
Self::OrderPendingUpdate => "order_pending_update",
Self::OrderUpdatePass => "order_update_pass",
Self::OrderUpdateReject => "order_update_reject",
Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
Self::Trade => "trade",
Self::UniverseUpdated => "universe_updated",
@@ -235,6 +364,38 @@ impl ProcessEventKind {
Self::AccountManagementFee => "account_management_fee",
}
}
/// Returns whether the event is part of the durable business lifecycle
/// audit. Phase boundary events are useful during interactive debugging,
/// but retaining every minute phase marker for a long run is unnecessary.
pub fn is_business_lifecycle(&self) -> bool {
matches!(
*self,
Self::PreScheduled
| Self::PostScheduled
| Self::PreOnDay
| Self::OnDay
| Self::PostOnDay
| Self::OrderPendingNew
| Self::OrderCreationPass
| Self::OrderCreationReject
| Self::OrderPendingCancel
| Self::OrderCancellationPass
| Self::OrderCancellationReject
| Self::OrderPendingUpdate
| Self::OrderUpdatePass
| Self::OrderUpdateReject
| Self::OrderUnsolicitedUpdate
| Self::Trade
| Self::UniverseUpdated
| Self::UniverseSubscribed
| Self::UniverseUnsubscribed
| Self::AccountDepositWithdraw
| Self::AccountFinanceRepay
| Self::AccountManagementFee
| Self::Settlement
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -250,3 +411,117 @@ pub struct ProcessEvent {
pub side: Option<OrderSide>,
pub detail: String,
}
#[cfg(test)]
mod tests {
use chrono::{NaiveDate, NaiveDateTime};
use super::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent {
OrderEvent {
date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
decision_date: None,
order_created_date: None,
execution_date: None,
order_id: Some(1),
symbol: "600000.SH".to_string(),
side: OrderSide::Buy,
requested_quantity: 100,
filled_quantity,
status,
reason: "test".to_string(),
}
}
#[test]
fn order_event_status_quantity_contract_is_explicit() {
assert!(order_event(OrderStatus::Pending, 0).validate().is_ok());
assert!(
order_event(OrderStatus::PartiallyFilled, 40)
.validate()
.is_ok()
);
assert!(order_event(OrderStatus::Filled, 100).validate().is_ok());
assert!(order_event(OrderStatus::Canceled, 40).validate().is_ok());
assert!(order_event(OrderStatus::Rejected, 0).validate().is_ok());
assert!(order_event(OrderStatus::Expired, 40).validate().is_ok());
assert!(
order_event(OrderStatus::PartiallyFilled, 0)
.validate()
.is_err()
);
assert!(order_event(OrderStatus::Filled, 99).validate().is_err());
assert!(order_event(OrderStatus::Canceled, 100).validate().is_err());
assert!(order_event(OrderStatus::Rejected, 1).validate().is_err());
assert!(order_event(OrderStatus::Expired, 100).validate().is_err());
}
fn fill_event(start: Option<NaiveDateTime>, end: Option<NaiveDateTime>) -> FillEvent {
FillEvent {
date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
decision_date: None,
order_created_date: None,
execution_date: None,
execution_start_timestamp: start,
execution_timestamp: end,
order_id: Some(1),
symbol: "600000.SH".to_string(),
side: OrderSide::Buy,
quantity: 100,
price: 10.0,
gross_amount: 1_000.0,
commission: 5.0,
stamp_tax: 0.0,
transfer_fee: 0.0,
net_cash_flow: -1_005.0,
reason: "test".to_string(),
}
}
#[test]
fn fill_execution_timestamp_range_is_explicit_and_backward_compatible() {
let start = NaiveDate::from_ymd_opt(2025, 1, 2)
.unwrap()
.and_hms_opt(10, 18, 0)
.unwrap();
let end = start + chrono::Duration::seconds(3);
assert!(fill_event(Some(start), Some(end)).validate().is_ok());
assert!(fill_event(Some(end), Some(start)).validate().is_err());
assert!(fill_event(Some(start), None).validate().is_err());
let next_day = start + chrono::Duration::days(1);
assert!(
fill_event(Some(next_day), Some(next_day))
.validate()
.is_err()
);
let legacy = fill_event(None, None);
let legacy_json = serde_json::to_value(&legacy).unwrap();
assert!(legacy_json.get("execution_start_timestamp").is_none());
assert!(legacy_json.get("execution_timestamp").is_none());
let decoded: FillEvent = serde_json::from_value(legacy_json).unwrap();
assert_eq!(decoded.execution_start_timestamp, None);
assert_eq!(decoded.execution_timestamp, None);
let timestamped_json = serde_json::to_value(fill_event(Some(start), Some(end))).unwrap();
assert_eq!(
timestamped_json["execution_start_timestamp"],
"2025-01-02 10:18:00"
);
assert_eq!(
timestamped_json["execution_timestamp"],
"2025-01-02 10:18:03"
);
}
#[test]
fn process_event_business_lifecycle_filter_keeps_audit_events_only() {
assert!(ProcessEventKind::OrderUpdateReject.is_business_lifecycle());
assert!(ProcessEventKind::Settlement.is_business_lifecycle());
assert!(!ProcessEventKind::PreMinute.is_business_lifecycle());
assert!(!ProcessEventKind::PostBar.is_business_lifecycle());
}
}
+566
View File
@@ -0,0 +1,566 @@
//! Fixed-point execution primitives for money and fee arithmetic.
//!
//! Market data and analytics remain floating point at their API boundaries.
//! The execution kernel quantizes monetary values to micro-yuan before fee,
//! budget and cash-ledger arithmetic so repeated fills and external cash flows
//! do not accumulate binary floating-point drift.
use std::collections::{BTreeMap, VecDeque};
use chrono::NaiveDate;
use crate::events::OrderSide;
pub const MONEY_SCALE: i128 = 1_000_000;
const MONEY_SCALE_F64: f64 = MONEY_SCALE as f64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct FixedMoney(i128);
impl FixedMoney {
pub const ZERO: Self = Self(0);
pub const fn from_raw(raw: i128) -> Self {
Self(raw)
}
pub const fn raw(self) -> i128 {
self.0
}
pub fn from_decimal_str(value: &str) -> Result<Self, String> {
let value = value.trim();
if value.is_empty() {
return Err("fixed money value is empty".to_string());
}
let (negative, unsigned) = match value.as_bytes()[0] {
b'-' => (true, &value[1..]),
b'+' => (false, &value[1..]),
_ => (false, value),
};
let mut parts = unsigned.split('.');
let whole = parts.next().unwrap_or_default();
let fractional = parts.next().unwrap_or_default();
if parts.next().is_some()
|| whole.is_empty()
|| !whole.bytes().all(|byte| byte.is_ascii_digit())
|| !fractional.bytes().all(|byte| byte.is_ascii_digit())
{
return Err(format!("invalid fixed money decimal: {value}"));
}
let whole = whole
.parse::<i128>()
.map_err(|_| format!("fixed money whole part is out of range: {value}"))?;
let mut fractional_digits = fractional.as_bytes().to_vec();
let round_up = fractional_digits.len() > 6 && fractional_digits[6] >= b'5';
fractional_digits.truncate(6);
while fractional_digits.len() < 6 {
fractional_digits.push(b'0');
}
let fractional = if fractional_digits.is_empty() {
0
} else {
std::str::from_utf8(&fractional_digits)
.expect("fractional digits are ASCII")
.parse::<i128>()
.map_err(|_| format!("fixed money fractional part is invalid: {value}"))?
};
let mut raw = whole
.checked_mul(MONEY_SCALE)
.and_then(|raw| raw.checked_add(fractional))
.ok_or_else(|| format!("fixed money value is out of range: {value}"))?;
if round_up {
raw = raw
.checked_add(1)
.ok_or_else(|| format!("fixed money value is out of range: {value}"))?;
}
Ok(Self(if negative { -raw } else { raw }))
}
pub fn from_f64(value: f64) -> Option<Self> {
if !value.is_finite() {
return None;
}
let raw = (value * MONEY_SCALE_F64).round();
if !raw.is_finite() || raw < i128::MIN as f64 || raw > i128::MAX as f64 {
return None;
}
Some(Self(raw as i128))
}
pub fn to_f64(self) -> f64 {
self.0 as f64 / MONEY_SCALE_F64
}
pub fn checked_add(self, other: Self) -> Option<Self> {
self.0.checked_add(other.0).map(Self)
}
pub fn checked_sub(self, other: Self) -> Option<Self> {
self.0.checked_sub(other.0).map(Self)
}
pub fn checked_mul_quantity(self, quantity: u64) -> Option<Self> {
self.0.checked_mul(i128::from(quantity)).map(Self)
}
pub fn checked_neg(self) -> Option<Self> {
self.0.checked_neg().map(Self)
}
pub fn checked_mul_rate(self, rate: Self) -> Option<Self> {
let product = self.0.checked_mul(rate.0)?;
let half = MONEY_SCALE / 2;
let rounded = if product >= 0 {
product.checked_add(half)? / MONEY_SCALE
} else {
product.checked_sub(half)? / MONEY_SCALE
};
Some(Self(rounded))
}
pub fn checked_sum_f64(values: impl IntoIterator<Item = f64>) -> Option<Self> {
values.into_iter().try_fold(Self::ZERO, |total, value| {
total.checked_add(Self::from_f64(value)?)
})
}
pub fn f64_fits_within(value: f64, limit: f64) -> Option<bool> {
let value = Self::from_f64(value)?;
if limit == f64::INFINITY {
return Some(true);
}
Some(value <= Self::from_f64(limit)?)
}
pub fn abs(self) -> Self {
Self(self.0.abs())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FixedTradingCost {
pub commission: FixedMoney,
pub stamp_tax: FixedMoney,
pub transfer_fee: FixedMoney,
}
impl FixedTradingCost {
pub fn total(self) -> FixedMoney {
FixedMoney::from_raw(self.commission.raw() + self.stamp_tax.raw() + self.transfer_fee.raw())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FixedChinaAShareCostModel {
pub commission_rate: FixedMoney,
pub stamp_tax_rate_before_change: FixedMoney,
pub stamp_tax_rate_after_change: FixedMoney,
pub stamp_tax_change_date: NaiveDate,
pub minimum_commission: FixedMoney,
pub transfer_fee_rate: FixedMoney,
}
impl FixedChinaAShareCostModel {
pub fn commission_for(self, gross_amount: FixedMoney) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
let raw = gross_amount
.checked_mul_rate(self.commission_rate)
.expect("fixed commission multiplication overflow");
raw.max(self.minimum_commission)
}
pub fn stamp_tax_rate_for(self, date: NaiveDate) -> FixedMoney {
if date < self.stamp_tax_change_date {
self.stamp_tax_rate_before_change
} else {
self.stamp_tax_rate_after_change
}
}
pub fn stamp_tax_for(
self,
date: NaiveDate,
side: OrderSide,
gross_amount: FixedMoney,
) -> FixedMoney {
if gross_amount.raw() <= 0 || side == OrderSide::Buy {
return FixedMoney::ZERO;
}
gross_amount
.checked_mul_rate(self.stamp_tax_rate_for(date))
.expect("fixed stamp tax multiplication overflow")
}
pub fn transfer_fee_for(self, gross_amount: FixedMoney) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
gross_amount
.checked_mul_rate(self.transfer_fee_rate)
.expect("fixed transfer fee multiplication overflow")
}
pub fn calculate(
self,
date: NaiveDate,
side: OrderSide,
gross_amount: FixedMoney,
) -> FixedTradingCost {
FixedTradingCost {
commission: self.commission_for(gross_amount),
stamp_tax: self.stamp_tax_for(date, side, gross_amount),
transfer_fee: self.transfer_fee_for(gross_amount),
}
}
pub fn commission_for_order_fill(
self,
gross_amount: FixedMoney,
order_id: Option<u64>,
commission_state: &mut BTreeMap<u64, FixedMoney>,
) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
let raw = gross_amount
.checked_mul_rate(self.commission_rate)
.expect("fixed commission multiplication overflow");
let Some(order_id) = order_id else {
return raw.max(self.minimum_commission);
};
let remaining = commission_state
.entry(order_id)
.or_insert(self.minimum_commission);
self.commission_for_order_fill_remaining(gross_amount, remaining)
}
pub fn commission_for_order_fill_remaining(
self,
gross_amount: FixedMoney,
remaining: &mut FixedMoney,
) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
let raw = gross_amount
.checked_mul_rate(self.commission_rate)
.expect("fixed commission multiplication overflow");
if raw > *remaining {
let charged = if *remaining == self.minimum_commission {
raw
} else {
raw.checked_sub(*remaining)
.expect("fixed remaining commission underflow")
};
*remaining = FixedMoney::ZERO;
charged
} else {
let charged = if *remaining == self.minimum_commission {
self.minimum_commission
} else {
FixedMoney::ZERO
};
*remaining = remaining
.checked_sub(raw)
.expect("fixed remaining commission underflow");
charged
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FixedLot {
pub acquired_date: NaiveDate,
pub quantity: u64,
pub entry_price: FixedMoney,
}
#[derive(Debug, Clone, Default)]
pub struct FixedLotBook {
lots: VecDeque<FixedLot>,
pub realized_pnl: FixedMoney,
pub quantity: u64,
}
impl FixedLotBook {
pub fn buy(&mut self, date: NaiveDate, quantity: u64, price: FixedMoney) {
if quantity == 0 {
return;
}
self.lots.push_back(FixedLot {
acquired_date: date,
quantity,
entry_price: price,
});
self.quantity = self.quantity.saturating_add(quantity);
}
pub fn sell(&mut self, quantity: u64, price: FixedMoney) -> Result<FixedMoney, String> {
if quantity > self.quantity {
return Err(format!(
"fixed sell quantity {} exceeds current quantity {}",
quantity, self.quantity
));
}
let mut remaining = quantity;
let mut realized = FixedMoney::ZERO;
while remaining > 0 {
let Some(mut lot) = self.lots.pop_front() else {
return Err("fixed lot book is empty while selling".to_string());
};
let sold = remaining.min(lot.quantity);
let price_delta = price
.checked_sub(lot.entry_price)
.and_then(|delta| delta.checked_mul_quantity(sold))
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
realized = realized
.checked_add(price_delta)
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
lot.quantity -= sold;
remaining -= sold;
if lot.quantity > 0 {
self.lots.push_front(lot);
}
}
self.quantity -= quantity;
self.realized_pnl = self
.realized_pnl
.checked_add(realized)
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
Ok(realized)
}
pub fn market_value(&self, mark_price: FixedMoney) -> FixedMoney {
mark_price
.checked_mul_quantity(self.quantity)
.expect("fixed market value overflow")
}
pub fn unrealized_pnl(&self, mark_price: FixedMoney) -> FixedMoney {
self.lots.iter().fold(FixedMoney::ZERO, |total, lot| {
let delta = mark_price
.checked_sub(lot.entry_price)
.and_then(|value| value.checked_mul_quantity(lot.quantity))
.expect("fixed unrealized PnL overflow");
total
.checked_add(delta)
.expect("fixed unrealized PnL overflow")
})
}
}
#[derive(Debug, Clone)]
pub struct FixedAccount {
pub cash: FixedMoney,
pub units: FixedMoney,
pub external_cash_flow_total: FixedMoney,
}
impl FixedAccount {
pub fn new(initial_cash: FixedMoney) -> Self {
Self {
cash: initial_cash,
units: initial_cash,
external_cash_flow_total: FixedMoney::ZERO,
}
}
pub fn apply_external_cash_flow(
&mut self,
amount: FixedMoney,
unit_nav: FixedMoney,
) -> Result<(), String> {
if unit_nav.raw() <= 0 {
return Err("fixed unit NAV must be positive".to_string());
}
let exact_units_raw = amount
.raw()
.checked_mul(MONEY_SCALE)
.and_then(|value| value.checked_div(unit_nav.raw()))
.ok_or_else(|| "fixed external flow unit conversion overflow".to_string())?;
self.cash = self
.cash
.checked_add(amount)
.ok_or_else(|| "fixed cash overflow".to_string())?;
self.units = self
.units
.checked_add(FixedMoney::from_raw(exact_units_raw))
.ok_or_else(|| "fixed units overflow".to_string())?;
self.external_cash_flow_total = self
.external_cash_flow_total
.checked_add(amount)
.ok_or_else(|| "fixed external flow overflow".to_string())?;
Ok(())
}
pub fn unit_nav(&self, total_equity: FixedMoney) -> Result<FixedMoney, String> {
if self.units.raw() <= 0 {
return Err("fixed account has no units".to_string());
}
let raw = total_equity
.raw()
.checked_mul(MONEY_SCALE)
.and_then(|value| value.checked_div(self.units.raw()))
.ok_or_else(|| "fixed unit NAV overflow".to_string())?;
Ok(FixedMoney::from_raw(raw))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cost::{ChinaAShareCostModel, CostModel};
use crate::risk_control::TradingConstraintConfig;
fn fixed_model() -> FixedChinaAShareCostModel {
let config = TradingConstraintConfig::default();
FixedChinaAShareCostModel {
commission_rate: FixedMoney::from_f64(config.commission_rate).unwrap(),
stamp_tax_rate_before_change: FixedMoney::from_f64(config.stamp_tax_rate_before_change)
.unwrap(),
stamp_tax_rate_after_change: FixedMoney::from_f64(config.stamp_tax_rate_after_change)
.unwrap(),
stamp_tax_change_date: config.stamp_tax_change_date,
minimum_commission: FixedMoney::from_f64(config.minimum_commission).unwrap(),
transfer_fee_rate: FixedMoney::from_f64(config.transfer_fee_rate).unwrap(),
}
}
#[test]
fn decimal_parser_rounds_only_beyond_money_scale() {
assert_eq!(
FixedMoney::from_decimal_str("1.234567").unwrap().raw(),
1_234_567
);
assert_eq!(
FixedMoney::from_decimal_str("1.2345675").unwrap().raw(),
1_234_568
);
assert_eq!(
FixedMoney::from_decimal_str("-0.0000014").unwrap().raw(),
-1
);
}
#[test]
fn runtime_cost_model_matches_fixed_execution_primitive() {
let fixed = fixed_model();
let float = ChinaAShareCostModel::default();
let dates = [
NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
];
for gross in [0.01, 10.0, 16_666.67, 248_059.812, 1_000_000.01] {
let fixed_gross = FixedMoney::from_f64(gross).unwrap();
for date in dates {
for side in [OrderSide::Buy, OrderSide::Sell] {
let expected = float.calculate(date, side, gross);
let actual = fixed.calculate(date, side, fixed_gross);
for (actual, expected) in [
(actual.commission, expected.commission),
(actual.stamp_tax, expected.stamp_tax),
(actual.transfer_fee, expected.transfer_fee),
] {
assert_eq!(actual.to_f64(), expected);
}
}
}
}
}
#[test]
fn runtime_split_commission_matches_fixed_execution_primitive() {
let fixed = fixed_model();
let float = ChinaAShareCostModel::default();
let mut fixed_state = BTreeMap::new();
let mut float_state = BTreeMap::new();
let mut fixed_total = FixedMoney::ZERO;
let mut float_total = 0.0;
for gross in [1000.0, 2000.0, 4000.0, 40_000.0] {
let fixed_fee = fixed.commission_for_order_fill(
FixedMoney::from_f64(gross).unwrap(),
Some(42),
&mut fixed_state,
);
let float_fee = float.commission_for_order_fill(gross, Some(42), &mut float_state);
fixed_total = fixed_total.checked_add(fixed_fee).unwrap();
float_total += float_fee;
}
assert_eq!(fixed_total.to_f64(), float_total);
}
#[test]
fn fixed_budget_never_exceeds_cash_after_cost() {
let model = fixed_model();
let date = NaiveDate::from_ymd_opt(2025, 2, 3).unwrap();
let cash = FixedMoney::from_decimal_str("99880.00").unwrap();
let price = FixedMoney::from_decimal_str("19.9731").unwrap();
let mut quantity = 5_000u64;
while quantity > 0 {
let gross = price.checked_mul_quantity(quantity).unwrap();
if gross
.checked_add(model.calculate(date, OrderSide::Buy, gross).total())
.unwrap()
<= cash
{
break;
}
quantity -= 100;
}
let gross = price.checked_mul_quantity(quantity).unwrap();
let total = gross
.checked_add(model.calculate(date, OrderSide::Buy, gross).total())
.unwrap();
assert!(total <= cash);
assert!(quantity < 5_000);
}
#[test]
fn fixed_budget_comparison_rejects_one_micro_yuan_overrun() {
assert_eq!(FixedMoney::f64_fits_within(100.0, 100.0), Some(true));
assert_eq!(FixedMoney::f64_fits_within(100.000001, 100.0), Some(false));
assert_eq!(
FixedMoney::f64_fits_within(100.000001, f64::INFINITY),
Some(true)
);
}
#[test]
fn fixed_fifo_pnl_and_external_flow_are_deterministic() {
let day_one = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let day_two = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut book = FixedLotBook::default();
book.buy(day_one, 100, FixedMoney::from_decimal_str("10.01").unwrap());
book.buy(day_two, 100, FixedMoney::from_decimal_str("10.03").unwrap());
let realized = book
.sell(150, FixedMoney::from_decimal_str("10.11").unwrap())
.unwrap();
assert_eq!(realized.raw(), 14_000_000);
assert_eq!(book.quantity, 50);
assert_eq!(
book.unrealized_pnl(FixedMoney::from_decimal_str("10.20").unwrap())
.raw(),
8_500_000
);
let mut account = FixedAccount::new(FixedMoney::from_decimal_str("100.00").unwrap());
account
.apply_external_cash_flow(
FixedMoney::from_decimal_str("50.00").unwrap(),
FixedMoney::from_decimal_str("1.00").unwrap(),
)
.unwrap();
assert_eq!(account.units.raw(), 150 * MONEY_SCALE);
assert_eq!(
account
.unit_nav(FixedMoney::from_decimal_str("150.00").unwrap())
.unwrap()
.raw(),
MONEY_SCALE
);
assert_eq!(account.external_cash_flow_total.raw(), 50 * MONEY_SCALE);
}
}
+308 -75
View File
@@ -7,6 +7,24 @@ use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind,
};
use crate::fixed_point::FixedMoney;
fn futures_money(value: f64, label: &str) -> Result<FixedMoney, String> {
FixedMoney::from_f64(value)
.ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}"))
}
fn futures_money_or_panic(value: f64, label: &str) -> FixedMoney {
futures_money(value, label).unwrap_or_else(|error| panic!("{error}"))
}
fn sum_futures_money(values: impl IntoIterator<Item = FixedMoney>, label: &str) -> FixedMoney {
values.into_iter().fold(FixedMoney::ZERO, |total, value| {
total
.checked_add(value)
.unwrap_or_else(|| panic!("fixed-point {label} overflow"))
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FuturesDirection {
@@ -345,6 +363,14 @@ pub struct FuturesExecutionReport {
}
impl FuturesContractSpec {
pub fn unresolved() -> Self {
Self {
contract_multiplier: f64::NAN,
long_margin_rate: f64::NAN,
short_margin_rate: f64::NAN,
}
}
pub fn new(contract_multiplier: f64, long_margin_rate: f64, short_margin_rate: f64) -> Self {
Self {
contract_multiplier: contract_multiplier.max(1.0),
@@ -359,6 +385,15 @@ impl FuturesContractSpec {
FuturesDirection::Short => self.short_margin_rate,
}
}
pub fn is_resolved(&self) -> bool {
self.contract_multiplier.is_finite()
&& self.contract_multiplier > 0.0
&& self.long_margin_rate.is_finite()
&& self.long_margin_rate >= 0.0
&& self.short_margin_rate.is_finite()
&& self.short_margin_rate >= 0.0
}
}
#[derive(Debug, Clone)]
@@ -366,15 +401,16 @@ pub struct FuturesPosition {
pub symbol: String,
pub direction: FuturesDirection,
pub old_quantity: u32,
day_start_quantity: u32,
pub quantity: u32,
pub avg_price: f64,
pub last_price: f64,
pub prev_close: f64,
pub contract_multiplier: f64,
pub margin_rate: f64,
pub transaction_cost: f64,
transaction_cost: FixedMoney,
trade_quantity_delta: i32,
trade_cost: f64,
trade_value: FixedMoney,
}
impl FuturesPosition {
@@ -390,15 +426,16 @@ impl FuturesPosition {
symbol: symbol.into(),
direction,
old_quantity: init_quantity,
day_start_quantity: init_quantity,
quantity: init_quantity,
avg_price: init_price.max(0.0),
last_price: init_price.max(0.0),
prev_close: init_price.max(0.0),
contract_multiplier: spec.contract_multiplier,
margin_rate,
transaction_cost: 0.0,
transaction_cost: FixedMoney::ZERO,
trade_quantity_delta: 0,
trade_cost: 0.0,
trade_value: FixedMoney::ZERO,
}
}
@@ -407,18 +444,39 @@ impl FuturesPosition {
}
pub fn market_value(&self) -> f64 {
self.quantity as f64 * self.last_price * self.contract_multiplier
self.market_value_money().to_f64()
}
fn market_value_money(&self) -> FixedMoney {
futures_money_or_panic(
self.quantity as f64 * self.last_price * self.contract_multiplier,
"futures position market value",
)
}
pub fn margin(&self) -> f64 {
self.market_value() * self.margin_rate
self.margin_money().to_f64()
}
fn margin_money(&self) -> FixedMoney {
futures_money_or_panic(
self.market_value_money().to_f64() * self.margin_rate,
"futures position margin",
)
}
pub fn equity(&self) -> f64 {
(self.last_price - self.avg_price)
* self.quantity as f64
* self.contract_multiplier
* self.direction.factor()
self.equity_money().to_f64()
}
fn equity_money(&self) -> FixedMoney {
futures_money_or_panic(
(self.last_price - self.avg_price)
* self.quantity as f64
* self.contract_multiplier
* self.direction.factor(),
"futures position equity",
)
}
pub fn pnl(&self) -> f64 {
@@ -426,22 +484,47 @@ impl FuturesPosition {
}
pub fn trading_pnl(&self) -> f64 {
(self.trade_quantity_delta as f64 * self.last_price - self.trade_cost)
* self.contract_multiplier
* self.direction.factor()
self.trading_pnl_money().to_f64()
}
fn trading_pnl_money(&self) -> FixedMoney {
let marked_trade_value = futures_money_or_panic(
self.trade_quantity_delta as f64 * self.last_price * self.contract_multiplier,
"futures marked trade value",
);
let pnl = marked_trade_value
.checked_sub(self.trade_value)
.expect("fixed-point futures trading PnL overflow");
if self.direction == FuturesDirection::Short {
pnl.checked_neg()
.expect("fixed-point futures short trading PnL overflow")
} else {
pnl
}
}
pub fn position_pnl(&self) -> f64 {
if self.old_quantity == 0 {
0.0
self.position_pnl_money().to_f64()
}
fn position_pnl_money(&self) -> FixedMoney {
if self.day_start_quantity == 0 {
FixedMoney::ZERO
} else {
self.old_quantity as f64
* (self.last_price - self.prev_close)
* self.contract_multiplier
* self.direction.factor()
futures_money_or_panic(
self.day_start_quantity as f64
* (self.last_price - self.prev_close)
* self.contract_multiplier
* self.direction.factor(),
"futures position daily PnL",
)
}
}
pub fn transaction_cost(&self) -> f64 {
self.transaction_cost.to_f64()
}
pub fn open(&mut self, quantity: u32, price: f64, transaction_cost: f64) {
if quantity == 0 {
return;
@@ -450,9 +533,20 @@ impl FuturesPosition {
self.quantity += quantity;
self.avg_price = (old_value + price * quantity as f64) / self.quantity as f64;
self.last_price = price;
self.transaction_cost += transaction_cost.max(0.0);
let transaction_cost =
futures_money_or_panic(transaction_cost.max(0.0), "futures open transaction cost");
self.transaction_cost = self
.transaction_cost
.checked_add(transaction_cost)
.expect("fixed-point futures transaction cost overflow");
self.trade_quantity_delta += quantity as i32;
self.trade_cost += price * quantity as f64;
self.trade_value = self
.trade_value
.checked_add(futures_money_or_panic(
price * quantity as f64 * self.contract_multiplier,
"futures open trade value",
))
.expect("fixed-point futures trade value overflow");
}
pub fn close(
@@ -476,6 +570,17 @@ impl FuturesPosition {
transaction_cost: f64,
effect: FuturesPositionEffect,
) -> Result<f64, String> {
self.close_with_effect_money(quantity, price, transaction_cost, effect)
.map(FixedMoney::to_f64)
}
fn close_with_effect_money(
&mut self,
quantity: u32,
price: f64,
transaction_cost: f64,
effect: FuturesPositionEffect,
) -> Result<FixedMoney, String> {
if effect == FuturesPositionEffect::Open {
return Err("close_with_effect does not accept open effect".to_string());
}
@@ -489,7 +594,7 @@ impl FuturesPosition {
));
}
if quantity == 0 {
return Ok(0.0);
return Ok(FixedMoney::ZERO);
}
match effect {
FuturesPositionEffect::Open => unreachable!(),
@@ -523,19 +628,34 @@ impl FuturesPosition {
}
}
let realized = (price - self.avg_price)
* quantity as f64
* self.contract_multiplier
* self.direction.factor()
- transaction_cost.max(0.0);
let transaction_cost =
futures_money(transaction_cost.max(0.0), "futures close transaction cost")?;
let realized = futures_money(
(price - self.avg_price)
* quantity as f64
* self.contract_multiplier
* self.direction.factor(),
"futures realized PnL",
)?
.checked_sub(transaction_cost)
.ok_or_else(|| "fixed-point futures realized PnL overflow".to_string())?;
self.quantity -= quantity;
if self.quantity == 0 {
self.avg_price = 0.0;
}
self.last_price = price;
self.transaction_cost += transaction_cost.max(0.0);
self.transaction_cost = self
.transaction_cost
.checked_add(transaction_cost)
.ok_or_else(|| "fixed-point futures transaction cost overflow".to_string())?;
self.trade_quantity_delta -= quantity as i32;
self.trade_cost -= price * quantity as f64;
self.trade_value = self
.trade_value
.checked_sub(futures_money(
price * quantity as f64 * self.contract_multiplier,
"futures close trade value",
)?)
.ok_or_else(|| "fixed-point futures trade value overflow".to_string())?;
Ok(realized)
}
@@ -547,98 +667,163 @@ impl FuturesPosition {
pub fn begin_trading_day(&mut self) {
self.old_quantity = self.quantity;
self.day_start_quantity = self.quantity;
self.prev_close = self.last_price;
self.transaction_cost = 0.0;
self.transaction_cost = FixedMoney::ZERO;
self.trade_quantity_delta = 0;
self.trade_cost = 0.0;
self.trade_value = FixedMoney::ZERO;
}
pub fn settlement(&mut self, settlement_price: f64) -> f64 {
self.settlement_money(settlement_price).to_f64()
}
fn settlement_money(&mut self, settlement_price: f64) -> FixedMoney {
self.mark_price(settlement_price);
let cash_delta = self.equity();
let cash_delta = self.equity_money();
self.avg_price = self.last_price;
self.prev_close = self.last_price;
self.old_quantity = self.quantity;
cash_delta
}
}
#[derive(Debug, Clone)]
pub struct FuturesAccountState {
starting_cash: f64,
total_cash: f64,
frozen_cash: f64,
starting_cash: FixedMoney,
total_cash: FixedMoney,
frozen_cash: FixedMoney,
closed_day_trading_pnl: FixedMoney,
closed_day_position_pnl: FixedMoney,
closed_day_transaction_cost: FixedMoney,
positions: BTreeMap<(String, FuturesDirection), FuturesPosition>,
}
impl FuturesAccountState {
pub fn new(total_cash: f64) -> Self {
let total_cash = futures_money_or_panic(total_cash, "futures starting cash");
Self {
starting_cash: total_cash,
total_cash,
frozen_cash: 0.0,
frozen_cash: FixedMoney::ZERO,
closed_day_trading_pnl: FixedMoney::ZERO,
closed_day_position_pnl: FixedMoney::ZERO,
closed_day_transaction_cost: FixedMoney::ZERO,
positions: BTreeMap::new(),
}
}
pub fn starting_cash(&self) -> f64 {
self.starting_cash
self.starting_cash.to_f64()
}
pub fn total_cash(&self) -> f64 {
self.total_cash
self.total_cash.to_f64()
}
pub fn frozen_cash(&self) -> f64 {
self.frozen_cash
self.frozen_cash.to_f64()
}
pub fn cash(&self) -> f64 {
self.total_cash - self.margin() - self.frozen_cash
self.cash_money().to_f64()
}
fn cash_money(&self) -> FixedMoney {
self.total_cash
.checked_sub(self.margin_money())
.and_then(|cash| cash.checked_sub(self.frozen_cash))
.expect("fixed-point futures available cash overflow")
}
pub fn margin(&self) -> f64 {
self.positions.values().map(FuturesPosition::margin).sum()
self.margin_money().to_f64()
}
fn margin_money(&self) -> FixedMoney {
sum_futures_money(
self.positions.values().map(FuturesPosition::margin_money),
"futures account margin",
)
}
pub fn market_value(&self) -> f64 {
self.positions
.values()
.map(FuturesPosition::market_value)
.sum()
sum_futures_money(
self.positions
.values()
.map(FuturesPosition::market_value_money),
"futures account market value",
)
.to_f64()
}
pub fn position_equity(&self) -> f64 {
self.positions.values().map(FuturesPosition::equity).sum()
self.position_equity_money().to_f64()
}
fn position_equity_money(&self) -> FixedMoney {
sum_futures_money(
self.positions.values().map(FuturesPosition::equity_money),
"futures account position equity",
)
}
pub fn total_value(&self) -> f64 {
self.total_cash + self.position_equity()
self.total_cash
.checked_add(self.position_equity_money())
.expect("fixed-point futures total value overflow")
.to_f64()
}
pub fn daily_pnl(&self) -> f64 {
self.trading_pnl() + self.position_pnl() - self.transaction_cost()
self.trading_pnl_money()
.checked_add(self.position_pnl_money())
.and_then(|pnl| pnl.checked_sub(self.transaction_cost_money()))
.expect("fixed-point futures daily PnL overflow")
.to_f64()
}
pub fn trading_pnl(&self) -> f64 {
self.positions
.values()
.map(FuturesPosition::trading_pnl)
.sum()
self.trading_pnl_money().to_f64()
}
fn trading_pnl_money(&self) -> FixedMoney {
sum_futures_money(
std::iter::once(self.closed_day_trading_pnl).chain(
self.positions
.values()
.map(FuturesPosition::trading_pnl_money),
),
"futures account trading PnL",
)
}
pub fn position_pnl(&self) -> f64 {
self.positions
.values()
.map(FuturesPosition::position_pnl)
.sum()
self.position_pnl_money().to_f64()
}
fn position_pnl_money(&self) -> FixedMoney {
sum_futures_money(
std::iter::once(self.closed_day_position_pnl).chain(
self.positions
.values()
.map(FuturesPosition::position_pnl_money),
),
"futures account position PnL",
)
}
pub fn transaction_cost(&self) -> f64 {
self.positions
.values()
.map(|position| position.transaction_cost)
.sum()
self.transaction_cost_money().to_f64()
}
fn transaction_cost_money(&self) -> FixedMoney {
sum_futures_money(
std::iter::once(self.closed_day_transaction_cost).chain(
self.positions
.values()
.map(|position| position.transaction_cost),
),
"futures account transaction cost",
)
}
pub fn positions(&self) -> &BTreeMap<(String, FuturesDirection), FuturesPosition> {
@@ -667,7 +852,13 @@ impl FuturesAccountState {
.entry((symbol.clone(), direction))
.or_insert_with(|| FuturesPosition::new(symbol, direction, spec, 0, price));
position.open(quantity, price, transaction_cost);
self.total_cash -= transaction_cost.max(0.0);
self.total_cash = self
.total_cash
.checked_sub(futures_money_or_panic(
transaction_cost.max(0.0),
"futures open transaction cost",
))
.expect("fixed-point futures cash overflow");
}
pub fn close(
@@ -702,12 +893,30 @@ impl FuturesAccountState {
.positions
.get_mut(&key)
.ok_or_else(|| format!("missing futures position {symbol} {}", direction.as_str()))?;
let cash_delta = position.close_with_effect(quantity, price, transaction_cost, effect)?;
self.total_cash += cash_delta;
let cash_delta =
position.close_with_effect_money(quantity, price, transaction_cost, effect)?;
self.total_cash = self
.total_cash
.checked_add(cash_delta)
.ok_or_else(|| "fixed-point futures cash overflow".to_string())?;
if position.quantity == 0 {
self.closed_day_trading_pnl = self
.closed_day_trading_pnl
.checked_add(position.trading_pnl_money())
.ok_or_else(|| "fixed-point closed futures trading PnL overflow".to_string())?;
self.closed_day_position_pnl = self
.closed_day_position_pnl
.checked_add(position.position_pnl_money())
.ok_or_else(|| "fixed-point closed futures position PnL overflow".to_string())?;
self.closed_day_transaction_cost = self
.closed_day_transaction_cost
.checked_add(position.transaction_cost)
.ok_or_else(|| {
"fixed-point closed futures transaction cost overflow".to_string()
})?;
self.positions.remove(&key);
}
Ok(cash_delta)
Ok(cash_delta.to_f64())
}
pub fn execute_order(
@@ -782,7 +991,7 @@ impl FuturesAccountState {
intent.price,
intent.transaction_cost,
);
if projected.cash() < -1e-8 {
if projected.cash_money().raw() < 0 {
Err(format!(
"insufficient futures margin available_cash={:.2} required_margin_after={:.2}",
self.cash(),
@@ -797,7 +1006,13 @@ impl FuturesAccountState {
intent.price,
intent.transaction_cost,
);
Ok(-intent.transaction_cost.max(0.0))
Ok(futures_money_or_panic(
intent.transaction_cost.max(0.0),
"futures open transaction cost",
)
.checked_neg()
.expect("fixed-point futures open cash delta overflow")
.to_f64())
}
}
FuturesPositionEffect::Close
@@ -822,20 +1037,30 @@ impl FuturesAccountState {
.position(&intent.symbol, intent.direction)
.map(|position| position.avg_price)
.unwrap_or(0.0);
let notional =
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier;
let notional = futures_money_or_panic(
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier,
"futures fill notional",
)
.to_f64();
let transaction_cost = futures_money_or_panic(
intent.transaction_cost.max(0.0),
"futures fill transaction cost",
)
.to_f64();
report.fill_events.push(FillEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
execution_start_timestamp: None,
execution_timestamp: None,
order_id,
symbol: intent.symbol.clone(),
side,
quantity: intent.quantity,
price: intent.price,
gross_amount: notional,
commission: intent.transaction_cost.max(0.0),
commission: transaction_cost,
stamp_tax: 0.0,
transfer_fee: 0.0,
net_cash_flow: cash_delta,
@@ -1010,22 +1235,30 @@ impl FuturesAccountState {
}
pub fn begin_trading_day(&mut self) {
self.closed_day_trading_pnl = FixedMoney::ZERO;
self.closed_day_position_pnl = FixedMoney::ZERO;
self.closed_day_transaction_cost = FixedMoney::ZERO;
for position in self.positions.values_mut() {
position.begin_trading_day();
}
}
pub fn settle(&mut self, settlement_prices: &BTreeMap<String, f64>) -> f64 {
let mut cash_delta = 0.0;
let mut cash_delta = FixedMoney::ZERO;
for position in self.positions.values_mut() {
let price = settlement_prices
.get(&position.symbol)
.copied()
.unwrap_or(position.last_price);
cash_delta += position.settlement(price);
cash_delta = cash_delta
.checked_add(position.settlement_money(price))
.expect("fixed-point futures settlement overflow");
}
self.total_cash += cash_delta;
cash_delta
self.total_cash = self
.total_cash
.checked_add(cash_delta)
.expect("fixed-point futures cash settlement overflow");
cash_delta.to_f64()
}
}
+15 -6
View File
@@ -5,9 +5,11 @@ pub mod data;
pub mod engine;
pub mod event_bus;
pub mod events;
pub mod fixed_point;
pub mod futures;
pub mod instrument;
pub mod metrics;
mod numeric_expr_vm;
pub mod platform_expr_strategy;
pub mod platform_runtime_schema;
pub mod platform_strategy_spec;
@@ -29,19 +31,25 @@ pub use data::{
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
IntradayOrderBookDepthLevel, PriceBar, PriceField, SecuritiesMarginRecord, SplitRecord,
YieldCurvePoint,
IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar, PriceField, SecuritiesMarginRecord,
SplitRecord, YieldCurvePoint,
};
pub use engine::{
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
BacktestResult, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
ProcessEventRetention,
};
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
pub use events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind,
};
pub use fixed_point::{
FixedAccount, FixedChinaAShareCostModel, FixedLotBook, FixedMoney, FixedTradingCost,
MONEY_SCALE,
};
pub use futures::{
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
FuturesExecutionReport, FuturesOrderIntent, FuturesPosition, FuturesPositionEffect,
@@ -68,7 +76,8 @@ pub use platform_strategy_spec::{
StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig,
StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig,
StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig,
StrategyRuntimeEnvironment, StrategyRuntimeExpressions, StrategyRuntimeSpec,
StrategyRebalanceSpec, StrategyRiskPolicySpec, StrategyRuntimeEnvironment,
StrategyRuntimeExpressions, StrategyRuntimeSpec, StrategyUniverseSpec,
platform_expr_config_from_spec, platform_expr_config_from_value,
};
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
@@ -82,8 +91,8 @@ pub use scheduler::{
};
pub use strategy::{
AlgoOrderStyle, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, OmniMicroCapConfig,
OmniMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, PortfolioRuntimeView,
Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing,
OmniMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, OrderTimeInForce,
PortfolioRuntimeView, Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing,
};
pub use strategy_ai::{
ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+557 -49
View File
@@ -9,7 +9,8 @@ use crate::{
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind,
RebalanceCashMode, ScheduleTimeRule, SlippageModel,
RebalanceCashMode, ScheduleTimeRule, SlippageModel, futures::FuturesDirection,
futures::FuturesPositionEffect, strategy::OrderTimeInForce,
};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -35,6 +36,14 @@ pub struct StrategyRuntimeSpec {
pub engine_config: Option<StrategyEngineConfig>,
#[serde(default, alias = "runtime_expressions")]
pub runtime_expressions: Option<StrategyRuntimeExpressions>,
#[serde(default, alias = "factor_refs")]
pub factor_refs: Vec<String>,
#[serde(default)]
pub metadata: Option<Value>,
#[serde(default, alias = "factor_value_bindings")]
pub factor_value_bindings: Vec<Value>,
#[serde(default)]
pub parameters: Option<Value>,
#[serde(default)]
pub environment: Option<StrategyRuntimeEnvironment>,
}
@@ -44,6 +53,8 @@ pub struct StrategyRuntimeSpec {
pub struct StrategyBenchmarkSpec {
#[serde(default)]
pub instrument_id: Option<String>,
#[serde(default, alias = "fallback_instrument_id")]
pub fallback_instrument_id: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -61,6 +72,8 @@ pub struct StrategyRebalanceSpec {
#[serde(default)]
pub dates: Vec<String>,
#[serde(default)]
pub schedule: Option<StrategyExpressionScheduleConfig>,
#[serde(default)]
pub trade_times: Vec<String>,
}
@@ -75,9 +88,17 @@ pub struct StrategyExecutionSpec {
pub slippage_model: Option<String>,
#[serde(default, alias = "slippage_value")]
pub slippage_value: Option<f64>,
#[serde(default, alias = "slippage_impact_coefficient")]
#[serde(
default,
alias = "slippage_impact_coefficient",
alias = "slippageImpact"
)]
pub slippage_impact_coefficient: Option<f64>,
#[serde(default, alias = "slippage_volatility_coefficient")]
#[serde(
default,
alias = "slippage_volatility_coefficient",
alias = "slippageVolatility"
)]
pub slippage_volatility_coefficient: Option<f64>,
#[serde(default, alias = "slippage_max_value", alias = "slippage_max_rate")]
pub slippage_max_value: Option<f64>,
@@ -100,12 +121,28 @@ pub struct StrategyExecutionSpec {
pub stamp_tax_rate_after_change: Option<f64>,
#[serde(default, alias = "stamp_tax_change_date")]
pub stamp_tax_change_date: Option<String>,
#[serde(default, alias = "volume_limit")]
#[serde(
default,
alias = "volume_limit",
alias = "volumeLimitEnabled",
alias = "volume_limit_enabled"
)]
pub volume_limit: Option<bool>,
#[serde(default, alias = "liquidity_limit")]
#[serde(
default,
alias = "liquidity_limit",
alias = "liquidityLimitEnabled",
alias = "liquidity_limit_enabled"
)]
pub liquidity_limit: Option<bool>,
#[serde(default, alias = "inactive_limit")]
pub inactive_limit: Option<bool>,
#[serde(default, alias = "same_day_buy_close_mark_at_fill")]
pub same_day_buy_close_mark_at_fill: Option<bool>,
#[serde(default, alias = "volume_percent")]
pub volume_percent: Option<f64>,
#[serde(default, alias = "execution_granularity")]
pub execution_granularity: Option<String>,
#[serde(default, alias = "risk_policy")]
pub risk_policy: Option<StrategyRiskPolicySpec>,
#[serde(default, alias = "strict_value_budget")]
@@ -121,27 +158,27 @@ pub struct StrategyExecutionSpec {
pub struct StrategyEngineConfig {
#[serde(default)]
pub frequency: Option<String>,
#[serde(default)]
#[serde(default, alias = "template_id")]
pub template_id: Option<String>,
#[serde(default, alias = "benchmark_symbol")]
pub benchmark_symbol: Option<String>,
#[serde(default, alias = "signal_symbol")]
pub signal_symbol: Option<String>,
#[serde(default)]
#[serde(default, alias = "rank_limit")]
pub rank_limit: Option<usize>,
#[serde(default)]
#[serde(default, alias = "refresh_rate")]
pub refresh_rate: Option<usize>,
#[serde(default)]
#[serde(default, alias = "rsi_rate")]
pub rsi_rate: Option<f64>,
#[serde(default)]
#[serde(default, alias = "dynamic_range")]
pub dynamic_range: Option<DynamicRangeConfig>,
#[serde(default)]
#[serde(default, alias = "stock_ma_filter")]
pub stock_ma_filter: Option<MovingAverageFilterConfig>,
#[serde(default)]
#[serde(default, alias = "index_throttle")]
pub index_throttle: Option<IndexThrottleConfig>,
#[serde(default)]
#[serde(default, alias = "stop_loss_multiplier")]
pub stop_loss_multiplier: Option<f64>,
#[serde(default)]
#[serde(default, alias = "take_profit_multiplier")]
pub take_profit_multiplier: Option<f64>,
#[serde(default, alias = "matching_type")]
pub matching_type: Option<String>,
@@ -149,9 +186,17 @@ pub struct StrategyEngineConfig {
pub slippage_model: Option<String>,
#[serde(default, alias = "slippage_value")]
pub slippage_value: Option<f64>,
#[serde(default, alias = "slippage_impact_coefficient")]
#[serde(
default,
alias = "slippage_impact_coefficient",
alias = "slippageImpact"
)]
pub slippage_impact_coefficient: Option<f64>,
#[serde(default, alias = "slippage_volatility_coefficient")]
#[serde(
default,
alias = "slippage_volatility_coefficient",
alias = "slippageVolatility"
)]
pub slippage_volatility_coefficient: Option<f64>,
#[serde(default, alias = "slippage_max_value", alias = "slippage_max_rate")]
pub slippage_max_value: Option<f64>,
@@ -174,10 +219,24 @@ pub struct StrategyEngineConfig {
pub stamp_tax_rate_after_change: Option<f64>,
#[serde(default, alias = "stamp_tax_change_date")]
pub stamp_tax_change_date: Option<String>,
#[serde(default, alias = "volume_limit")]
#[serde(
default,
alias = "volume_limit",
alias = "volumeLimitEnabled",
alias = "volume_limit_enabled"
)]
pub volume_limit: Option<bool>,
#[serde(default, alias = "liquidity_limit")]
#[serde(
default,
alias = "liquidity_limit",
alias = "liquidityLimitEnabled",
alias = "liquidity_limit_enabled"
)]
pub liquidity_limit: Option<bool>,
#[serde(default, alias = "inactive_limit")]
pub inactive_limit: Option<bool>,
#[serde(default, alias = "same_day_buy_close_mark_at_fill")]
pub same_day_buy_close_mark_at_fill: Option<bool>,
#[serde(default, alias = "volume_percent")]
pub volume_percent: Option<f64>,
#[serde(default, alias = "risk_policy")]
@@ -188,9 +247,9 @@ pub struct StrategyEngineConfig {
pub rebalance_cash_mode: Option<String>,
#[serde(default, alias = "sell_then_buy_delay_slippage_rate")]
pub sell_then_buy_delay_slippage_rate: Option<f64>,
#[serde(default)]
#[serde(default, alias = "dividend_reinvestment")]
pub dividend_reinvestment: Option<bool>,
#[serde(default)]
#[serde(default, alias = "weak_market_shrink_overweight_threshold")]
pub weak_market_shrink_overweight_threshold: Option<f64>,
#[serde(
default,
@@ -200,9 +259,9 @@ pub struct StrategyEngineConfig {
alias = "maxHoldingDays"
)]
pub max_holding_days: Option<i64>,
#[serde(default)]
#[serde(default, alias = "rebalance_schedule")]
pub rebalance_schedule: Option<StrategyExpressionScheduleConfig>,
#[serde(default)]
#[serde(default, alias = "skip_windows")]
pub skip_windows: Vec<SkipWindowConfig>,
}
@@ -271,11 +330,21 @@ pub struct StrategyRiskPolicySpec {
alias = "blacklist"
)]
pub blacklisted_symbols: Vec<String>,
#[serde(default, alias = "volume_limit_enabled")]
#[serde(
default,
alias = "volume_limit_enabled",
alias = "volume_limit",
alias = "volumeLimit"
)]
pub volume_limit_enabled: Option<bool>,
#[serde(default, alias = "volume_percent")]
pub volume_percent: Option<f64>,
#[serde(default, alias = "liquidity_limit_enabled")]
#[serde(
default,
alias = "liquidity_limit_enabled",
alias = "liquidity_limit",
alias = "liquidityLimit"
)]
pub liquidity_limit_enabled: Option<bool>,
#[serde(default, alias = "commission_rate")]
pub commission_rate: Option<f64>,
@@ -549,54 +618,54 @@ fn normalize_risk_policy_aliases_in_value(value: &mut Value) -> Result<(), Strin
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DynamicRangeConfig {
#[serde(default)]
#[serde(default, alias = "base_index_level")]
pub base_index_level: Option<f64>,
#[serde(default)]
#[serde(default, alias = "base_cap_floor")]
pub base_cap_floor: Option<f64>,
#[serde(default)]
#[serde(default, alias = "cap_span")]
pub cap_span: Option<f64>,
#[serde(default)]
pub xs: Option<f64>,
/// Padding ratio to expand the market cap range (e.g., 0.5 means 50% of span)
#[serde(default)]
#[serde(default, alias = "padding_ratio")]
pub padding_ratio: Option<f64>,
/// Minimum padding in billion yuan
#[serde(default)]
#[serde(default, alias = "min_padding")]
pub min_padding: Option<f64>,
/// Maximum padding in billion yuan
#[serde(default)]
#[serde(default, alias = "max_padding")]
pub max_padding: Option<f64>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MovingAverageFilterConfig {
#[serde(default)]
#[serde(default, alias = "short_days")]
pub short_days: Option<usize>,
#[serde(default)]
#[serde(default, alias = "mid_days")]
pub mid_days: Option<usize>,
#[serde(default)]
#[serde(default, alias = "long_days")]
pub long_days: Option<usize>,
#[serde(default)]
#[serde(default, alias = "volume_short_days", alias = "volumeShort")]
pub volume_short_days: Option<usize>,
#[serde(default)]
#[serde(default, alias = "volume_long_days", alias = "volumeLong")]
pub volume_long_days: Option<usize>,
#[serde(default)]
#[serde(default, alias = "rsi_rate")]
pub rsi_rate: Option<f64>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IndexThrottleConfig {
#[serde(default)]
#[serde(default, alias = "short_days")]
pub short_days: Option<usize>,
#[serde(default)]
#[serde(default, alias = "long_days")]
pub long_days: Option<usize>,
#[serde(default)]
#[serde(default, alias = "rsi_rate")]
pub rsi_rate: Option<f64>,
#[serde(default)]
#[serde(default, alias = "defensive_exposure")]
pub defensive_exposure: Option<f64>,
#[serde(default)]
#[serde(default, alias = "full_exposure")]
pub full_exposure: Option<f64>,
}
@@ -721,6 +790,10 @@ pub struct StrategyExpressionTradingConfig {
pub schedule: Option<StrategyExpressionScheduleConfig>,
#[serde(default)]
pub rotation_enabled: Option<bool>,
#[serde(default, alias = "stock_initial_cash")]
pub stock_initial_cash: Option<f64>,
#[serde(default, alias = "futures_initial_cash")]
pub futures_initial_cash: Option<f64>,
#[serde(default)]
pub daily_top_up: Option<bool>,
#[serde(default)]
@@ -756,6 +829,8 @@ pub struct StrategyExpressionTradingConfig {
#[serde(default)]
pub subscription_guard_required: Option<bool>,
#[serde(default)]
pub subscriptions: Vec<String>,
#[serde(default)]
pub actions: Vec<StrategyExpressionActionConfig>,
}
@@ -770,12 +845,24 @@ pub struct StrategyExpressionActionConfig {
pub symbols_expr: Option<String>,
#[serde(default)]
pub amount_expr: Option<String>,
#[serde(default, alias = "receiving_days_expr")]
pub receiving_days_expr: Option<String>,
#[serde(default)]
pub direction: Option<String>,
#[serde(default)]
pub effect: Option<String>,
#[serde(default, alias = "quantity_expr")]
pub quantity_expr: Option<String>,
#[serde(default, alias = "transaction_cost_expr")]
pub transaction_cost_expr: Option<String>,
#[serde(default)]
pub start_time_expr: Option<String>,
#[serde(default)]
pub end_time_expr: Option<String>,
#[serde(default)]
pub limit_price_expr: Option<String>,
#[serde(default, alias = "time_in_force")]
pub time_in_force: Option<String>,
#[serde(default)]
pub target_weights_expr: Option<String>,
#[serde(default)]
@@ -1389,7 +1476,7 @@ pub fn platform_expr_config_from_spec(
signal_symbol: &str,
strategy_spec: Option<&StrategyRuntimeSpec>,
) -> Result<PlatformExprStrategyConfig, String> {
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.strategy_name = strategy_id.to_string();
if !signal_symbol.trim().is_empty() {
cfg.signal_symbol = signal_symbol.trim().to_string();
@@ -1620,6 +1707,17 @@ pub fn platform_expr_config_from_spec(
.filter(|value| !value.trim().is_empty())
{
cfg.selection_limit_expr = expr.clone();
if let Ok(limit) = expr.trim().parse::<usize>()
&& limit > 0
&& spec
.engine_config
.as_ref()
.and_then(|engine| engine.rank_limit)
.filter(|value| *value > 0)
.is_none()
{
cfg.max_positions = limit;
}
}
if let Some(expr) = selection
.candidate_limit_expr
@@ -1844,9 +1942,16 @@ pub fn platform_expr_config_from_spec(
if let Some(required) = trading.subscription_guard_required {
cfg.subscription_guard_required = required;
}
cfg.initial_subscriptions = trading
.subscriptions
.iter()
.map(|symbol| symbol.trim().to_ascii_uppercase())
.filter(|symbol| !symbol.is_empty())
.collect();
if let Some(stage) = trading.stage.as_deref().map(str::trim) {
cfg.explicit_action_stage = match stage.to_ascii_lowercase().as_str() {
"open_auction" | "open-auction" => PlatformExplicitActionStage::OpenAuction,
"minute" | "on_minute" | "on-minute" => PlatformExplicitActionStage::Minute,
_ => PlatformExplicitActionStage::OnDay,
};
}
@@ -1864,11 +1969,23 @@ pub fn platform_expr_config_from_spec(
{
cfg.intraday_execution_time = Some(time);
}
cfg.explicit_actions = trading
.actions
.iter()
.filter_map(parse_platform_trade_action)
.collect();
let mut explicit_actions = Vec::with_capacity(trading.actions.len());
for (index, action) in trading.actions.iter().enumerate() {
let parsed = parse_platform_trade_action(action).ok_or_else(|| {
format!(
"runtimeExpressions.trading.actions[{index}] is invalid or unsupported kind={}",
action.kind.as_deref().unwrap_or("")
)
})?;
explicit_actions.push(parsed);
}
cfg.explicit_actions = explicit_actions;
// An explicit action follows the strategy's declared schedule when
// it does not have a separate trading schedule. Otherwise the
// action can be parsed successfully but never be dispatched.
if cfg.explicit_action_schedule.is_none() && !cfg.explicit_actions.is_empty() {
cfg.explicit_action_schedule = cfg.rebalance_schedule.clone();
}
}
} else if let Some(engine) = spec.engine_config.as_ref() {
if let Some(dynamic_range) = engine.dynamic_range.as_ref() {
@@ -2006,6 +2123,10 @@ fn parse_platform_rebalance_schedule(
let frequency = schedule.frequency.as_deref()?.trim().to_ascii_lowercase();
let time_rule = parse_schedule_time_rule(schedule);
match frequency.as_str() {
"daily" => Some(PlatformRebalanceSchedule {
frequency: PlatformScheduleFrequency::Daily,
time_rule,
}),
"weekly" => Some(PlatformRebalanceSchedule {
frequency: PlatformScheduleFrequency::Weekly {
weekday: schedule.weekday,
@@ -2133,6 +2254,15 @@ fn parse_platform_trade_action(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let time_in_force = match action
.time_in_force
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(value) => Some(OrderTimeInForce::parse(value)?),
None => None,
};
match kind.as_str() {
"target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart {
target_weights_expr: action
@@ -2153,9 +2283,75 @@ fn parse_platform_trade_action(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string),
time_in_force,
when_expr,
reason,
}),
"futures_order"
| "futures_open"
| "futures_close"
| "futures_close_today"
| "futures_close_yesterday" => {
let symbol = action
.symbol
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_ascii_uppercase();
let direction = match action
.direction
.as_deref()
.map(str::trim)
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"long" => FuturesDirection::Long,
"short" => FuturesDirection::Short,
_ => return None,
};
let effect_name = match kind.as_str() {
"futures_open" => "open",
"futures_close" => "close",
"futures_close_today" => "close_today",
"futures_close_yesterday" => "close_yesterday",
_ => action.effect.as_deref()?.trim(),
};
let effect = match effect_name.to_ascii_lowercase().as_str() {
"open" => FuturesPositionEffect::Open,
"close" => FuturesPositionEffect::Close,
"close_today" | "close-today" => FuturesPositionEffect::CloseToday,
"close_yesterday" | "close-yesterday" => FuturesPositionEffect::CloseYesterday,
_ => return None,
};
let quantity_expr = action
.quantity_expr
.as_deref()
.or(action.amount_expr.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
Some(PlatformTradeAction::Futures {
symbol,
direction,
effect,
quantity_expr,
limit_price_expr: action
.limit_price_expr
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string),
transaction_cost_expr: action
.transaction_cost_expr
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string),
when_expr,
reason,
})
}
"shares"
| "limit_shares"
| "lots"
@@ -2217,6 +2413,7 @@ fn parse_platform_trade_action(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string),
time_in_force,
start_time_expr: action
.start_time_expr
.as_deref()
@@ -2264,6 +2461,42 @@ fn parse_platform_trade_action(
when_expr,
reason,
}),
"modify_order" => {
let order_id_expr = action
.order_id_expr
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let new_total_quantity_expr = action
.quantity_expr
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let new_limit_price_expr = action
.limit_price_expr
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
if new_total_quantity_expr.is_none() && new_limit_price_expr.is_none() {
return None;
}
Some(PlatformTradeAction::Modify {
symbol: action
.symbol
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string),
order_id_expr,
new_total_quantity_expr,
new_limit_price_expr,
when_expr,
reason,
})
}
"update_universe" => Some(PlatformTradeAction::Universe {
kind: PlatformUniverseActionKind::UpdateUniverse,
symbols_expr: action
@@ -2305,7 +2538,12 @@ fn parse_platform_trade_action(
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string(),
receiving_days_expr: None,
receiving_days_expr: action
.receiving_days_expr
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string),
when_expr,
reason,
}),
@@ -2491,6 +2729,266 @@ mod tests {
);
}
#[test]
fn parses_minute_stage_schedule_and_initial_subscriptions() {
let spec = serde_json::json!({
"strategyId": "minute_runtime_strategy",
"signalSymbol": "000300.SH",
"benchmark": {"instrumentId": "000300.SH"},
"runtimeExpressions": {
"selection": {"limitExpr": "1"},
"trading": {
"stage": "minute",
"subscriptions": ["000001.sz", "000002.SZ"],
"schedule": {"frequency": "daily", "time": "10:18"},
"actions": [
{
"kind": "target_percent",
"symbol": "000001.SZ",
"amountExpr": "0.5",
"reason": "minute_target"
}
]
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("minute config");
assert_eq!(
cfg.explicit_action_stage,
PlatformExplicitActionStage::Minute
);
assert_eq!(
cfg.initial_subscriptions,
BTreeSet::from(["000001.SZ".to_string(), "000002.SZ".to_string()])
);
let schedule = cfg.explicit_action_schedule.expect("minute schedule");
assert_eq!(schedule.frequency, PlatformScheduleFrequency::Daily);
assert_eq!(
schedule.time_rule,
Some(ScheduleTimeRule::physical_time(10, 18))
);
assert_eq!(cfg.explicit_actions.len(), 1);
}
#[test]
fn explicit_actions_inherit_top_level_runtime_schedule() {
let spec = serde_json::json!({
"runtimeExpressions": {
"schedule": {"frequency": "daily", "time": "15:00"},
"trading": {
"rotationEnabled": false,
"actions": [{
"kind": "modify_order",
"orderIdExpr": "42",
"quantityExpr": "200",
"limitPriceExpr": "10.25",
"symbol": "000001.SZ",
"reason": "modify_test"
}]
}
}
});
let cfg = platform_expr_config_from_value("modify", "000300.SH", &spec)
.expect("explicit action config");
assert_eq!(cfg.explicit_actions.len(), 1);
assert_eq!(
cfg.explicit_action_schedule,
Some(PlatformRebalanceSchedule {
frequency: PlatformScheduleFrequency::Daily,
time_rule: Some(ScheduleTimeRule::physical_time(15, 0)),
})
);
}
#[test]
fn parses_typed_time_in_force_for_explicit_orders() {
let spec = serde_json::json!({
"runtimeExpressions": {
"trading": {
"rotationEnabled": false,
"actions": [{
"kind": "limit_shares",
"symbol": "000001.SZ",
"amountExpr": "200",
"limitPriceExpr": "10.25",
"timeInForce": "FOK",
"reason": "fok_entry"
}]
}
}
});
let cfg = platform_expr_config_from_value("tif", "000300.SH", &spec)
.expect("time-in-force config");
assert!(matches!(
cfg.explicit_actions.as_slice(),
[PlatformTradeAction::Order {
kind: PlatformExplicitOrderKind::LimitShares,
time_in_force: Some(OrderTimeInForce::Fok),
..
}]
));
}
#[test]
fn rejects_unknown_time_in_force_in_runtime_contract() {
let spec = serde_json::json!({
"runtimeExpressions": {
"trading": {
"rotationEnabled": false,
"actions": [{
"kind": "shares",
"symbol": "000001.SZ",
"amountExpr": "200",
"timeInForce": "until_lucky",
"reason": "invalid_tif"
}]
}
}
});
let error = platform_expr_config_from_value("tif", "000300.SH", &spec)
.expect_err("unknown time-in-force must be rejected");
assert!(
error
.to_string()
.contains("runtimeExpressions.trading.actions[0]")
);
}
#[test]
fn parses_delayed_deposit_receiving_days_expression() {
let spec = serde_json::json!({
"runtimeExpressions": {
"trading": {
"rotationEnabled": false,
"actions": [{
"kind": "deposit_withdraw",
"amountExpr": "1000",
"receivingDaysExpr": "1",
"reason": "delayed capital injection"
}]
}
}
});
let cfg = platform_expr_config_from_value("cash-flow", "000300.SH", &spec)
.expect("delayed deposit config");
assert!(matches!(
cfg.explicit_actions.as_slice(),
[PlatformTradeAction::Account {
kind: PlatformAccountActionKind::DepositWithdraw,
amount_expr,
receiving_days_expr: Some(receiving_days_expr),
reason,
..
}] if amount_expr == "1000"
&& receiving_days_expr == "1"
&& reason == "delayed capital injection"
));
}
#[test]
fn parses_generic_futures_actions_and_rejects_incomplete_contracts() {
let spec = serde_json::json!({
"runtimeExpressions": {
"trading": {
"rotationEnabled": false,
"actions": [
{
"kind": "futures_order",
"symbol": "if2509.ccfx",
"direction": "long",
"effect": "open",
"quantityExpr": "2",
"limitPriceExpr": "4010.2",
"transactionCostExpr": "0",
"whenExpr": "year >= 2025",
"reason": "open index hedge"
},
{
"kind": "futures_close_today",
"symbol": "IF2509.CCFX",
"direction": "short",
"amountExpr": "1",
"reason": "close intraday hedge"
}
]
}
}
});
let cfg = platform_expr_config_from_value("futures", "000300.SH", &spec).expect("config");
assert_eq!(cfg.explicit_actions.len(), 2);
assert!(matches!(
&cfg.explicit_actions[0],
PlatformTradeAction::Futures {
symbol,
direction: FuturesDirection::Long,
effect: FuturesPositionEffect::Open,
quantity_expr,
limit_price_expr: Some(limit_price),
..
} if symbol == "IF2509.CCFX" && quantity_expr == "2" && limit_price == "4010.2"
));
assert!(matches!(
&cfg.explicit_actions[1],
PlatformTradeAction::Futures {
direction: FuturesDirection::Short,
effect: FuturesPositionEffect::CloseToday,
quantity_expr,
..
} if quantity_expr == "1"
));
let invalid = serde_json::json!({
"runtimeExpressions": {
"trading": {
"rotationEnabled": false,
"actions": [{
"kind": "futures_open",
"symbol": "IF2509.CCFX",
"quantityExpr": "1"
}]
}
}
});
let error = platform_expr_config_from_value("futures", "000300.SH", &invalid)
.expect_err("missing direction must fail");
assert!(error.to_string().contains("actions[0] is invalid"));
}
#[test]
fn runtime_expression_parser_does_not_inherit_microcap_template_defaults() {
let spec = serde_json::json!({
"strategyId": "generic_runtime_strategy",
"signalSymbol": "000300.SH",
"benchmark": { "instrumentId": "000300.SH" },
"runtimeExpressions": {
"selection": { "limitExpr": "7" },
"trading": { "rotationEnabled": true }
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("generic config");
assert_eq!(cfg.strategy_name, "generic_runtime_strategy");
assert_eq!(cfg.max_positions, 7);
assert_eq!(cfg.selection_limit_expr, "7");
assert_eq!(cfg.market_cap_lower_expr, "0.0");
assert_eq!(cfg.market_cap_upper_expr, "1.0e30");
assert!(cfg.stock_filter_expr.is_empty());
assert!(cfg.prelude.is_empty());
assert_eq!(cfg.refresh_rate, 1);
assert!(!cfg.daily_position_target_adjust_enabled);
}
#[test]
fn engine_config_parses_weak_market_shrink_overweight_threshold() {
let spec = serde_json::json!({
@@ -2834,6 +3332,8 @@ mod tests {
"reject_st_buy": 0,
"volumePercent": 0.25,
"volume_percent": 25,
"volume_limit": false,
"liquidityLimit": true,
"minimumCommission": 5,
"minimum_commission": "5"
}
@@ -2844,6 +3344,8 @@ mod tests {
assert!(!cfg.risk_config.static_rules.reject_st_buy);
assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12);
assert!(!cfg.risk_config.trading_constraints.volume_limit_enabled);
assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled);
assert_eq!(cfg.risk_config.trading_constraints.minimum_commission, 5.0);
}
@@ -3164,7 +3666,13 @@ mod tests {
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(cfg.rebalance_schedule, None);
assert_eq!(
cfg.rebalance_schedule,
Some(PlatformRebalanceSchedule {
frequency: PlatformScheduleFrequency::Daily,
time_rule: Some(ScheduleTimeRule::MinuteOfDay(9 * 60 + 33)),
})
);
assert_eq!(
cfg.intraday_execution_time,
Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap())
File diff suppressed because it is too large Load Diff
+262 -26
View File
@@ -10,6 +10,7 @@ use crate::data::{
};
use crate::engine::BacktestError;
use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent};
use crate::fixed_point::FixedMoney;
use crate::futures::{FuturesAccountState, FuturesOrderIntent};
use crate::instrument::Instrument;
use crate::portfolio::PortfolioState;
@@ -19,6 +20,12 @@ use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSe
pub trait Strategy {
fn name(&self) -> &str;
fn initial_subscriptions(&self) -> BTreeSet<String> {
BTreeSet::new()
}
fn requires_minute_callbacks(&self) -> bool {
true
}
fn management_fee(
&mut self,
_ctx: &StrategyContext<'_>,
@@ -82,7 +89,7 @@ pub trait Strategy {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub struct OpenOrderView {
pub order_id: u64,
pub symbol: String,
@@ -1007,6 +1014,35 @@ pub enum AlgoOrderStyle {
Twap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrderTimeInForce {
Day,
Ioc,
Fok,
Gtc,
}
impl OrderTimeInForce {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"day" => Some(Self::Day),
"ioc" | "immediate_or_cancel" | "immediate-or-cancel" => Some(Self::Ioc),
"fok" | "fill_or_kill" | "fill-or-kill" => Some(Self::Fok),
"gtc" | "good_til_canceled" | "good-til-canceled" => Some(Self::Gtc),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Day => "day",
Self::Ioc => "ioc",
Self::Fok => "fok",
Self::Gtc => "gtc",
}
}
}
#[derive(Debug, Clone)]
pub enum TargetPortfolioOrderPricing {
LimitPrices(BTreeMap<String, f64>),
@@ -1019,6 +1055,10 @@ pub enum TargetPortfolioOrderPricing {
#[derive(Debug, Clone)]
pub enum OrderIntent {
WithTimeInForce {
intent: Box<OrderIntent>,
time_in_force: OrderTimeInForce,
},
Shares {
symbol: String,
quantity: i32,
@@ -1130,6 +1170,12 @@ pub enum OrderIntent {
order_id: u64,
reason: String,
},
ModifyOrder {
order_id: u64,
new_total_quantity: Option<u32>,
new_limit_price: Option<f64>,
reason: String,
},
CancelSymbol {
symbol: String,
reason: String,
@@ -1167,6 +1213,101 @@ pub enum OrderIntent {
},
}
impl OrderIntent {
pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self {
match self {
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
intent,
time_in_force,
},
intent => Self::WithTimeInForce {
intent: Box::new(intent),
time_in_force,
},
}
}
pub fn time_in_force(&self) -> Option<OrderTimeInForce> {
match self {
Self::WithTimeInForce { time_in_force, .. } => Some(*time_in_force),
_ => None,
}
}
pub fn into_time_in_force_parts(self) -> (Self, Option<OrderTimeInForce>) {
match self {
Self::WithTimeInForce {
intent,
time_in_force,
} => (*intent, Some(time_in_force)),
intent => (intent, None),
}
}
pub fn apply_time_in_force(self, time_in_force: Option<OrderTimeInForce>) -> Self {
match time_in_force {
Some(time_in_force) => self.with_time_in_force(time_in_force),
None => self,
}
}
pub fn unwrapped(&self) -> &Self {
match self {
Self::WithTimeInForce { intent, .. } => intent.unwrapped(),
_ => self,
}
}
pub fn supports_time_in_force(&self, time_in_force: OrderTimeInForce) -> bool {
let intent = self.unwrapped();
if matches!(
intent,
Self::CancelOrder { .. }
| Self::ModifyOrder { .. }
| Self::CancelSymbol { .. }
| Self::CancelAll { .. }
| Self::UpdateUniverse { .. }
| Self::Subscribe { .. }
| Self::Unsubscribe { .. }
| Self::DepositWithdraw { .. }
| Self::FinanceRepay { .. }
| Self::SetManagementFeeRate { .. }
| Self::Futures { .. }
) {
return false;
}
let is_algo = matches!(
intent,
Self::AlgoValue { .. } | Self::AlgoPercent { .. } | Self::TimedTargetValue { .. }
) || matches!(
intent,
Self::TargetPortfolioSmart {
order_prices: Some(TargetPortfolioOrderPricing::AlgoOrder { .. }),
..
}
);
let is_limit = matches!(
intent,
Self::LimitShares { .. }
| Self::LimitLots { .. }
| Self::LimitTargetShares { .. }
| Self::LimitTargetValue { .. }
| Self::LimitValue { .. }
| Self::LimitPercent { .. }
| Self::LimitTargetPercent { .. }
| Self::TargetPortfolioSmart {
order_prices: Some(TargetPortfolioOrderPricing::LimitPrices(_)),
..
}
);
match time_in_force {
OrderTimeInForce::Day | OrderTimeInForce::Ioc => true,
OrderTimeInForce::Fok => !is_algo,
OrderTimeInForce::Gtc => is_limit,
}
}
}
#[derive(Debug, Clone)]
pub struct CnSmallCapRotationConfig {
pub strategy_name: String,
@@ -1744,14 +1885,47 @@ impl OmniMicroCapStrategy {
ChinaAShareCostModel::from_trading_constraints(self.config.risk_config.trading_constraints)
}
fn buy_commission(&self, gross_amount: f64) -> f64 {
self.cost_model().commission_for(gross_amount)
fn buy_cost(&self, gross_amount: f64) -> f64 {
let model = self.cost_model();
FixedMoney::checked_sum_f64([
model.commission_for(gross_amount),
model.transfer_fee_for(gross_amount),
])
.expect("projected buy costs must be finite fixed-point money")
.to_f64()
}
fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 {
let model = self.cost_model();
model.commission_for(gross_amount)
+ model.stamp_tax_for(date, OrderSide::Sell, gross_amount)
FixedMoney::checked_sum_f64([
model.commission_for(gross_amount),
model.stamp_tax_for(date, OrderSide::Sell, gross_amount),
model.transfer_fee_for(gross_amount),
])
.expect("projected sell costs must be finite fixed-point money")
.to_f64()
}
fn buy_cash_out(&self, gross_amount: f64) -> f64 {
FixedMoney::checked_sum_f64([gross_amount, self.buy_cost(gross_amount)])
.expect("projected buy cash must be finite fixed-point money")
.to_f64()
}
fn sell_net_cash(&self, date: NaiveDate, gross_amount: f64) -> f64 {
let gross = FixedMoney::from_f64(gross_amount)
.expect("projected sell gross must be finite fixed-point money");
gross
.checked_sub(
FixedMoney::from_f64(self.sell_cost(date, gross.to_f64()))
.expect("projected sell costs must be finite fixed-point money"),
)
.expect("projected sell proceeds underflow")
.to_f64()
}
fn fixed_cash_fits(value: f64, limit: f64) -> bool {
FixedMoney::f64_fits_within(value, limit).unwrap_or(false)
}
fn round_lot_quantity(
@@ -1820,7 +1994,7 @@ impl OmniMicroCapStrategy {
let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100, 100);
while quantity > 0 {
let gross_amount = execution_price * quantity as f64;
if gross_amount + self.buy_commission(gross_amount) <= cash + 1e-6 {
if Self::fixed_cash_fits(self.buy_cash_out(gross_amount), cash) {
return quantity;
}
quantity = self.decrement_order_quantity(quantity, 100, 100);
@@ -1868,8 +2042,10 @@ impl OmniMicroCapStrategy {
);
while snapshot_requested_qty > 0 {
let gross_amount = sizing_price * snapshot_requested_qty as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 {
let cash_out = self.buy_cash_out(gross_amount);
if Self::fixed_cash_fits(cash_out, order_value)
&& Self::fixed_cash_fits(cash_out, projected.cash())
{
break;
}
snapshot_requested_qty = self.decrement_order_quantity(
@@ -1896,8 +2072,10 @@ impl OmniMicroCapStrategy {
let mut quantity = snapshot_requested_qty;
while quantity > 0 {
let gross_amount = projected_execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 {
let cash_out = self.buy_cash_out(gross_amount);
if Self::fixed_cash_fits(cash_out, order_value)
&& Self::fixed_cash_fits(cash_out, projected.cash())
{
break;
}
quantity =
@@ -1912,8 +2090,10 @@ impl OmniMicroCapStrategy {
.unwrap_or(projected_execution_price);
while quantity > 0 {
let gross_amount = execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 {
let cash_out = self.buy_cash_out(gross_amount);
if Self::fixed_cash_fits(cash_out, order_value)
&& Self::fixed_cash_fits(cash_out, projected.cash())
{
break;
}
quantity =
@@ -1928,11 +2108,15 @@ impl OmniMicroCapStrategy {
next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1),
};
let gross_amount = fill.price * fill.quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out > projected.cash() + 1e-6 || cash_out > order_value + 1e-6 {
let cash_out = self.buy_cash_out(gross_amount);
if !Self::fixed_cash_fits(cash_out, projected.cash())
|| !Self::fixed_cash_fits(cash_out, order_value)
{
return 0;
}
projected.apply_cash_delta(-cash_out);
projected
.apply_cash_delta(-cash_out)
.expect("projected buy cash must fit fixed-point ledger");
projected
.position_mut(symbol)
.buy(date, fill.quantity, fill.price);
@@ -1988,12 +2172,14 @@ impl OmniMicroCapStrategy {
+ Duration::seconds(1),
});
let gross_amount = fill.price * fill.quantity as f64;
let net_cash = gross_amount - self.sell_cost(date, gross_amount);
let net_cash = self.sell_net_cash(date, gross_amount);
projected
.position_mut(symbol)
.sell(fill.quantity, fill.price)
.ok()?;
projected.apply_cash_delta(net_cash);
projected
.apply_cash_delta(net_cash)
.expect("projected sell cash must fit fixed-point ledger");
*execution_state
.intraday_turnover
.entry(symbol.to_string())
@@ -2138,7 +2324,9 @@ impl OmniMicroCapStrategy {
);
while take_qty > 0 {
let candidate_gross = execution_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
if gross_limit
.is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit))
{
take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
@@ -2146,9 +2334,8 @@ impl OmniMicroCapStrategy {
);
continue;
}
let candidate_cash =
candidate_gross + self.buy_commission(candidate_gross);
if candidate_cash <= cash + 1e-6 {
let candidate_cash = self.buy_cash_out(candidate_gross);
if Self::fixed_cash_fits(candidate_cash, cash) {
break;
}
take_qty = self.decrement_order_quantity(
@@ -2248,7 +2435,9 @@ impl OmniMicroCapStrategy {
if let Some(cash) = cash_limit {
while take_qty > 0 {
let candidate_gross = gross_amount + quote_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
if gross_limit
.is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit))
{
take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
@@ -2256,7 +2445,7 @@ impl OmniMicroCapStrategy {
);
continue;
}
if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 {
if Self::fixed_cash_fits(self.buy_cash_out(candidate_gross), cash) {
break;
}
take_qty = self.decrement_order_quantity(
@@ -2486,7 +2675,7 @@ impl OmniMicroCapStrategy {
date: NaiveDate,
) -> Vec<FidcRiskDecisionAudit> {
let mut decisions = Vec::new();
for factor in ctx.data.factor_snapshots_on(date) {
for factor in ctx.data.factor_snapshot_rows_on(date) {
if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) {
continue;
}
@@ -2854,6 +3043,53 @@ mod tests {
use super::*;
use crate::{BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot};
#[test]
fn order_time_in_force_parsing_and_order_type_contract_are_explicit() {
assert_eq!(OrderTimeInForce::parse("DAY"), Some(OrderTimeInForce::Day));
assert_eq!(
OrderTimeInForce::parse("immediate_or_cancel"),
Some(OrderTimeInForce::Ioc)
);
assert_eq!(
OrderTimeInForce::parse("fill-or-kill"),
Some(OrderTimeInForce::Fok)
);
assert_eq!(
OrderTimeInForce::parse("good_til_canceled"),
Some(OrderTimeInForce::Gtc)
);
assert_eq!(OrderTimeInForce::parse("unknown"), None);
let market = OrderIntent::Shares {
symbol: "000001.SZ".to_string(),
quantity: 100,
reason: "market".to_string(),
};
assert!(market.supports_time_in_force(OrderTimeInForce::Day));
assert!(market.supports_time_in_force(OrderTimeInForce::Ioc));
assert!(market.supports_time_in_force(OrderTimeInForce::Fok));
assert!(!market.supports_time_in_force(OrderTimeInForce::Gtc));
let limit = OrderIntent::LimitShares {
symbol: "000001.SZ".to_string(),
quantity: 100,
limit_price: 10.0,
reason: "limit".to_string(),
};
assert!(limit.supports_time_in_force(OrderTimeInForce::Gtc));
let algo = OrderIntent::AlgoValue {
symbol: "000001.SZ".to_string(),
value: 10_000.0,
style: AlgoOrderStyle::Vwap,
start_time: None,
end_time: None,
reason: "algo".to_string(),
};
assert!(!algo.supports_time_in_force(OrderTimeInForce::Fok));
assert!(!algo.supports_time_in_force(OrderTimeInForce::Gtc));
}
#[test]
fn omni_microcap_projection_uses_configured_trading_cost() {
let mut cfg = OmniMicroCapConfig::omni_microcap();
@@ -2864,8 +3100,8 @@ mod tests {
.stamp_tax_rate_after_change = 0.0005;
let strategy = OmniMicroCapStrategy::new(cfg);
assert!((strategy.buy_commission(100_000.0) - 30.0).abs() < 1e-9);
assert!((strategy.buy_commission(1_000.0) - 5.0).abs() < 1e-9);
assert!((strategy.buy_cost(100_000.0) - 30.0).abs() < 1e-9);
assert!((strategy.buy_cost(1_000.0) - 5.0).abs() < 1e-9);
assert!(
(strategy.sell_cost(NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), 100_000.0) - 80.0)
.abs()
+18 -5
View File
@@ -261,12 +261,20 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\")current_bar_close 使用决策日当日 closenext_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_buysell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 请求滑点率,例如 0.002) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
},
ManualSection {
title: "期货提交校验".to_string(),
detail: "期货订单进入撮合前会先执行账户与交易规则校验:合约必须在上市/退市日期范围内,日行情不能停牌,trading_phase 需处于 continuous/trading/open_auction/auction/call_auction/opening_auction 等可交易阶段,限价必须为正且按 futures_trading_parameters.price_tick 或日行情 price_tick 对齐,并且不能越过 upper_limit/lower_limit;随后继续检查反向挂单自成交风险、保证金和可平数量。服务层可通过 FuturesValidationConfig 分别关闭 active instrument、trading phase、限价最小价位、price limit 校验,但默认全部开启".to_string(),
title: "期货 runtime action 与提交校验".to_string(),
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓".to_string(),
},
ManualSection {
title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(),
detail: "支持显式下单、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。其中 order.target_shares(...) 对应 平台内核 的 order_toorder.target_portfolio_smart(...) 对应 平台内核 的 order_target_portfolio_smart 批量目标权重语义;account.deposit_withdraw(...) 和 account.finance_repay(...) 对应 平台内核 账户出入金与融资/还款语义;order_prices 既可以是逐标的限价映射,也可以是 VWAPOrder/TWAPOrder 这类全局 AlgoOrderorder.vwap_* / order.twap_* 对应 平台内核 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 平台内核 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForceDAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
},
ManualSection {
title: "order.time_in_force target runtime scope".to_string(),
detail: "回测支持 DAY/IOC/FOK/GTCpaper/live 当前只支持 DAY/IOC/FOK。GTC 需要持久化跨交易日 parent/child 重挂账本和券商适配器能力,在该合同实现前只允许回测,paper/live 必须明确拒绝并禁止降级为 DAY。生成策略前必须按目标运行模式选择能力。".to_string(),
},
ManualSection {
title: "order.modify".to_string(),
detail: "回测中可用 order.modify(order_id, total_quantity=?, limit_price=?) 原位修改仍未完成的限价单。total_quantity 是新的总委托量而不是增量,不能低于已成交量;改价或增量会重置盘口队列优先级,减少总量且不改价保留优先级,同时保留 order_id、有效期、累计成交和费用状态。paper/live 在适配器提供持久且确认的 cancel-replace 合同前必须拒绝该动作,不得静默转换为撤单加新订单。".to_string(),
},
ManualSection {
title: "when / unless / else".to_string(),
@@ -404,7 +412,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
},
ManualFactorSource {
table: "期货交易参数".to_string(),
detail: "字段包括 symbol、effective_date、contract_multiplier、long_margin_rate、short_margin_rate、commission_type、open_commission_ratio、close_commission_ratio、close_today_commission_ratio、price_tick。回测按交易日自动选择不晚于当前日期的最新参数,用于保证金、手续费和限价最小价位校验".to_string(),
detail: "来自 futures_contract_spec_history 与 futures_cost_margin_history字段包括 symbol、effective_date、contract_multiplier、long_margin_rate、short_margin_rate、commission_type、open_commission_ratio、close_commission_ratio、close_today_commission_ratio、price_tick。回测按交易日选择不晚于当前日期的最新参数。schema catalog 未同时发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 时,该能力视为不可用".to_string(),
fields: vec![],
},
],
@@ -672,6 +680,11 @@ mod tests {
assert!(markdown.contains("源策略明确写出的业务选股排除属于策略本身"));
assert!(markdown.contains("不能反向修改冻结的 reject_*_selection 开关"));
assert!(markdown.contains("冻结的 `reject_*_selection` 值不得改变"));
assert!(markdown.contains("time_in_force=\"day|ioc|fok|gtc\""));
assert!(markdown.contains("FOK 必须全量可成交否则零成交"));
assert!(markdown.contains("GTC 仅支持限价单并跨交易日保留"));
assert!(markdown.contains("paper/live 当前只支持 DAY/IOC/FOK"));
assert!(markdown.contains("paper/live 必须明确拒绝并禁止降级为 DAY"));
}
#[test]
+2 -2
View File
@@ -80,7 +80,7 @@ impl SelectionContext<'_> {
}
};
let mut decisions = Vec::new();
for factor in self.data.factor_snapshots_on(self.decision_date) {
for factor in self.data.factor_snapshot_rows_on(self.decision_date) {
if self
.dynamic_universe
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol))
@@ -213,7 +213,7 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
risk_decisions: Vec::new(),
};
diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len();
diagnostics.factor_total = ctx.data.factor_snapshot_rows_on(ctx.decision_date).len();
diagnostics.risk_decisions = ctx.selection_risk_decisions();
diagnostics.not_eligible_count = diagnostics.risk_decisions.len();
diagnostics.paused_count = diagnostics
+24 -10
View File
@@ -49,14 +49,30 @@ fn portfolio_settles_cash_receivable_on_payable_date() {
amount: 500.0,
reason: "cash_dividend 0.5".to_string(),
});
portfolio.add_cash_receivable(CashReceivable {
symbol: "000002.SZ".to_string(),
ex_date: d(2025, 1, 2),
payable_date: d(2025, 1, 5),
amount: 250.0,
reason: "cash_dividend 0.25".to_string(),
});
let settled_early = portfolio.settle_cash_receivables(d(2025, 1, 4));
assert!(settled_early.is_empty());
let due_early = portfolio.take_due_cash_receivables(d(2025, 1, 4));
assert!(due_early.is_empty());
assert!((portfolio.cash() - 1_000_000.0).abs() < 1e-9);
let settled = portfolio.settle_cash_receivables(d(2025, 1, 5));
assert_eq!(settled.len(), 1);
assert!((portfolio.cash() - 1_000_500.0).abs() < 1e-9);
let due = portfolio.take_due_cash_receivables(d(2025, 1, 5));
assert_eq!(due.len(), 2);
let mut cash_chain = Vec::new();
for receivable in &due {
let cash_before = portfolio.cash();
portfolio.settle_cash_receivable(receivable).unwrap();
cash_chain.push((cash_before, portfolio.cash()));
}
assert_eq!(
cash_chain,
vec![(1_000_000.0, 1_000_500.0), (1_000_500.0, 1_000_750.0)]
);
assert!(portfolio.cash_receivables().is_empty());
}
@@ -368,11 +384,9 @@ 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()
.with_commission_rate(0.0008)
.with_minimum_commission(0.0),
ChinaEquityRuleHooks::default(),
PriceField::Open,
),
+340 -27
View File
@@ -1,18 +1,21 @@
use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use chrono::{NaiveDate, NaiveDateTime};
use fidc_core::{
BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader,
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FuturesAccountState,
BacktestTerminalAssetClass, BacktestTerminalStatus, BenchmarkSnapshot, BrokerSimulator,
CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot,
DailyMarketSnapshot, DataSet, ExecutionQuoteRequest, FuturesAccountState,
FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesOrderIntent,
FuturesTradingParameter, FuturesValidationConfig, Instrument, IntradayExecutionQuote,
IntradayOrderBookDepthLevel, MatchingType, OpenOrderView, OrderIntent, OrderSide, OrderStatus,
PlatformExprStrategy, PlatformExprStrategyConfig, PortfolioState, PriceField, ProcessEvent,
ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy,
StrategyContext, StrategyDecision,
FuturesPositionEffect, FuturesTradingParameter, FuturesValidationConfig, Instrument,
IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, NumericFactorMap,
OpenOrderView, OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy,
PlatformExprStrategyConfig, PlatformTradeAction, PortfolioState, PriceField, ProcessEvent,
ProcessEventBus, ProcessEventKind, ProcessEventRetention, ScheduleRule, ScheduleStage,
ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision,
};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -131,7 +134,7 @@ fn market_row(date: NaiveDate, symbol: &str, open: f64, close: f64) -> DailyMark
fn factor_row(
date: NaiveDate,
symbol: &str,
extra_factors: BTreeMap<String, f64>,
extra_factors: NumericFactorMap,
) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date,
@@ -207,26 +210,26 @@ fn two_day_futures_data() -> DataSet {
d1,
"000001.SZ",
BTreeMap::from([
("custom_alpha".to_string(), 7.0),
("margin_all".to_string(), 1.0),
("yield_curve_1y".to_string(), 0.02),
("total_shares".to_string(), 123.0),
("stock_connect_north_bound".to_string(), 1.0),
("industry_citics_l1".to_string(), 10.0),
("fundamental_net_profit".to_string(), 99.0),
("custom_alpha".into(), 7.0),
("margin_all".into(), 1.0),
("yield_curve_1y".into(), 0.02),
("total_shares".into(), 123.0),
("stock_connect_north_bound".into(), 1.0),
("industry_citics_l1".into(), 10.0),
("fundamental_net_profit".into(), 99.0),
]),
),
factor_row(
d2,
"000001.SZ",
BTreeMap::from([
("custom_alpha".to_string(), 8.0),
("margin_all".to_string(), 1.0),
("yield_curve_1y".to_string(), 0.021),
("total_shares".to_string(), 124.0),
("stock_connect_north_bound".to_string(), 1.0),
("industry_citics_l1".to_string(), 10.0),
("fundamental_net_profit".to_string(), 101.0),
("custom_alpha".into(), 8.0),
("margin_all".into(), 1.0),
("yield_curve_1y".into(), 0.021),
("total_shares".into(), 124.0),
("stock_connect_north_bound".into(), 1.0),
("industry_citics_l1".into(), 10.0),
("fundamental_net_profit".into(), 101.0),
]),
),
],
@@ -634,6 +637,9 @@ struct UniverseDirectiveStrategy {
struct MinuteProbeStrategy {
seen_ticks: Rc<RefCell<Vec<String>>>,
scheduled_count: Rc<RefCell<usize>>,
subscribe_symbols: BTreeSet<String>,
minute_callbacks: bool,
ordered: bool,
}
@@ -674,6 +680,8 @@ impl Strategy for ScheduledProbeStrategy {
.with_time_rule(ScheduleTimeRule::physical_time(10, 18)),
ScheduleRule::monthly("first_trading_day_on_day", 1, ScheduleStage::OnDay)
.with_time_rule(ScheduleTimeRule::physical_time(10, 18)),
ScheduleRule::daily("daily_on_day_close", ScheduleStage::OnDay)
.with_time_rule(ScheduleTimeRule::physical_time(15, 0)),
]
}
@@ -809,6 +817,26 @@ impl Strategy for MinuteProbeStrategy {
"minute-probe"
}
fn requires_minute_callbacks(&self) -> bool {
self.minute_callbacks
}
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![
ScheduleRule::daily("minute_barrier", ScheduleStage::Minute)
.with_time_rule(ScheduleTimeRule::physical_time(10, 18)),
]
}
fn on_scheduled(
&mut self,
_ctx: &StrategyContext<'_>,
_rule: &ScheduleRule,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
*self.scheduled_count.borrow_mut() += 1;
Ok(StrategyDecision::default())
}
fn on_day(
&mut self,
_ctx: &StrategyContext<'_>,
@@ -818,7 +846,7 @@ impl Strategy for MinuteProbeStrategy {
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Subscribe {
symbols: BTreeSet::from(["000001.SZ".to_string()]),
symbols: self.subscribe_symbols.clone(),
reason: "subscribe_minute_probe".to_string(),
}],
notes: Vec::new(),
@@ -1171,6 +1199,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
)
.expect("dataset");
let compact_data = data.clone();
let log = Rc::new(RefCell::new(Vec::new()));
let strategy = HookProbeStrategy { log: log.clone() };
let broker = BrokerSimulator::new_with_execution_price(
@@ -1210,6 +1239,42 @@ fn engine_runs_strategy_hooks_in_daily_order() {
]
);
assert_eq!(result.process_events.len(), 36);
let compact_strategy = HookProbeStrategy {
log: Rc::new(RefCell::new(Vec::new())),
};
let compact_broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut compact_engine = BacktestEngine::new(
compact_data,
compact_strategy,
compact_broker,
BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(date1),
end_date: Some(date2),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
)
.with_process_event_retention(ProcessEventRetention::Business);
let compact_result = compact_engine.run().expect("compact backtest succeeds");
assert!(compact_result
.process_events
.iter()
.all(|event| event.kind.is_business_lifecycle()));
assert!(compact_result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::OnDay));
assert!(!compact_result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::PreBeforeTrading));
assert_eq!(
result.process_events[..18]
.iter()
@@ -1449,6 +1514,73 @@ fn engine_executes_futures_order_intents_against_future_account() {
assert!((futures_account.cash() - 355_988.0).abs() < 1e-6);
}
#[test]
fn platform_runtime_actions_execute_generic_futures_open_and_close() {
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.signal_symbol = "000001.SZ".to_string();
cfg.benchmark_symbol = "000300.SH".to_string();
cfg.rotation_enabled = false;
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
cfg.explicit_actions = vec![
PlatformTradeAction::Futures {
symbol: "IF2501".to_string(),
direction: FuturesDirection::Long,
effect: FuturesPositionEffect::Open,
quantity_expr: "1".to_string(),
limit_price_expr: None,
transaction_cost_expr: None,
when_expr: Some("decision_date == \"2025-01-02\"".to_string()),
reason: "generic futures open".to_string(),
},
PlatformTradeAction::Futures {
symbol: "IF2501".to_string(),
direction: FuturesDirection::Long,
effect: FuturesPositionEffect::Close,
quantity_expr: "1".to_string(),
limit_price_expr: None,
transaction_cost_expr: None,
when_expr: Some("decision_date == \"2025-01-03\"".to_string()),
reason: "generic futures close".to_string(),
},
];
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut engine = BacktestEngine::new(
two_day_futures_data(),
PlatformExprStrategy::new(cfg),
broker,
BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(d(2025, 1, 2)),
end_date: Some(d(2025, 1, 3)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
)
.with_futures_initial_cash(500_000.0);
let result = engine.run().expect("generic futures actions execute");
let futures_fills = result
.fills
.iter()
.filter(|fill| fill.symbol == "IF2501")
.collect::<Vec<_>>();
assert_eq!(futures_fills.len(), 2);
assert!((futures_fills[0].price - 4000.0).abs() < 1e-12);
assert!((futures_fills[0].commission - 2.5).abs() < 1e-12);
assert!((futures_fills[1].price - 3988.0).abs() < 1e-12);
assert!((futures_fills[1].commission - 2.0).abs() < 1e-12);
let futures_account = engine.futures_account().expect("future account");
assert!(futures_account.positions().is_empty());
assert!((futures_account.total_cash() - 496_395.5).abs() < 1e-12);
}
#[test]
fn engine_settles_configured_futures_expiration_at_settlement() {
let date = d(2025, 1, 2);
@@ -1524,7 +1656,9 @@ fn engine_aggregates_futures_account_into_nav_and_metrics() {
assert_eq!(result.metrics.initial_cash, 600_000.0);
assert!((result.equity_curve[0].total_equity - 599_988.0).abs() < 1e-6);
assert!((result.equity_curve[0].unit_nav - 0.99998).abs() < 1e-12);
assert!((result.metrics.total_assets - 599_988.0).abs() < 1e-6);
assert!((result.metrics.total_return + 0.00002).abs() < 1e-12);
assert_eq!(result.analyzer_report().trades.len(), result.fills.len());
assert_eq!(result.analyzer_report().monthly_returns.len(), 1);
assert_eq!(
@@ -1587,6 +1721,61 @@ fn engine_matches_pending_futures_limit_order_with_data_driven_costs() {
.expect("long futures position");
assert_eq!(position.quantity, 2);
assert!((position.contract_multiplier - 300.0).abs() < 1e-6);
assert_eq!(result.terminal_audit.status, BacktestTerminalStatus::Clean);
assert_eq!(result.terminal_audit.open_order_count(), 0);
}
#[test]
fn engine_reports_pending_futures_order_at_backtest_boundary() {
let date = d(2025, 1, 2);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut engine = BacktestEngine::new(
two_day_futures_data(),
FuturesLimitOrderStrategy,
broker,
BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
)
.with_futures_initial_cash(1_000_000.0);
let result = engine.run().expect("backtest succeeds");
assert!(result.fills.is_empty());
assert_eq!(
result.terminal_audit.status,
BacktestTerminalStatus::CompletedWithPendingState
);
assert_eq!(result.terminal_audit.last_execution_date, Some(date));
assert_eq!(result.terminal_audit.stock_open_order_count, 0);
assert_eq!(result.terminal_audit.futures_open_order_count, 1);
assert_eq!(result.terminal_audit.open_order_count(), 1);
assert_eq!(result.terminal_audit.omitted_open_order_count, 0);
assert_eq!(result.terminal_audit.open_order_samples.len(), 1);
assert_eq!(
result.terminal_audit.open_order_samples[0].asset_class,
BacktestTerminalAssetClass::Futures
);
assert_eq!(result.terminal_audit.open_order_samples[0].symbol, "IF2501");
assert_eq!(
result.terminal_audit.open_order_samples[0].remaining_quantity,
2
);
assert!(
result
.order_events
.iter()
.any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Pending })
);
}
#[test]
@@ -2011,6 +2200,19 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
amount_delta: 10_200.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: "000002.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0),
last_price: 20.4,
bid1: 20.3,
ask1: 20.4,
bid1_volume: 1_000,
ask1_volume: 1_000,
volume_delta: 1_000,
amount_delta: 20_400.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: "000001.SZ".to_string(),
@@ -2029,8 +2231,12 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
.expect("dataset");
let seen_ticks = Rc::new(RefCell::new(Vec::new()));
let scheduled_count = Rc::new(RefCell::new(0usize));
let strategy = MinuteProbeStrategy {
seen_ticks: seen_ticks.clone(),
scheduled_count: scheduled_count.clone(),
subscribe_symbols: BTreeSet::from(["000001.SZ".to_string(), "000002.SZ".to_string()]),
minute_callbacks: true,
ordered: false,
};
let broker = BrokerSimulator::new_with_execution_price(
@@ -2038,6 +2244,8 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
ChinaEquityRuleHooks::default(),
PriceField::Last,
);
let loader_requests = Arc::new(Mutex::new(Vec::<ExecutionQuoteRequest>::new()));
let loader_requests_for_callback = Arc::clone(&loader_requests);
let mut engine = BacktestEngine::new(
data,
strategy,
@@ -2050,7 +2258,11 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
},
);
)
.with_execution_quote_loader(move |request| {
loader_requests_for_callback.lock().unwrap().push(request);
Ok(Vec::new())
});
let result = engine.run().expect("backtest run");
@@ -2058,9 +2270,19 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
seen_ticks.borrow().as_slice(),
[
"000001.SZ:10:18:00:true:visible=10.20:previous=",
"000002.SZ:10:18:00:true:visible=20.40:previous=",
"000001.SZ:10:19:00:true:visible=10.20,10.30:previous=10.20"
]
);
assert_eq!(*scheduled_count.borrow(), 1);
let loader_requests = loader_requests.lock().unwrap();
assert_eq!(loader_requests.len(), 1);
assert_eq!(loader_requests[0].start_time, None);
assert_eq!(loader_requests[0].end_time, None);
assert_eq!(
loader_requests[0].symbols,
BTreeSet::from(["000001.SZ".to_string(), "000002.SZ".to_string()])
);
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].reason, "minute_buy");
assert_eq!(result.fills[0].quantity, 100);
@@ -2082,6 +2304,90 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
.iter()
.any(|event| event.kind == ProcessEventKind::PostMinute)
);
assert_eq!(
result
.process_events
.iter()
.filter(|event| event.kind == ProcessEventKind::PreMinute)
.count(),
2
);
}
#[test]
fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
let date = d(2025, 1, 2);
let mut data = single_day_anchor_data(date);
data.add_execution_quotes(vec![
IntradayExecutionQuote {
date,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0),
last_price: 10.2,
bid1: 10.1,
ask1: 10.2,
bid1_volume: 1_000,
ask1_volume: 1_000,
volume_delta: 1_000,
amount_delta: 10_200.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 19, 0),
last_price: 10.3,
bid1: 10.2,
ask1: 10.3,
bid1_volume: 1_000,
ask1_volume: 1_000,
volume_delta: 1_000,
amount_delta: 10_300.0,
trading_phase: Some("continuous".to_string()),
},
]);
let seen_ticks = Rc::new(RefCell::new(Vec::new()));
let scheduled_count = Rc::new(RefCell::new(0usize));
let strategy = MinuteProbeStrategy {
seen_ticks: seen_ticks.clone(),
scheduled_count: scheduled_count.clone(),
subscribe_symbols: BTreeSet::from(["000001.SZ".to_string()]),
minute_callbacks: false,
ordered: false,
};
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Last,
);
let mut engine = BacktestEngine::new(
data,
strategy,
broker,
BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
},
)
.with_execution_quote_loader(|_| Ok(Vec::new()));
let result = engine.run().expect("scheduled-only minute run");
assert!(seen_ticks.borrow().is_empty());
assert_eq!(*scheduled_count.borrow(), 1);
assert!(result.fills.is_empty());
assert_eq!(
result
.process_events
.iter()
.filter(|event| event.kind == ProcessEventKind::PreMinute)
.count(),
1
);
}
#[test]
@@ -2682,10 +2988,12 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
assert!(result.process_events.iter().any(|event| {
event.kind == ProcessEventKind::AccountManagementFee && event.detail.contains("fee=42.00")
}));
assert_eq!(result.terminal_audit.status, BacktestTerminalStatus::Clean);
assert_eq!(result.terminal_audit.pending_cash_flow_count, 0);
}
#[test]
fn engine_rejects_pending_limit_orders_at_market_close() {
fn engine_expires_pending_day_limit_orders_at_market_close() {
let date1 = d(2025, 1, 2);
let date2 = d(2025, 1, 3);
let data = DataSet::from_components(
@@ -2847,12 +3155,14 @@ fn engine_rejects_pending_limit_orders_at_market_close() {
);
assert!(result.order_events.iter().any(|event| {
event.date == date1
&& event.status == fidc_core::OrderStatus::Rejected
&& event.reason.contains("Market close")
&& event.status == fidc_core::OrderStatus::Expired
&& event.reason.contains("DAY order expired at market close")
}));
assert!(result.process_events.iter().any(|event| {
event.date == date1 && event.kind == ProcessEventKind::OrderUnsolicitedUpdate
}));
assert_eq!(result.terminal_audit.status, BacktestTerminalStatus::Clean);
assert_eq!(result.terminal_audit.stock_open_order_count, 0);
}
#[test]
@@ -3076,12 +3386,15 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
"scheduled:daily_before_trading:2025-01-30",
"scheduled:daily_market_open:2025-01-30",
"scheduled:first_trading_day_on_day:2025-01-30",
"scheduled:daily_on_day_close:2025-01-30",
"scheduled:daily_before_trading:2025-01-31",
"scheduled:daily_market_open:2025-01-31",
"scheduled:friday_on_day:2025-01-31",
"scheduled:daily_on_day_close:2025-01-31",
"scheduled:daily_before_trading:2025-02-03",
"scheduled:daily_market_open:2025-02-03",
"scheduled:first_trading_day_on_day:2025-02-03",
"scheduled:daily_on_day_close:2025-02-03",
]
);
let process_log = process_log.borrow();
File diff suppressed because it is too large Load Diff
+131
View File
@@ -208,3 +208,134 @@ fn futures_expiration_settlement_closes_all_contract_directions() {
);
assert!((account.total_cash() - 1_003_000.0).abs() < 1e-6);
}
#[test]
fn futures_full_close_preserves_closed_position_daily_metrics() {
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
let mut account = FuturesAccountState::new(100_000.0);
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 1.0);
account.begin_trading_day();
let realized = account
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 2.0)
.expect("close overnight position");
assert!(account.positions().is_empty());
assert!((realized - 98.0).abs() < 1e-12);
assert!((account.position_pnl() - 100.0).abs() < 1e-12);
assert!(account.trading_pnl().abs() < 1e-12);
assert!((account.transaction_cost() - 2.0).abs() < 1e-12);
assert!((account.daily_pnl() - 98.0).abs() < 1e-12);
assert!((account.total_cash() - 100_097.0).abs() < 1e-12);
account.begin_trading_day();
assert!(account.daily_pnl().abs() < 1e-12);
assert!(account.transaction_cost().abs() < 1e-12);
}
#[test]
fn futures_intraday_roundtrip_preserves_closed_trading_pnl() {
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
let mut account = FuturesAccountState::new(100_000.0);
account.begin_trading_day();
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 1.0);
account
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 2.0)
.expect("close intraday position");
assert!(account.positions().is_empty());
assert!((account.trading_pnl() - 100.0).abs() < 1e-12);
assert!(account.position_pnl().abs() < 1e-12);
assert!((account.transaction_cost() - 3.0).abs() < 1e-12);
assert!((account.daily_pnl() - 97.0).abs() < 1e-12);
assert!((account.total_cash() - 100_097.0).abs() < 1e-12);
}
#[test]
fn futures_partial_close_offsets_later_mark_with_trading_pnl() {
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
let mut account = FuturesAccountState::new(100_000.0);
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 2, 100.0, 0.0);
account.begin_trading_day();
account
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 0.0)
.expect("partially close overnight position");
account.mark_price("IF2506.CCFX", FuturesDirection::Long, 120.0);
assert!((account.position_pnl() - 400.0).abs() < 1e-12);
assert!((account.trading_pnl() + 100.0).abs() < 1e-12);
assert!((account.daily_pnl() - 300.0).abs() < 1e-12);
assert!((account.total_value() - 100_300.0).abs() < 1e-12);
}
#[test]
fn futures_settlement_keeps_same_day_pnl_visible_until_next_day() {
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
let mut account = FuturesAccountState::new(100_000.0);
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 0.0);
account.begin_trading_day();
account.mark_price("IF2506.CCFX", FuturesDirection::Long, 110.0);
let settled = account.settle(&BTreeMap::from([("IF2506.CCFX".to_string(), 110.0)]));
assert!((settled - 100.0).abs() < 1e-12);
assert!((account.daily_pnl() - 100.0).abs() < 1e-12);
assert!((account.total_cash() - 100_100.0).abs() < 1e-12);
assert!((account.total_value() - 100_100.0).abs() < 1e-12);
account.begin_trading_day();
assert!(account.daily_pnl().abs() < 1e-12);
}
#[test]
fn futures_cash_and_closed_cost_accumulate_micro_yuan_exactly() {
let spec = FuturesContractSpec::new(1.0, 0.0, 0.0);
let mut account = FuturesAccountState::new(1_000_000.0);
account.begin_trading_day();
for _ in 0..10_000 {
account.open(
"IF2506.CCFX",
FuturesDirection::Long,
spec,
1,
100.0,
0.000001,
);
account
.close("IF2506.CCFX", FuturesDirection::Long, 1, 100.0, 0.000001)
.expect("close micro-cost position");
}
assert!((account.total_cash() - 999_999.98).abs() < 1e-12);
assert!((account.transaction_cost() - 0.02).abs() < 1e-12);
assert!((account.daily_pnl() + 0.02).abs() < 1e-12);
}
#[test]
fn futures_margin_gate_and_fill_cash_use_exact_micro_yuan() {
let date = d(2025, 1, 2);
let spec = FuturesContractSpec::new(1.0, 1.0, 1.0);
let intent = FuturesOrderIntent::open(
"IF2506.CCFX",
FuturesDirection::Long,
spec,
1,
100.0,
0.000001,
"micro margin boundary",
);
let mut insufficient = FuturesAccountState::new(100.0);
let rejected = insufficient.execute_order(date, Some(1), intent.clone());
assert_eq!(rejected.order_events[0].status, OrderStatus::Rejected);
assert!((insufficient.total_cash() - 100.0).abs() < 1e-12);
let mut exact = FuturesAccountState::new(100.000001);
let filled = exact.execute_order(date, Some(2), intent);
assert_eq!(filled.order_events[0].status, OrderStatus::Filled);
assert_eq!(filled.fill_events.len(), 1);
assert!((filled.fill_events[0].gross_amount - 100.0).abs() < 1e-12);
assert!((filled.fill_events[0].commission - 0.000001).abs() < 1e-12);
assert!((filled.fill_events[0].net_cash_flow + 0.000001).abs() < 1e-12);
assert!(exact.cash().abs() < 1e-12);
}
+3
View File
@@ -39,6 +39,7 @@ futures path. Confirmed aligned areas:
| P0 | Futures intraday matching | Closed for daily/open/close, tick-price futures fills, and true multi-level order-book sweeping when optional `order_book_depth` data exists. L1-only data still uses the existing L1 matcher and is not inflated into fake depth. | Extend depth fields only if production vendors expose more levels or exchange-specific fields. |
| P0 | Futures open-order lifecycle | Closed for futures pending limit orders, cross-day rematching, cancellation by id/symbol/all, and merged open-order runtime views. | Add more order status transitions only if UI requires extra intermediate event names. |
| P0 | Combined multi-account NAV | Closed. `DailyEquityPoint`, progress events, and metrics use aggregate stock + futures initial cash and total equity. | None. |
| P0 | Fixed-point execution money | Stock execution now freezes fee rates once and uses signed micro-yuan `i128` for gross amount, commission, stamp tax, transfer fee, strict budget checks, cash, liabilities, management fees, external flows and account units. Market indicators and return statistics remain `f64` outside the execution boundary. | Migrate position cost/PnL and the standalone futures cash ledger only after independent artifact and performance A/B gates. |
| P1 | Futures trading parameter data source | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. |
| P1 | Futures transaction cost decider | Closed. `FuturesTransactionCostModel` calculates by-money/by-volume open/close/close-today costs from trading parameters. | None. |
| P1 | Futures settlement price mode | Closed. Engine supports configurable settlement price mode and resolves settlement/prev-settlement from factor fields with close/prev_close fallback. | Add dedicated settlement columns if the storage layer later separates them from factors. |
@@ -55,6 +56,8 @@ futures path. Confirmed aligned areas:
- [x] Minute-level `time_rule` semantics including market-open, market-close,
and physical-time style schedules.
- [x] Fine-grained daily and minute execution quote strategy entrypoints.
- [x] Stock broker fee, budget and cash-ledger arithmetic uses a micro-yuan
fixed-point execution primitive; one-micro over-budget orders fail.
- [x] Scheduled actions evaluated against explicit intraday times.
- [x] `update_universe`, `subscribe`, and `unsubscribe`.
- [x] Intraday subscription guards at strategy API level; intraday execution uses minute quote semantics.