You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
207 lines
5.3 KiB
207 lines
5.3 KiB
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)) |
|
}
|
|
|