package backtest import ( "math" "sig-pub/pkg/trade" "sig-pub/pkg/types/ta" "sig-pub/pkg/utils/collect" "sort" ) // SharpeRatio computes an annualized Sharpe ratio based on closed trades. // rfAnnual is the annual risk-free rate expressed as a decimal (e.g. 0.01 for 1%). // Method: // - For each closed trade, we compute a period return = trade.Pnl / initialCash. // - Period lengths are derived from successive trade close timestamps (ms). // - Excess returns = periodReturn - rfAnnual * periodYears. // - Sharpe = mean(excess) / stddev(excess) * sqrt(periodsPerYear) // This provides a reasonable approximation when equity snapshots are not available. func (a *TradingPlanBacktester) sharpeRatio(rfAnnual float64) float64 { if a.account.initialCash <= 0 { return 0 } n := len(a.account.closeTrades) if n < 2 { return 0 } trades := make([]*trade.TradeOrder, 0, n) for _, tid := range a.account.closeTrades { if order, ok := a.account.orders[tid]; ok { trades = append(trades, order) } } collect.SortAsc(trades, func(trd *trade.TradeOrder) int64 { return trd.TradeId }) // 年化每笔收益方法: // 对每笔平仓交易,先计算该笔收益相对于初始资金的返回率(profit / initialCash), // 再根据该笔持仓时长(以毫秒为单位,使用 t.Ctime - t.EntryTime)将收益年化: // annualizedReturn = return * (secsYear / dtSec) // 然后计算超额收益 = annualizedReturn - rfAnnual // 最后 Sharpe = mean(excess) / stddev(excess) const secsYear = 365.0 * 24.0 * 3600.0 excess := make([]float64, 0, n) for _, t := range trades { ret := t.Profit / a.account.initialCash // holding time in seconds dtSec := float64(t.Ctime-t.EntryTime) / 1000.0 if dtSec <= 0 { dtSec = 1.0 } annualized := ret * (secsYear / dtSec) excess = append(excess, annualized-rfAnnual) } if len(excess) <= 1 { return 0 } meanEx := ta.Avg(excess) sd := stddev(excess) if sd == 0 { return 0 } return meanEx / sd } // sharpeFromEquitySnapshots computes Sharpe based on equity time series snapshots. // Method: // - compute simple returns between consecutive snapshots: r_t = eq_t / eq_{t-1} - 1 // - compute average snapshot interval and derive periodsPerYear = secsYear / avgDt // - rf per period = rfAnnual / periodsPerYear // - excess = r_t - rf_per_period // - Sharpe = mean(excess)/std(excess) * sqrt(periodsPerYear) func sharpeFromEquitySnapshots(snapshots []*EquitySnapshot, rfAnnual float64) float64 { if len(snapshots) < 2 { return 0 } // ensure sorted by timestamp sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].Ts < snapshots[j].Ts }) const secsYear = 365.0 * 24.0 * 3600.0 var returns []float64 var dts []float64 for i := 1; i < len(snapshots); i++ { prev := snapshots[i-1].Equity cur := snapshots[i].Equity if prev <= 0 { continue } returns = append(returns, cur/prev-1) dt := float64(snapshots[i].Ts-snapshots[i-1].Ts) / 1000.0 if dt <= 0 { dt = 1.0 } dts = append(dts, dt) } if len(returns) <= 1 { return 0 } sum := 0.0 for _, d := range dts { sum += d } avgDt := sum / float64(len(dts)) periodsPerYear := secsYear / avgDt rfPeriod := rfAnnual / periodsPerYear excess := make([]float64, len(returns)) for i := range returns { excess[i] = returns[i] - rfPeriod } meanEx := ta.Avg(excess) sd := stddev(excess) if sd == 0 { return 0 } return meanEx / sd * math.Sqrt(periodsPerYear) } // sortinoFromEquitySnapshots computes Sortino Ratio based on equity time series snapshots. func sortinoFromEquitySnapshots(snapshots []*EquitySnapshot, rfAnnual float64) float64 { if len(snapshots) < 2 { return 0 } // ensure sorted by timestamp sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].Ts < snapshots[j].Ts }) const secsYear = 365.0 * 24.0 * 3600.0 var returns []float64 var dts []float64 for i := 1; i < len(snapshots); i++ { prev := snapshots[i-1].Equity cur := snapshots[i].Equity if prev <= 0 { continue } returns = append(returns, cur/prev-1) dt := float64(snapshots[i].Ts-snapshots[i-1].Ts) / 1000.0 if dt <= 0 { dt = 1.0 } dts = append(dts, dt) } if len(returns) <= 1 { return 0 } sum := 0.0 for _, d := range dts { sum += d } avgDt := sum / float64(len(dts)) periodsPerYear := secsYear / avgDt rfPeriod := rfAnnual / periodsPerYear excess := make([]float64, len(returns)) downsideSum := 0.0 for i := range returns { excess[i] = returns[i] - rfPeriod if excess[i] < 0 { downsideSum += excess[i] * excess[i] } } meanEx := ta.Avg(excess) downsideDev := math.Sqrt(downsideSum / float64(len(returns))) if downsideDev == 0 { return 0 } return meanEx / downsideDev * math.Sqrt(periodsPerYear) } func calmarRatio(annualReturn float64, maxDrawdown float64) float64 { if maxDrawdown == 0 { return 0 } return annualReturn / maxDrawdown } func profitFactor(orders []*trade.TradeOrder) float64 { grossProfit := 0.0 grossLoss := 0.0 for _, o := range orders { if o.Profit > 0 { grossProfit += o.Profit } else { grossLoss += math.Abs(o.Profit) } } if grossLoss == 0 { if grossProfit == 0 { return 0 } return 999.0 // Infinite } return grossProfit / grossLoss } func stddev(x []float64) float64 { if len(x) <= 1 { return 0 } m := ta.Avg(x) s := 0.0 for _, v := range x { d := v - m s += d * d } // population or sample? use sample (n-1) return math.Sqrt(s / float64(len(x)-1)) }