Merge remote-tracking branch 'origin/main'
# Conflicts: # crates/fidc-core/src/metrics.rs
This commit is contained in:
@@ -3749,6 +3749,7 @@ where
|
|||||||
gross_amount,
|
gross_amount,
|
||||||
commission: cost.commission,
|
commission: cost.commission,
|
||||||
stamp_tax: cost.stamp_tax,
|
stamp_tax: cost.stamp_tax,
|
||||||
|
transfer_fee: cost.transfer_fee,
|
||||||
net_cash_flow: net_cash,
|
net_cash_flow: net_cash,
|
||||||
reason: reason.to_string(),
|
reason: reason.to_string(),
|
||||||
});
|
});
|
||||||
@@ -5400,6 +5401,7 @@ where
|
|||||||
gross_amount,
|
gross_amount,
|
||||||
commission: cost.commission,
|
commission: cost.commission,
|
||||||
stamp_tax: cost.stamp_tax,
|
stamp_tax: cost.stamp_tax,
|
||||||
|
transfer_fee: cost.transfer_fee,
|
||||||
net_cash_flow: -cash_out,
|
net_cash_flow: -cash_out,
|
||||||
reason: reason.to_string(),
|
reason: reason.to_string(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ use crate::risk_control::TradingConstraintConfig;
|
|||||||
pub struct TradingCost {
|
pub struct TradingCost {
|
||||||
pub commission: f64,
|
pub commission: f64,
|
||||||
pub stamp_tax: f64,
|
pub stamp_tax: f64,
|
||||||
|
pub transfer_fee: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TradingCost {
|
impl TradingCost {
|
||||||
pub fn total(self) -> f64 {
|
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_rate_after_change: f64,
|
||||||
pub stamp_tax_change_date: NaiveDate,
|
pub stamp_tax_change_date: NaiveDate,
|
||||||
pub minimum_commission: f64,
|
pub minimum_commission: f64,
|
||||||
|
pub transfer_fee_rate: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ChinaAShareCostModel {
|
impl Default for ChinaAShareCostModel {
|
||||||
@@ -55,6 +57,7 @@ impl ChinaAShareCostModel {
|
|||||||
stamp_tax_rate_after_change: config.stamp_tax_rate_after_change,
|
stamp_tax_rate_after_change: config.stamp_tax_rate_after_change,
|
||||||
stamp_tax_change_date: config.stamp_tax_change_date,
|
stamp_tax_change_date: config.stamp_tax_change_date,
|
||||||
minimum_commission: config.minimum_commission,
|
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)
|
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(
|
pub fn commission_for_order_fill(
|
||||||
&self,
|
&self,
|
||||||
gross_amount: f64,
|
gross_amount: f64,
|
||||||
@@ -124,15 +134,18 @@ impl CostModel for ChinaAShareCostModel {
|
|||||||
return TradingCost {
|
return TradingCost {
|
||||||
commission: 0.0,
|
commission: 0.0,
|
||||||
stamp_tax: 0.0,
|
stamp_tax: 0.0,
|
||||||
|
transfer_fee: 0.0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let commission = self.commission_for(gross_amount);
|
let commission = self.commission_for(gross_amount);
|
||||||
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
|
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
|
||||||
|
let transfer_fee = self.transfer_fee_for(gross_amount);
|
||||||
|
|
||||||
TradingCost {
|
TradingCost {
|
||||||
commission,
|
commission,
|
||||||
stamp_tax,
|
stamp_tax,
|
||||||
|
transfer_fee,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,15 +161,18 @@ impl CostModel for ChinaAShareCostModel {
|
|||||||
return TradingCost {
|
return TradingCost {
|
||||||
commission: 0.0,
|
commission: 0.0,
|
||||||
stamp_tax: 0.0,
|
stamp_tax: 0.0,
|
||||||
|
transfer_fee: 0.0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let commission = self.commission_for_order_fill(gross_amount, order_id, commission_state);
|
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 stamp_tax = self.stamp_tax_for(date, side, gross_amount);
|
||||||
|
let transfer_fee = self.transfer_fee_for(gross_amount);
|
||||||
|
|
||||||
TradingCost {
|
TradingCost {
|
||||||
commission,
|
commission,
|
||||||
stamp_tax,
|
stamp_tax,
|
||||||
|
transfer_fee,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +197,7 @@ mod tests {
|
|||||||
let config = TradingConstraintConfig {
|
let config = TradingConstraintConfig {
|
||||||
commission_rate: 0.0003,
|
commission_rate: 0.0003,
|
||||||
minimum_commission: 5.0,
|
minimum_commission: 5.0,
|
||||||
|
transfer_fee_rate: 0.00001,
|
||||||
stamp_tax_rate_before_change: 0.002,
|
stamp_tax_rate_before_change: 0.002,
|
||||||
stamp_tax_rate_after_change: 0.001,
|
stamp_tax_rate_after_change: 0.001,
|
||||||
stamp_tax_change_date: NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"),
|
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);
|
let model = ChinaAShareCostModel::from_trading_constraints(config);
|
||||||
|
|
||||||
|
assert!((model.transfer_fee_for(10_000.0) - 0.1).abs() < 1e-12);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
(model.stamp_tax_for(
|
(model.stamp_tax_for(
|
||||||
NaiveDate::from_ymd_opt(2025, 1, 9).expect("valid date"),
|
NaiveDate::from_ymd_opt(2025, 1, 9).expect("valid date"),
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ impl BacktestResult {
|
|||||||
quantity: fill.quantity,
|
quantity: fill.quantity,
|
||||||
price: fill.price,
|
price: fill.price,
|
||||||
gross_amount: fill.gross_amount,
|
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,
|
net_cash_flow: fill.net_cash_flow,
|
||||||
reason: fill.reason.clone(),
|
reason: fill.reason.clone(),
|
||||||
})
|
})
|
||||||
@@ -2921,6 +2921,7 @@ where
|
|||||||
&result.equity_curve,
|
&result.equity_curve,
|
||||||
&result.fills,
|
&result.fills,
|
||||||
&result.daily_holdings,
|
&result.daily_holdings,
|
||||||
|
&result.account_events,
|
||||||
self.aggregate_initial_cash(),
|
self.aggregate_initial_cash(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3169,6 +3170,7 @@ where
|
|||||||
gross_amount: reinvest_cash,
|
gross_amount: reinvest_cash,
|
||||||
commission: 0.0,
|
commission: 0.0,
|
||||||
stamp_tax: 0.0,
|
stamp_tax: 0.0,
|
||||||
|
transfer_fee: 0.0,
|
||||||
net_cash_flow: -reinvest_cash,
|
net_cash_flow: -reinvest_cash,
|
||||||
reason: "dividend_reinvestment".to_string(),
|
reason: "dividend_reinvestment".to_string(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ pub struct FillEvent {
|
|||||||
pub gross_amount: f64,
|
pub gross_amount: f64,
|
||||||
pub commission: f64,
|
pub commission: f64,
|
||||||
pub stamp_tax: f64,
|
pub stamp_tax: f64,
|
||||||
|
pub transfer_fee: f64,
|
||||||
pub net_cash_flow: f64,
|
pub net_cash_flow: f64,
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -837,6 +837,7 @@ impl FuturesAccountState {
|
|||||||
gross_amount: notional,
|
gross_amount: notional,
|
||||||
commission: intent.transaction_cost.max(0.0),
|
commission: intent.transaction_cost.max(0.0),
|
||||||
stamp_tax: 0.0,
|
stamp_tax: 0.0,
|
||||||
|
transfer_fee: 0.0,
|
||||||
net_cash_flow: cash_delta,
|
net_cash_flow: cash_delta,
|
||||||
reason: format!(
|
reason: format!(
|
||||||
"{} direction={} effect={}",
|
"{} direction={} effect={}",
|
||||||
|
|||||||
+121
-24
@@ -4,7 +4,7 @@ use chrono::{Datelike, NaiveDate};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::engine::DailyEquityPoint;
|
use crate::engine::DailyEquityPoint;
|
||||||
use crate::events::FillEvent;
|
use crate::events::{AccountEvent, FillEvent};
|
||||||
use crate::portfolio::HoldingSummary;
|
use crate::portfolio::HoldingSummary;
|
||||||
|
|
||||||
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
|
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
|
||||||
@@ -61,6 +61,7 @@ pub fn compute_backtest_metrics(
|
|||||||
equity_curve: &[DailyEquityPoint],
|
equity_curve: &[DailyEquityPoint],
|
||||||
fills: &[FillEvent],
|
fills: &[FillEvent],
|
||||||
daily_holdings: &[HoldingSummary],
|
daily_holdings: &[HoldingSummary],
|
||||||
|
account_events: &[AccountEvent],
|
||||||
initial_cash: f64,
|
initial_cash: f64,
|
||||||
) -> BacktestMetrics {
|
) -> BacktestMetrics {
|
||||||
let Some(first_point) = equity_curve.first() else {
|
let Some(first_point) = equity_curve.first() else {
|
||||||
@@ -86,14 +87,26 @@ pub fn compute_backtest_metrics(
|
|||||||
} else {
|
} else {
|
||||||
first_point.benchmark_close
|
first_point.benchmark_close
|
||||||
};
|
};
|
||||||
let nav_series = equity_curve
|
let explicit_unit_nav = equity_curve.iter().any(|point| {
|
||||||
.iter()
|
point.external_cash_flow.abs() > f64::EPSILON
|
||||||
.map(|point| point_nav(point, initial_cash))
|
|| (point.unit_nav.is_finite()
|
||||||
.collect::<Vec<_>>();
|
&& point.unit_nav > 0.0
|
||||||
let mut returns = Vec::with_capacity(nav_series.len());
|
&& (point.unit_nav - safe_div(point.total_equity, initial_cash, 1.0)).abs() > 1e-12)
|
||||||
returns.push(pct_change(1.0, nav_series[0]));
|
});
|
||||||
|
let portfolio_nav = if explicit_unit_nav {
|
||||||
|
equity_curve
|
||||||
|
.iter()
|
||||||
|
.map(|point| point_nav(point, initial_cash))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
flow_neutral_nav_series(equity_curve, account_events, initial_cash)
|
||||||
|
};
|
||||||
|
let mut returns = Vec::with_capacity(portfolio_nav.len());
|
||||||
|
if let Some(first_nav) = portfolio_nav.first().copied() {
|
||||||
|
returns.push(pct_change(1.0, first_nav));
|
||||||
|
}
|
||||||
returns.extend(
|
returns.extend(
|
||||||
nav_series
|
portfolio_nav
|
||||||
.windows(2)
|
.windows(2)
|
||||||
.map(|window| pct_change(window[0], window[1])),
|
.map(|window| pct_change(window[0], window[1])),
|
||||||
);
|
);
|
||||||
@@ -116,12 +129,12 @@ pub fn compute_backtest_metrics(
|
|||||||
last_point.benchmark_close / benchmark_start
|
last_point.benchmark_close / benchmark_start
|
||||||
};
|
};
|
||||||
let benchmark_cumulative_return = benchmark_net_value - 1.0;
|
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 total_return = final_nav - 1.0;
|
||||||
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
|
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
|
||||||
total_return
|
total_return
|
||||||
} else {
|
} 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 excess_return = total_return - benchmark_cumulative_return;
|
||||||
let annual_return = annualize_return(total_return, trade_days);
|
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 excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
||||||
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
|
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
|
let benchmark_nav_series = equity_curve
|
||||||
.iter()
|
.iter()
|
||||||
.map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0))
|
.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 win_rate = ratio(winning_days, returns.len());
|
||||||
let excess_win_rate = ratio(excess_winning_days, excess_returns.len());
|
let excess_win_rate = ratio(excess_winning_days, excess_returns.len());
|
||||||
|
|
||||||
let monthly_portfolio_returns =
|
let monthly_portfolio_returns = group_monthly_returns_from_values(equity_curve, &equity_nav);
|
||||||
group_monthly_returns(equity_curve, 1.0, |point| point_nav(point, initial_cash));
|
|
||||||
let monthly_benchmark_returns =
|
let monthly_benchmark_returns =
|
||||||
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
|
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
|
||||||
let monthly_excess_returns = monthly_portfolio_returns
|
let monthly_excess_returns = monthly_portfolio_returns
|
||||||
@@ -262,10 +274,14 @@ pub fn compute_backtest_metrics(
|
|||||||
cash_balance: last_point.cash,
|
cash_balance: last_point.cash,
|
||||||
unit_nav: final_nav,
|
unit_nav: final_nav,
|
||||||
initial_cash,
|
initial_cash,
|
||||||
external_cash_flow_total: equity_curve
|
external_cash_flow_total: if explicit_unit_nav {
|
||||||
.iter()
|
equity_curve
|
||||||
.map(|point| point.external_cash_flow)
|
.iter()
|
||||||
.sum(),
|
.map(|point| point.external_cash_flow)
|
||||||
|
.sum()
|
||||||
|
} else {
|
||||||
|
external_flow_total_from_events(account_events)
|
||||||
|
},
|
||||||
excess_win_rate,
|
excess_win_rate,
|
||||||
monthly_sharpe,
|
monthly_sharpe,
|
||||||
monthly_volatility,
|
monthly_volatility,
|
||||||
@@ -399,6 +415,80 @@ fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
|||||||
(max_drawdown, max_duration)
|
(max_drawdown, max_duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn flow_neutral_nav_series(
|
||||||
|
equity_curve: &[DailyEquityPoint],
|
||||||
|
account_events: &[AccountEvent],
|
||||||
|
initial_cash: f64,
|
||||||
|
) -> Vec<f64> {
|
||||||
|
let mut external_flow_by_date = BTreeMap::<NaiveDate, f64>::new();
|
||||||
|
for event in account_events {
|
||||||
|
if !(event.note.starts_with("deposit_withdraw amount=")
|
||||||
|
|| event.note.starts_with("deposit_withdraw_settled amount="))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
*external_flow_by_date.entry(event.date).or_default() +=
|
||||||
|
event.cash_after - event.cash_before;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut units = initial_cash;
|
||||||
|
let mut previous_equity = initial_cash;
|
||||||
|
let mut navs = Vec::with_capacity(equity_curve.len());
|
||||||
|
for point in equity_curve {
|
||||||
|
let unit_nav_before_flow = safe_div(previous_equity, units, 1.0);
|
||||||
|
let external_flow = external_flow_by_date
|
||||||
|
.get(&point.date)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default();
|
||||||
|
if external_flow.abs() > f64::EPSILON && unit_nav_before_flow.is_finite() {
|
||||||
|
units += external_flow / unit_nav_before_flow;
|
||||||
|
}
|
||||||
|
let unit_nav = safe_div(point.total_equity, units, 0.0);
|
||||||
|
navs.push(unit_nav);
|
||||||
|
previous_equity = point.total_equity;
|
||||||
|
}
|
||||||
|
navs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn external_flow_total_from_events(account_events: &[AccountEvent]) -> f64 {
|
||||||
|
account_events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| {
|
||||||
|
event.note.starts_with("deposit_withdraw amount=")
|
||||||
|
|| event.note.starts_with("deposit_withdraw_settled amount=")
|
||||||
|
})
|
||||||
|
.map(|event| event.cash_after - event.cash_before)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_monthly_returns_from_values(
|
||||||
|
equity_curve: &[DailyEquityPoint],
|
||||||
|
values: &[f64],
|
||||||
|
) -> Vec<f64> {
|
||||||
|
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
|
||||||
|
let mut month_first = BTreeMap::<(i32, u32), f64>::new();
|
||||||
|
let mut previous_value = 1.0;
|
||||||
|
for (point, value) in equity_curve.iter().zip(values.iter().copied()) {
|
||||||
|
let key = (point.date.year(), point.date.month());
|
||||||
|
month_first.entry(key).or_insert(previous_value);
|
||||||
|
month_last.insert(key, value);
|
||||||
|
previous_value = value;
|
||||||
|
}
|
||||||
|
let mut keys = month_last.keys().copied().collect::<Vec<_>>();
|
||||||
|
keys.sort_unstable();
|
||||||
|
keys.into_iter()
|
||||||
|
.filter_map(|key| {
|
||||||
|
let first = month_first.get(&key).copied().unwrap_or_default();
|
||||||
|
let last = month_last.get(&key).copied().unwrap_or_default();
|
||||||
|
if first.abs() < f64::EPSILON {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((last / first) - 1.0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn group_monthly_returns<F>(
|
fn group_monthly_returns<F>(
|
||||||
equity_curve: &[DailyEquityPoint],
|
equity_curve: &[DailyEquityPoint],
|
||||||
initial_value: f64,
|
initial_value: f64,
|
||||||
@@ -516,7 +606,7 @@ mod tests {
|
|||||||
equity_point("2025-01-02", 100.0, 5797.089, 5957.717),
|
equity_point("2025-01-02", 100.0, 5797.089, 5957.717),
|
||||||
equity_point("2025-12-31", 120.0, 7595.285, 7597.299),
|
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;
|
let expected = 7595.285 / 5957.717 - 1.0;
|
||||||
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
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),
|
equity_point("2025-01-02", 100.0, 100.0, 100.0),
|
||||||
DailyEquityPoint {
|
DailyEquityPoint {
|
||||||
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||||
cash: 200.0,
|
cash: 220.0,
|
||||||
market_value: 0.0,
|
market_value: 0.0,
|
||||||
total_equity: 200.0,
|
total_equity: 220.0,
|
||||||
external_cash_flow: 100.0,
|
external_cash_flow: 100.0,
|
||||||
unit_nav: 1.0,
|
unit_nav: 1.1,
|
||||||
benchmark_close: 100.0,
|
benchmark_close: 100.0,
|
||||||
benchmark_prev_close: 100.0,
|
benchmark_prev_close: 100.0,
|
||||||
notes: String::new(),
|
notes: String::new(),
|
||||||
diagnostics: String::new(),
|
diagnostics: String::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let metrics = compute_backtest_metrics(&curve, &[], &[], 100.0);
|
let events = vec![AccountEvent {
|
||||||
assert!((metrics.total_return - 0.0).abs() < 1e-12);
|
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||||
assert!((metrics.unit_nav - 1.0).abs() < 1e-12);
|
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);
|
assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ pub struct StrategyRebalanceSpec {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyExecutionSpec {
|
pub struct StrategyExecutionSpec {
|
||||||
|
#[serde(default)]
|
||||||
|
pub frequency: Option<String>,
|
||||||
#[serde(default, alias = "matching_type")]
|
#[serde(default, alias = "matching_type")]
|
||||||
pub matching_type: Option<String>,
|
pub matching_type: Option<String>,
|
||||||
#[serde(default, alias = "slippage_model")]
|
#[serde(default, alias = "slippage_model")]
|
||||||
@@ -88,6 +90,8 @@ pub struct StrategyExecutionSpec {
|
|||||||
alias = "minCommission"
|
alias = "minCommission"
|
||||||
)]
|
)]
|
||||||
pub minimum_commission: Option<f64>,
|
pub minimum_commission: Option<f64>,
|
||||||
|
#[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")]
|
||||||
|
pub transfer_fee_rate: Option<f64>,
|
||||||
#[serde(default, alias = "stamp_tax_rate")]
|
#[serde(default, alias = "stamp_tax_rate")]
|
||||||
pub stamp_tax_rate: Option<f64>,
|
pub stamp_tax_rate: Option<f64>,
|
||||||
#[serde(default, alias = "stamp_tax_rate_before_change")]
|
#[serde(default, alias = "stamp_tax_rate_before_change")]
|
||||||
@@ -115,6 +119,8 @@ pub struct StrategyExecutionSpec {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyEngineConfig {
|
pub struct StrategyEngineConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub frequency: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub template_id: Option<String>,
|
pub template_id: Option<String>,
|
||||||
#[serde(default, alias = "benchmark_symbol")]
|
#[serde(default, alias = "benchmark_symbol")]
|
||||||
@@ -158,6 +164,8 @@ pub struct StrategyEngineConfig {
|
|||||||
alias = "minCommission"
|
alias = "minCommission"
|
||||||
)]
|
)]
|
||||||
pub minimum_commission: Option<f64>,
|
pub minimum_commission: Option<f64>,
|
||||||
|
#[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")]
|
||||||
|
pub transfer_fee_rate: Option<f64>,
|
||||||
#[serde(default, alias = "stamp_tax_rate")]
|
#[serde(default, alias = "stamp_tax_rate")]
|
||||||
pub stamp_tax_rate: Option<f64>,
|
pub stamp_tax_rate: Option<f64>,
|
||||||
#[serde(default, alias = "stamp_tax_rate_before_change")]
|
#[serde(default, alias = "stamp_tax_rate_before_change")]
|
||||||
@@ -278,6 +286,8 @@ pub struct StrategyRiskPolicySpec {
|
|||||||
alias = "minCommission"
|
alias = "minCommission"
|
||||||
)]
|
)]
|
||||||
pub minimum_commission: Option<f64>,
|
pub minimum_commission: Option<f64>,
|
||||||
|
#[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")]
|
||||||
|
pub transfer_fee_rate: Option<f64>,
|
||||||
#[serde(default, alias = "stamp_tax_rate")]
|
#[serde(default, alias = "stamp_tax_rate")]
|
||||||
pub stamp_tax_rate: Option<f64>,
|
pub stamp_tax_rate: Option<f64>,
|
||||||
#[serde(default, alias = "stamp_tax_rate_before_change")]
|
#[serde(default, alias = "stamp_tax_rate_before_change")]
|
||||||
@@ -339,6 +349,7 @@ const RISK_POLICY_VALUE_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
|||||||
"minimumCommission",
|
"minimumCommission",
|
||||||
&["minimum_commission", "min_commission", "minCommission"],
|
&["minimum_commission", "min_commission", "minCommission"],
|
||||||
),
|
),
|
||||||
|
("transferFeeRate", &["transfer_fee_rate"]),
|
||||||
("stampTaxRate", &["stamp_tax_rate"]),
|
("stampTaxRate", &["stamp_tax_rate"]),
|
||||||
(
|
(
|
||||||
"stampTaxRateBeforeChange",
|
"stampTaxRateBeforeChange",
|
||||||
@@ -902,6 +913,7 @@ fn apply_cost_overrides(
|
|||||||
cfg: &mut PlatformExprStrategyConfig,
|
cfg: &mut PlatformExprStrategyConfig,
|
||||||
commission_rate: Option<f64>,
|
commission_rate: Option<f64>,
|
||||||
minimum_commission: Option<f64>,
|
minimum_commission: Option<f64>,
|
||||||
|
transfer_fee_rate: Option<f64>,
|
||||||
stamp_tax_rate: Option<f64>,
|
stamp_tax_rate: Option<f64>,
|
||||||
stamp_tax_rate_before_change: Option<f64>,
|
stamp_tax_rate_before_change: Option<f64>,
|
||||||
stamp_tax_rate_after_change: Option<f64>,
|
stamp_tax_rate_after_change: Option<f64>,
|
||||||
@@ -915,6 +927,9 @@ fn apply_cost_overrides(
|
|||||||
cfg.minimum_commission = Some(value);
|
cfg.minimum_commission = Some(value);
|
||||||
cfg.risk_config.trading_constraints.minimum_commission = 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) {
|
if let Some(value) = valid_non_negative(stamp_tax_rate) {
|
||||||
cfg.stamp_tax_rate_before_change = Some(value);
|
cfg.stamp_tax_rate_before_change = Some(value);
|
||||||
cfg.stamp_tax_rate_after_change = Some(value);
|
cfg.stamp_tax_rate_after_change = Some(value);
|
||||||
@@ -1070,6 +1085,7 @@ fn apply_risk_policy_overrides(
|
|||||||
cfg,
|
cfg,
|
||||||
policy.commission_rate,
|
policy.commission_rate,
|
||||||
policy.minimum_commission,
|
policy.minimum_commission,
|
||||||
|
policy.transfer_fee_rate,
|
||||||
policy.stamp_tax_rate,
|
policy.stamp_tax_rate,
|
||||||
policy.stamp_tax_rate_before_change,
|
policy.stamp_tax_rate_before_change,
|
||||||
policy.stamp_tax_rate_after_change,
|
policy.stamp_tax_rate_after_change,
|
||||||
@@ -1491,6 +1507,7 @@ pub fn platform_expr_config_from_spec(
|
|||||||
&mut cfg,
|
&mut cfg,
|
||||||
engine.commission_rate,
|
engine.commission_rate,
|
||||||
engine.minimum_commission,
|
engine.minimum_commission,
|
||||||
|
engine.transfer_fee_rate,
|
||||||
engine.stamp_tax_rate,
|
engine.stamp_tax_rate,
|
||||||
engine.stamp_tax_rate_before_change,
|
engine.stamp_tax_rate_before_change,
|
||||||
engine.stamp_tax_rate_after_change,
|
engine.stamp_tax_rate_after_change,
|
||||||
@@ -1951,6 +1968,7 @@ pub fn platform_expr_config_from_spec(
|
|||||||
&mut cfg,
|
&mut cfg,
|
||||||
execution.commission_rate,
|
execution.commission_rate,
|
||||||
execution.minimum_commission,
|
execution.minimum_commission,
|
||||||
|
execution.transfer_fee_rate,
|
||||||
execution.stamp_tax_rate,
|
execution.stamp_tax_rate,
|
||||||
execution.stamp_tax_rate_before_change,
|
execution.stamp_tax_rate_before_change,
|
||||||
execution.stamp_tax_rate_after_change,
|
execution.stamp_tax_rate_after_change,
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ pub struct TradingConstraintConfig {
|
|||||||
pub liquidity_limit_enabled: bool,
|
pub liquidity_limit_enabled: bool,
|
||||||
pub commission_rate: f64,
|
pub commission_rate: f64,
|
||||||
pub minimum_commission: f64,
|
pub minimum_commission: f64,
|
||||||
|
pub transfer_fee_rate: f64,
|
||||||
pub stamp_tax_rate_before_change: f64,
|
pub stamp_tax_rate_before_change: f64,
|
||||||
pub stamp_tax_rate_after_change: f64,
|
pub stamp_tax_rate_after_change: f64,
|
||||||
pub stamp_tax_change_date: NaiveDate,
|
pub stamp_tax_change_date: NaiveDate,
|
||||||
@@ -95,6 +96,7 @@ impl Default for TradingConstraintConfig {
|
|||||||
liquidity_limit_enabled: true,
|
liquidity_limit_enabled: true,
|
||||||
commission_rate: 0.0003,
|
commission_rate: 0.0003,
|
||||||
minimum_commission: 5.0,
|
minimum_commission: 5.0,
|
||||||
|
transfer_fee_rate: 0.0,
|
||||||
stamp_tax_rate_before_change: 0.001,
|
stamp_tax_rate_before_change: 0.001,
|
||||||
stamp_tax_rate_after_change: 0.0005,
|
stamp_tax_rate_after_change: 0.0005,
|
||||||
stamp_tax_change_date: NaiveDate::from_ymd_opt(2023, 8, 28)
|
stamp_tax_change_date: NaiveDate::from_ymd_opt(2023, 8, 28)
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ impl StrategyContext<'_> {
|
|||||||
let gross_amount = fills.iter().map(|fill| fill.gross_amount).sum::<f64>();
|
let gross_amount = fills.iter().map(|fill| fill.gross_amount).sum::<f64>();
|
||||||
let transaction_cost = fills
|
let transaction_cost = fills
|
||||||
.iter()
|
.iter()
|
||||||
.map(|fill| fill.commission + fill.stamp_tax)
|
.map(|fill| fill.commission + fill.stamp_tax + fill.transfer_fee)
|
||||||
.sum::<f64>();
|
.sum::<f64>();
|
||||||
let avg_price = if filled_quantity == 0 {
|
let avg_price = if filled_quantity == 0 {
|
||||||
0.0
|
0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user