Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c86a0e2339 | |||
| ed126a3630 | |||
| 45cafa5c96 | |||
| d0639558b3 | |||
| 8dccf8414f | |||
| d84fad721d | |||
| ce4d17c293 | |||
| 97e9a83dd2 | |||
| 9db2a9f79c | |||
| 801a27dace | |||
| 00ec7a6d55 | |||
| 9b00a0777a | |||
| 5a765766e3 | |||
| 6ee1835ca5 | |||
| cdbd8a67de | |||
| 8d7bb60c30 | |||
| 21cfa65af2 | |||
| 01d1e5073d | |||
| 5c300f8181 | |||
| 78c5b72ed3 | |||
| 32b3122457 | |||
| 50690540cd | |||
| 71b4ffcecf | |||
| 7f66bcfff7 | |||
| 422e5f1021 | |||
| 68bff3f661 | |||
| b92a09b5ed | |||
| e867aea3b1 | |||
| 32693dad30 | |||
| 723ce93623 | |||
| b05bd3fc1b | |||
| 48acd66c30 | |||
| 861ed483b5 | |||
| 3926ac2985 | |||
| a72a4518d3 | |||
| 255fc2b878 | |||
| dbaf7b45af | |||
| 8e238f9131 | |||
| 88f5a1a0ae | |||
| bc0f5f6089 | |||
| 0793473210 | |||
| 8303a6477b | |||
| 935dd47e34 | |||
| 8fcf34b3a9 | |||
| c18306aed9 | |||
| 9399a61b46 | |||
| 33370fb694 | |||
| 82604481b6 | |||
| 283bf56e9f | |||
| d3bacffd8b | |||
| 670686681d | |||
| 5929fedf91 | |||
| 8b246a63f0 | |||
| 0867655d85 | |||
| 782bc640ff | |||
| 77622e164c | |||
| 6604afd24f | |||
| bf2e3af4eb | |||
| d071a8a190 | |||
| afef38e45e | |||
| ac30d86b6a | |||
| 01cffb947c | |||
| fac5078dbf | |||
| 68ebe76f24 | |||
| c284cc191e | |||
| 90da7f8a21 | |||
| 2b94d5148f | |||
| 2574b9375d | |||
| e368bad7e4 | |||
| 5b6b3682dd | |||
| 92724c6ab0 | |||
| c9ddff46dd | |||
| 5ff8ddca92 | |||
| 85cfdca14c | |||
| 5482c8a52d | |||
| 2a6bbb82a6 | |||
| 24e4ac9284 | |||
| 81d70f18b3 | |||
| 85c9d03b99 | |||
| a147c495af | |||
| 4cf0224d2d | |||
| 7503dc8517 | |||
| 1c04318ecf | |||
| 4b577517a9 | |||
| c52478708f | |||
| 1d7ac19886 | |||
| 0686532be0 | |||
| 911074ae95 | |||
| 555f2ab9bd | |||
| a79077af17 | |||
| 61a4172bd4 | |||
| 589f94e5b2 | |||
| 8254ebbb47 | |||
| ea79fdae46 | |||
| 2013314e4f | |||
| 869c14e2b0 | |||
| cea079a770 | |||
| 9a7e5c7903 | |||
| 279d6a100f | |||
| 7afb72dca8 |
Generated
+1
@@ -146,6 +146,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
name = "fidc-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"chrono",
|
||||
"indexmap",
|
||||
"rayon",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ahash.workspace = true
|
||||
chrono.workspace = true
|
||||
indexmap.workspace = true
|
||||
rayon.workspace = true
|
||||
|
||||
+1203
-191
File diff suppressed because it is too large
Load Diff
+153
-61
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1191
-399
File diff suppressed because it is too large
Load Diff
+811
-190
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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,116 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+502
-164
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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 使用决策日当日 close,next_bar_open 在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数,保证执行金额加手续费不超过分配金额;禁止把执行日 open/high/low/close 解释为下单前已知数据,也禁止用 T+1 prev_close 或 T 日估算股数直接成交;next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。金额预算始终严格,execution.strict_value_budget(false) 会被拒绝。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。日线调仓现金口径由 execution.rebalance_cash_mode(\"sell_then_buy\" | \"same_point_net\" | \"pre_open_cash\") 或页面/API 参数控制,默认 sell_then_buy;sell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 请求滑点率,例如 0.002) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "期货提交校验".to_string(),
|
||||
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_to,order.target_portfolio_smart(...) 对应 平台内核 的 order_target_portfolio_smart 批量目标权重语义;account.deposit_withdraw(...) 和 account.finance_repay(...) 对应 平台内核 账户出入金与融资/还款语义;order_prices 既可以是逐标的限价映射,也可以是 VWAPOrder/TWAPOrder 这类全局 AlgoOrder;order.vwap_* / order.twap_* 对应 平台内核 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 平台内核 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||
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[].timeInForce:DAY 日内保留并在收盘 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/GTC;paper/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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use fidc_core::{
|
||||
BenchmarkSnapshot, DailyMarketSnapshot, DataSet, Instrument, IntradayExecutionQuote,
|
||||
};
|
||||
|
||||
const SYMBOL: &str = "000001.SZ";
|
||||
|
||||
fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
|
||||
let start = NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid start date");
|
||||
let dates = (0..day_count)
|
||||
.map(|offset| start + Duration::days(offset as i64))
|
||||
.collect::<Vec<_>>();
|
||||
let markets = dates
|
||||
.iter()
|
||||
.map(|date| DailyMarketSnapshot {
|
||||
date: *date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
timestamp: None,
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.5,
|
||||
low: 9.5,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 9.99,
|
||||
ask1: 10.01,
|
||||
prev_close: 10.0,
|
||||
volume: 1_000_000,
|
||||
minute_volume: 1_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let benchmarks = dates
|
||||
.iter()
|
||||
.map(|date| BenchmarkSnapshot {
|
||||
date: *date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1_000.0,
|
||||
close: 1_000.0,
|
||||
prev_close: 1_000.0,
|
||||
volume: 10_000_000,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut quotes = Vec::with_capacity(day_count * bars_per_day);
|
||||
for date in &dates {
|
||||
let session_start = date
|
||||
.and_hms_opt(9, 30, 0)
|
||||
.expect("valid session start");
|
||||
for offset in 0..bars_per_day {
|
||||
let timestamp = session_start + Duration::minutes(offset as i64);
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
date: *date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
timestamp,
|
||||
last_price: 10.0 + offset as f64 / 10_000.0,
|
||||
bid1: 9.99,
|
||||
ask1: 10.01,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
volume_delta: 1_000,
|
||||
amount_delta: 10_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
vec![Instrument {
|
||||
symbol: SYMBOL.to_string(),
|
||||
name: "平安银行".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(start - Duration::days(1_000)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
markets,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
benchmarks,
|
||||
Vec::new(),
|
||||
quotes,
|
||||
)
|
||||
.expect("build intraday history dataset");
|
||||
(data, dates)
|
||||
}
|
||||
|
||||
fn timestamp(date: NaiveDate, time: &str) -> NaiveDateTime {
|
||||
let time = NaiveTime::parse_from_str(time, "%H:%M:%S").expect("valid time");
|
||||
date.and_time(time)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intraday_history_is_bounded_by_visibility_and_preserves_order() {
|
||||
let (data, dates) = dataset(3, 4);
|
||||
let rows = data.history_intraday_quotes_at(
|
||||
dates[1],
|
||||
Some(timestamp(dates[1], "09:32:00")),
|
||||
SYMBOL,
|
||||
3,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
rows.iter().map(|row| row.timestamp).collect::<Vec<_>>(),
|
||||
vec![
|
||||
timestamp(dates[0], "09:33:00"),
|
||||
timestamp(dates[1], "09:30:00"),
|
||||
timestamp(dates[1], "09:31:00"),
|
||||
]
|
||||
);
|
||||
|
||||
let including_now = data.history_intraday_quotes_at(
|
||||
dates[1],
|
||||
Some(timestamp(dates[1], "09:32:00")),
|
||||
SYMBOL,
|
||||
3,
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
including_now
|
||||
.iter()
|
||||
.map(|row| row.timestamp)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
timestamp(dates[1], "09:30:00"),
|
||||
timestamp(dates[1], "09:31:00"),
|
||||
timestamp(dates[1], "09:32:00"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual release-mode intraday history benchmark"]
|
||||
fn benchmark_bounded_intraday_history() {
|
||||
let (data, dates) = dataset(250, 240);
|
||||
let active_datetime = timestamp(*dates.last().expect("last date"), "13:29:00");
|
||||
|
||||
for _ in 0..5 {
|
||||
black_box(data.history_intraday_quotes_at(
|
||||
active_datetime.date(),
|
||||
Some(active_datetime),
|
||||
SYMBOL,
|
||||
30,
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0_i64;
|
||||
for _ in 0..200 {
|
||||
let rows = data.history_intraday_quotes_at(
|
||||
active_datetime.date(),
|
||||
Some(active_datetime),
|
||||
SYMBOL,
|
||||
30,
|
||||
true,
|
||||
);
|
||||
checksum += rows
|
||||
.last()
|
||||
.expect("history row")
|
||||
.timestamp
|
||||
.and_utc()
|
||||
.timestamp();
|
||||
black_box(&rows);
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
eprintln!(
|
||||
"intraday_history_benchmark iterations=200 rows_per_dataset=60000 elapsed_seconds={:.6} checksum={checksum}",
|
||||
elapsed.as_secs_f64(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual release-mode quote-stream benchmark"]
|
||||
fn benchmark_borrowed_execution_quote_stream() {
|
||||
let (data, dates) = dataset(250, 240);
|
||||
let date = *dates.last().expect("last date");
|
||||
let symbols = std::collections::BTreeSet::from([SYMBOL.to_string()]);
|
||||
|
||||
for _ in 0..5 {
|
||||
black_box(data.execution_quotes_on_date_for_symbols(date, Some(&symbols)));
|
||||
black_box(
|
||||
data.execution_quotes_iter_on_date_for_symbols(date, Some(&symbols))
|
||||
.count(),
|
||||
);
|
||||
}
|
||||
|
||||
let materialized_started = Instant::now();
|
||||
let mut materialized_checksum = 0_i64;
|
||||
for _ in 0..5_000 {
|
||||
let rows = data.execution_quotes_on_date_for_symbols(date, Some(&symbols));
|
||||
materialized_checksum += rows
|
||||
.iter()
|
||||
.map(|quote| quote.timestamp.and_utc().timestamp())
|
||||
.sum::<i64>();
|
||||
black_box(rows);
|
||||
}
|
||||
let materialized_seconds = materialized_started.elapsed().as_secs_f64();
|
||||
|
||||
let streamed_started = Instant::now();
|
||||
let mut streamed_checksum = 0_i64;
|
||||
for _ in 0..5_000 {
|
||||
let count = data
|
||||
.execution_quotes_iter_on_date_for_symbols(date, Some(&symbols))
|
||||
.map(|quote| quote.timestamp.and_utc().timestamp())
|
||||
.sum::<i64>();
|
||||
streamed_checksum += count;
|
||||
black_box(count);
|
||||
}
|
||||
let streamed_seconds = streamed_started.elapsed().as_secs_f64();
|
||||
eprintln!(
|
||||
"quote_stream_benchmark iterations=5000 rows_per_day=240 materialized_seconds={materialized_seconds:.6} streamed_seconds={streamed_seconds:.6} materialized_checksum={materialized_checksum} streamed_checksum={streamed_checksum}"
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user