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 2f891d0..1b7af7d 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -203,7 +203,7 @@ impl BacktestResult { quantity: fill.quantity, price: fill.price, gross_amount: fill.gross_amount, - transaction_cost: fill.commission + fill.stamp_tax, + transaction_cost: fill.commission + fill.stamp_tax + fill.transfer_fee, net_cash_flow: fill.net_cash_flow, reason: fill.reason.clone(), }) @@ -2921,6 +2921,7 @@ where &result.equity_curve, &result.fills, &result.daily_holdings, + &result.account_events, self.aggregate_initial_cash(), ); @@ -3169,6 +3170,7 @@ where gross_amount: reinvest_cash, commission: 0.0, stamp_tax: 0.0, + transfer_fee: 0.0, net_cash_flow: -reinvest_cash, reason: "dividend_reinvestment".to_string(), }); 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/metrics.rs b/crates/fidc-core/src/metrics.rs index 2c2899c..678c9c7 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; @@ -61,6 +61,7 @@ pub fn compute_backtest_metrics( equity_curve: &[DailyEquityPoint], fills: &[FillEvent], daily_holdings: &[HoldingSummary], + account_events: &[AccountEvent], initial_cash: f64, ) -> BacktestMetrics { let Some(first_point) = equity_curve.first() else { @@ -86,14 +87,26 @@ pub fn compute_backtest_metrics( } else { first_point.benchmark_close }; - let nav_series = equity_curve - .iter() - .map(|point| point_nav(point, initial_cash)) - .collect::>(); - let mut returns = Vec::with_capacity(nav_series.len()); - returns.push(pct_change(1.0, nav_series[0])); + let explicit_unit_nav = equity_curve.iter().any(|point| { + point.external_cash_flow.abs() > f64::EPSILON + || (point.unit_nav.is_finite() + && point.unit_nav > 0.0 + && (point.unit_nav - safe_div(point.total_equity, initial_cash, 1.0)).abs() > 1e-12) + }); + let portfolio_nav = if explicit_unit_nav { + equity_curve + .iter() + .map(|point| point_nav(point, initial_cash)) + .collect::>() + } else { + flow_neutral_nav_series(equity_curve, account_events, initial_cash) + }; + let mut returns = Vec::with_capacity(portfolio_nav.len()); + if let Some(first_nav) = portfolio_nav.first().copied() { + returns.push(pct_change(1.0, first_nav)); + } returns.extend( - nav_series + portfolio_nav .windows(2) .map(|window| pct_change(window[0], window[1])), ); @@ -116,12 +129,12 @@ pub fn compute_backtest_metrics( last_point.benchmark_close / benchmark_start }; let benchmark_cumulative_return = benchmark_net_value - 1.0; - let final_nav = *nav_series.last().unwrap_or(&1.0); + let final_nav = portfolio_nav.last().copied().unwrap_or(1.0); let total_return = final_nav - 1.0; let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON { total_return } else { - final_nav / 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); @@ -138,7 +151,7 @@ pub fn compute_backtest_metrics( let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR); let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf); - let equity_nav = nav_series; + let equity_nav = portfolio_nav; let benchmark_nav_series = equity_curve .iter() .map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0)) @@ -157,8 +170,7 @@ pub fn compute_backtest_metrics( let win_rate = ratio(winning_days, returns.len()); let excess_win_rate = ratio(excess_winning_days, excess_returns.len()); - let monthly_portfolio_returns = - group_monthly_returns(equity_curve, 1.0, |point| point_nav(point, initial_cash)); + let monthly_portfolio_returns = group_monthly_returns_from_values(equity_curve, &equity_nav); let monthly_benchmark_returns = group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close); let monthly_excess_returns = monthly_portfolio_returns @@ -262,10 +274,14 @@ pub fn compute_backtest_metrics( cash_balance: last_point.cash, unit_nav: final_nav, initial_cash, - external_cash_flow_total: equity_curve - .iter() - .map(|point| point.external_cash_flow) - .sum(), + external_cash_flow_total: if explicit_unit_nav { + equity_curve + .iter() + .map(|point| point.external_cash_flow) + .sum() + } else { + external_flow_total_from_events(account_events) + }, excess_win_rate, monthly_sharpe, monthly_volatility, @@ -399,6 +415,80 @@ fn drawdown_stats(nav: &[f64]) -> (f64, usize) { (max_drawdown, max_duration) } +fn flow_neutral_nav_series( + equity_curve: &[DailyEquityPoint], + account_events: &[AccountEvent], + initial_cash: f64, +) -> Vec { + 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 external_flow_total_from_events(account_events: &[AccountEvent]) -> f64 { + account_events + .iter() + .filter(|event| { + event.note.starts_with("deposit_withdraw amount=") + || event.note.starts_with("deposit_withdraw_settled amount=") + }) + .map(|event| event.cash_after - event.cash_before) + .sum() +} + +fn group_monthly_returns_from_values( + equity_curve: &[DailyEquityPoint], + values: &[f64], +) -> Vec { + 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, @@ -516,7 +606,7 @@ 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); } @@ -527,20 +617,27 @@ mod tests { equity_point("2025-01-02", 100.0, 100.0, 100.0), DailyEquityPoint { date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(), - cash: 200.0, + cash: 220.0, market_value: 0.0, - total_equity: 200.0, + total_equity: 220.0, external_cash_flow: 100.0, - unit_nav: 1.0, + unit_nav: 1.1, benchmark_close: 100.0, benchmark_prev_close: 100.0, notes: String::new(), diagnostics: String::new(), }, ]; - let metrics = compute_backtest_metrics(&curve, &[], &[], 100.0); - assert!((metrics.total_return - 0.0).abs() < 1e-12); - assert!((metrics.unit_nav - 1.0).abs() < 1e-12); + let events = vec![AccountEvent { + date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(), + cash_before: 100.0, + cash_after: 200.0, + total_equity: 200.0, + note: "deposit_withdraw amount=100.00 reason=test".to_string(), + }]; + let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0); + assert!((metrics.total_return - 0.1).abs() < 1e-12); + assert!((metrics.unit_nav - 1.1).abs() < 1e-12); assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12); } } diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 32331d9..b94f30f 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")] @@ -88,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")] @@ -115,6 +119,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")] @@ -158,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")] @@ -278,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")] @@ -339,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", @@ -902,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, @@ -915,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); @@ -1070,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, @@ -1491,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, @@ -1951,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