From 7a1631efa3afad19710ab9777a3092c7fb0c48c3 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 22 Aug 2026 10:59:38 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E8=A1=A5=E5=85=85=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E9=A2=91=E7=8E=87=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/platform_strategy_spec.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 32331d9..f144a91 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -67,6 +67,8 @@ pub struct StrategyRebalanceSpec { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyExecutionSpec { + #[serde(default)] + pub frequency: Option, #[serde(default, alias = "matching_type")] pub matching_type: Option, #[serde(default, alias = "slippage_model")] @@ -115,6 +117,8 @@ pub struct StrategyExecutionSpec { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyEngineConfig { + #[serde(default)] + pub frequency: Option, #[serde(default)] pub template_id: Option, #[serde(default, alias = "benchmark_symbol")] From b8776d7169615a503391d78c8811ce4990b65b84 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 22 Aug 2026 14:32:52 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=85=A5=E9=87=91?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E8=B5=84=E9=87=91=E6=B5=81=E6=94=B6=E7=9B=8A?= =?UTF-8?q?=E6=8C=87=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 1 + crates/fidc-core/src/metrics.rs | 114 +++++++++++++++++++++++++++----- 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 2f85f60..e9d9b23 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -2908,6 +2908,7 @@ where &result.equity_curve, &result.fills, &result.daily_holdings, + &result.account_events, self.aggregate_initial_cash(), ); diff --git a/crates/fidc-core/src/metrics.rs b/crates/fidc-core/src/metrics.rs index e21be97..b8686a4 100644 --- a/crates/fidc-core/src/metrics.rs +++ b/crates/fidc-core/src/metrics.rs @@ -4,7 +4,7 @@ use chrono::{Datelike, NaiveDate}; use serde::{Deserialize, Serialize}; use crate::engine::DailyEquityPoint; -use crate::events::FillEvent; +use crate::events::{AccountEvent, FillEvent}; use crate::portfolio::HoldingSummary; const TRADING_DAYS_PER_YEAR: f64 = 252.0; @@ -56,6 +56,7 @@ pub fn compute_backtest_metrics( equity_curve: &[DailyEquityPoint], fills: &[FillEvent], daily_holdings: &[HoldingSummary], + account_events: &[AccountEvent], initial_cash: f64, ) -> BacktestMetrics { let Some(first_point) = equity_curve.first() else { @@ -81,12 +82,15 @@ pub fn compute_backtest_metrics( } else { first_point.benchmark_close }; - let mut returns = Vec::with_capacity(equity_curve.len()); - returns.push(pct_change(initial_cash, first_point.total_equity)); + let portfolio_nav = flow_neutral_nav_series(equity_curve, account_events, initial_cash); + let mut returns = Vec::with_capacity(portfolio_nav.len()); + if let Some(first_nav) = portfolio_nav.first().copied() { + returns.push(pct_change(1.0, first_nav)); + } returns.extend( - equity_curve + portfolio_nav .windows(2) - .map(|window| pct_change(window[0].total_equity, window[1].total_equity)), + .map(|window| pct_change(window[0], window[1])), ); let mut benchmark_returns = Vec::with_capacity(equity_curve.len()); benchmark_returns.push(pct_change(benchmark_start, first_point.benchmark_close)); @@ -107,15 +111,11 @@ pub fn compute_backtest_metrics( last_point.benchmark_close / benchmark_start }; let benchmark_cumulative_return = benchmark_net_value - 1.0; - let total_return = if initial_cash.abs() < f64::EPSILON { - 0.0 - } else { - (last_point.total_equity / initial_cash) - 1.0 - }; + let total_return = portfolio_nav.last().copied().unwrap_or(0.0) - 1.0; let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON { total_return } else { - (last_point.total_equity / initial_cash) / benchmark_net_value - 1.0 + portfolio_nav.last().copied().unwrap_or(0.0) / benchmark_net_value - 1.0 }; let excess_return = total_return - benchmark_cumulative_return; let annual_return = annualize_return(total_return, trade_days); @@ -132,10 +132,7 @@ pub fn compute_backtest_metrics( let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR); let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf); - let equity_nav = equity_curve - .iter() - .map(|point| safe_div(point.total_equity, initial_cash, 1.0)) - .collect::>(); + let equity_nav = portfolio_nav; let benchmark_nav_series = equity_curve .iter() .map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0)) @@ -155,7 +152,7 @@ pub fn compute_backtest_metrics( let excess_win_rate = ratio(excess_winning_days, excess_returns.len()); let monthly_portfolio_returns = - group_monthly_returns(equity_curve, initial_cash, |point| point.total_equity); + group_monthly_returns_from_values(equity_curve, &equity_nav); let monthly_benchmark_returns = group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close); let monthly_excess_returns = monthly_portfolio_returns @@ -257,7 +254,7 @@ pub fn compute_backtest_metrics( average_daily_turnover, total_assets: last_point.total_equity, cash_balance: last_point.cash, - unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0), + unit_nav: equity_nav.last().copied().unwrap_or_default(), initial_cash, excess_win_rate, monthly_sharpe, @@ -384,6 +381,69 @@ fn drawdown_stats(nav: &[f64]) -> (f64, usize) { (max_drawdown, max_duration) } +fn flow_neutral_nav_series( + equity_curve: &[DailyEquityPoint], + account_events: &[AccountEvent], + initial_cash: f64, +) -> Vec { + let mut external_flow_by_date = BTreeMap::::new(); + for event in account_events { + if !(event.note.starts_with("deposit_withdraw amount=") + || event.note.starts_with("deposit_withdraw_settled amount=")) + { + continue; + } + *external_flow_by_date.entry(event.date).or_default() += + event.cash_after - event.cash_before; + } + + let mut units = initial_cash; + let mut previous_equity = initial_cash; + let mut navs = Vec::with_capacity(equity_curve.len()); + for point in equity_curve { + let unit_nav_before_flow = safe_div(previous_equity, units, 1.0); + let external_flow = external_flow_by_date + .get(&point.date) + .copied() + .unwrap_or_default(); + if external_flow.abs() > f64::EPSILON && unit_nav_before_flow.is_finite() { + units += external_flow / unit_nav_before_flow; + } + let unit_nav = safe_div(point.total_equity, units, 0.0); + navs.push(unit_nav); + previous_equity = point.total_equity; + } + navs +} + +fn group_monthly_returns_from_values( + equity_curve: &[DailyEquityPoint], + values: &[f64], +) -> Vec { + let mut month_last = BTreeMap::<(i32, u32), f64>::new(); + let mut month_first = BTreeMap::<(i32, u32), f64>::new(); + let mut previous_value = 1.0; + for (point, value) in equity_curve.iter().zip(values.iter().copied()) { + let key = (point.date.year(), point.date.month()); + month_first.entry(key).or_insert(previous_value); + month_last.insert(key, value); + previous_value = value; + } + let mut keys = month_last.keys().copied().collect::>(); + keys.sort_unstable(); + keys.into_iter() + .filter_map(|key| { + let first = month_first.get(&key).copied().unwrap_or_default(); + let last = month_last.get(&key).copied().unwrap_or_default(); + if first.abs() < f64::EPSILON { + None + } else { + Some((last / first) - 1.0) + } + }) + .collect() +} + fn group_monthly_returns( equity_curve: &[DailyEquityPoint], initial_value: f64, @@ -499,8 +559,26 @@ mod tests { equity_point("2025-01-02", 100.0, 5797.089, 5957.717), equity_point("2025-12-31", 120.0, 7595.285, 7597.299), ]; - let metrics = compute_backtest_metrics(&curve, &[], &[], 100.0); + let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0); let expected = 7595.285 / 5957.717 - 1.0; assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12); } + + #[test] + fn external_deposit_does_not_count_as_strategy_return() { + let curve = vec![ + equity_point("2025-01-02", 100.0, 100.0, 100.0), + equity_point("2025-01-03", 220.0, 100.0, 100.0), + ]; + let events = vec![AccountEvent { + date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(), + cash_before: 100.0, + cash_after: 200.0, + total_equity: 200.0, + note: "deposit_withdraw amount=100.00 reason=test".to_string(), + }]; + let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0); + assert!((metrics.total_return - 0.1).abs() < 1e-12); + assert!((metrics.unit_nav - 1.1).abs() < 1e-12); + } } From 375b8b2df1059498734023f776347d22ad878fc1 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 22 Aug 2026 14:50:00 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=8F=AF=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E8=BF=87=E6=88=B7=E8=B4=B9=E5=B9=B6=E7=BA=B3=E5=85=A5?= =?UTF-8?q?=E6=88=90=E4=BA=A4=E6=88=90=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 2 ++ crates/fidc-core/src/cost.rs | 21 ++++++++++++++++++- crates/fidc-core/src/engine.rs | 3 ++- crates/fidc-core/src/events.rs | 1 + crates/fidc-core/src/futures.rs | 1 + .../fidc-core/src/platform_strategy_spec.rs | 14 +++++++++++++ crates/fidc-core/src/risk_control.rs | 2 ++ crates/fidc-core/src/strategy.rs | 2 +- 8 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 9b7aaa0..9ec0e9b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -3749,6 +3749,7 @@ where gross_amount, commission: cost.commission, stamp_tax: cost.stamp_tax, + transfer_fee: cost.transfer_fee, net_cash_flow: net_cash, reason: reason.to_string(), }); @@ -5400,6 +5401,7 @@ where gross_amount, commission: cost.commission, stamp_tax: cost.stamp_tax, + transfer_fee: cost.transfer_fee, net_cash_flow: -cash_out, reason: reason.to_string(), }); diff --git a/crates/fidc-core/src/cost.rs b/crates/fidc-core/src/cost.rs index 4aff6ef..7537692 100644 --- a/crates/fidc-core/src/cost.rs +++ b/crates/fidc-core/src/cost.rs @@ -9,11 +9,12 @@ use crate::risk_control::TradingConstraintConfig; pub struct TradingCost { pub commission: f64, pub stamp_tax: f64, + pub transfer_fee: f64, } impl TradingCost { pub fn total(self) -> f64 { - self.commission + self.stamp_tax + self.commission + self.stamp_tax + self.transfer_fee } } @@ -39,6 +40,7 @@ pub struct ChinaAShareCostModel { pub stamp_tax_rate_after_change: f64, pub stamp_tax_change_date: NaiveDate, pub minimum_commission: f64, + pub transfer_fee_rate: f64, } impl Default for ChinaAShareCostModel { @@ -55,6 +57,7 @@ impl ChinaAShareCostModel { stamp_tax_rate_after_change: config.stamp_tax_rate_after_change, stamp_tax_change_date: config.stamp_tax_change_date, minimum_commission: config.minimum_commission, + transfer_fee_rate: config.transfer_fee_rate, } } @@ -80,6 +83,13 @@ impl ChinaAShareCostModel { gross_amount * self.stamp_tax_rate_for(date) } + pub fn transfer_fee_for(&self, gross_amount: f64) -> f64 { + if gross_amount <= 0.0 { + return 0.0; + } + gross_amount * self.transfer_fee_rate + } + pub fn commission_for_order_fill( &self, gross_amount: f64, @@ -124,15 +134,18 @@ impl CostModel for ChinaAShareCostModel { return TradingCost { commission: 0.0, stamp_tax: 0.0, + transfer_fee: 0.0, }; } let commission = self.commission_for(gross_amount); let stamp_tax = self.stamp_tax_for(date, side, gross_amount); + let transfer_fee = self.transfer_fee_for(gross_amount); TradingCost { commission, stamp_tax, + transfer_fee, } } @@ -148,15 +161,18 @@ impl CostModel for ChinaAShareCostModel { return TradingCost { commission: 0.0, stamp_tax: 0.0, + transfer_fee: 0.0, }; } let commission = self.commission_for_order_fill(gross_amount, order_id, commission_state); let stamp_tax = self.stamp_tax_for(date, side, gross_amount); + let transfer_fee = self.transfer_fee_for(gross_amount); TradingCost { commission, stamp_tax, + transfer_fee, } } } @@ -181,6 +197,7 @@ mod tests { let config = TradingConstraintConfig { commission_rate: 0.0003, minimum_commission: 5.0, + transfer_fee_rate: 0.00001, stamp_tax_rate_before_change: 0.002, stamp_tax_rate_after_change: 0.001, stamp_tax_change_date: NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"), @@ -188,6 +205,8 @@ mod tests { }; let model = ChinaAShareCostModel::from_trading_constraints(config); + assert!((model.transfer_fee_for(10_000.0) - 0.1).abs() < 1e-12); + assert!( (model.stamp_tax_for( NaiveDate::from_ymd_opt(2025, 1, 9).expect("valid date"), diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index e9d9b23..c0331f9 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -196,7 +196,7 @@ impl BacktestResult { quantity: fill.quantity, price: fill.price, gross_amount: fill.gross_amount, - transaction_cost: fill.commission + fill.stamp_tax, + transaction_cost: fill.commission + fill.stamp_tax + fill.transfer_fee, net_cash_flow: fill.net_cash_flow, reason: fill.reason.clone(), }) @@ -3157,6 +3157,7 @@ where gross_amount: reinvest_cash, commission: 0.0, stamp_tax: 0.0, + transfer_fee: 0.0, net_cash_flow: -reinvest_cash, reason: "dividend_reinvestment".to_string(), }); diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 554fc18..6aafe4a 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -125,6 +125,7 @@ pub struct FillEvent { pub gross_amount: f64, pub commission: f64, pub stamp_tax: f64, + pub transfer_fee: f64, pub net_cash_flow: f64, pub reason: String, } diff --git a/crates/fidc-core/src/futures.rs b/crates/fidc-core/src/futures.rs index be05c07..5a85f68 100644 --- a/crates/fidc-core/src/futures.rs +++ b/crates/fidc-core/src/futures.rs @@ -837,6 +837,7 @@ impl FuturesAccountState { gross_amount: notional, commission: intent.transaction_cost.max(0.0), stamp_tax: 0.0, + transfer_fee: 0.0, net_cash_flow: cash_delta, reason: format!( "{} direction={} effect={}", diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index f144a91..b94f30f 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -90,6 +90,8 @@ pub struct StrategyExecutionSpec { alias = "minCommission" )] pub minimum_commission: Option, + #[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")] + pub transfer_fee_rate: Option, #[serde(default, alias = "stamp_tax_rate")] pub stamp_tax_rate: Option, #[serde(default, alias = "stamp_tax_rate_before_change")] @@ -162,6 +164,8 @@ pub struct StrategyEngineConfig { alias = "minCommission" )] pub minimum_commission: Option, + #[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")] + pub transfer_fee_rate: Option, #[serde(default, alias = "stamp_tax_rate")] pub stamp_tax_rate: Option, #[serde(default, alias = "stamp_tax_rate_before_change")] @@ -282,6 +286,8 @@ pub struct StrategyRiskPolicySpec { alias = "minCommission" )] pub minimum_commission: Option, + #[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")] + pub transfer_fee_rate: Option, #[serde(default, alias = "stamp_tax_rate")] pub stamp_tax_rate: Option, #[serde(default, alias = "stamp_tax_rate_before_change")] @@ -343,6 +349,7 @@ const RISK_POLICY_VALUE_ALIAS_GROUPS: &[(&str, &[&str])] = &[ "minimumCommission", &["minimum_commission", "min_commission", "minCommission"], ), + ("transferFeeRate", &["transfer_fee_rate"]), ("stampTaxRate", &["stamp_tax_rate"]), ( "stampTaxRateBeforeChange", @@ -906,6 +913,7 @@ fn apply_cost_overrides( cfg: &mut PlatformExprStrategyConfig, commission_rate: Option, minimum_commission: Option, + transfer_fee_rate: Option, stamp_tax_rate: Option, stamp_tax_rate_before_change: Option, stamp_tax_rate_after_change: Option, @@ -919,6 +927,9 @@ fn apply_cost_overrides( cfg.minimum_commission = Some(value); cfg.risk_config.trading_constraints.minimum_commission = value; } + if let Some(value) = valid_non_negative(transfer_fee_rate) { + cfg.risk_config.trading_constraints.transfer_fee_rate = value; + } if let Some(value) = valid_non_negative(stamp_tax_rate) { cfg.stamp_tax_rate_before_change = Some(value); cfg.stamp_tax_rate_after_change = Some(value); @@ -1074,6 +1085,7 @@ fn apply_risk_policy_overrides( cfg, policy.commission_rate, policy.minimum_commission, + policy.transfer_fee_rate, policy.stamp_tax_rate, policy.stamp_tax_rate_before_change, policy.stamp_tax_rate_after_change, @@ -1495,6 +1507,7 @@ pub fn platform_expr_config_from_spec( &mut cfg, engine.commission_rate, engine.minimum_commission, + engine.transfer_fee_rate, engine.stamp_tax_rate, engine.stamp_tax_rate_before_change, engine.stamp_tax_rate_after_change, @@ -1955,6 +1968,7 @@ pub fn platform_expr_config_from_spec( &mut cfg, execution.commission_rate, execution.minimum_commission, + execution.transfer_fee_rate, execution.stamp_tax_rate, execution.stamp_tax_rate_before_change, execution.stamp_tax_rate_after_change, diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 99c26d5..801ffb6 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -82,6 +82,7 @@ pub struct TradingConstraintConfig { pub liquidity_limit_enabled: bool, pub commission_rate: f64, pub minimum_commission: f64, + pub transfer_fee_rate: f64, pub stamp_tax_rate_before_change: f64, pub stamp_tax_rate_after_change: f64, pub stamp_tax_change_date: NaiveDate, @@ -95,6 +96,7 @@ impl Default for TradingConstraintConfig { liquidity_limit_enabled: true, commission_rate: 0.0003, minimum_commission: 5.0, + transfer_fee_rate: 0.0, stamp_tax_rate_before_change: 0.001, stamp_tax_rate_after_change: 0.0005, stamp_tax_change_date: NaiveDate::from_ymd_opt(2023, 8, 28) diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 1732f40..0369015 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -315,7 +315,7 @@ impl StrategyContext<'_> { let gross_amount = fills.iter().map(|fill| fill.gross_amount).sum::(); let transaction_cost = fills .iter() - .map(|fill| fill.commission + fill.stamp_tax) + .map(|fill| fill.commission + fill.stamp_tax + fill.transfer_fee) .sum::(); let avg_price = if filled_quantity == 0 { 0.0