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.
 
 

286 lines
7.5 KiB

package backtest
import (
"fmt"
"math"
"sig-pub/pkg/trade"
"sig-pub/pkg/types"
"sig-pub/pkg/types/decimals"
"sig-pub/pkg/utils/conver"
"sig-pub/pkg/utils/lang"
"sort"
"time"
)
type BacktestAccount struct {
trade.ITradeAccount
cash float64
initialCash float64
positions map[int64]*trade.Position
trades map[int64]*Trade
closeTrades map[int64]*Trade
simulator *TradeSimulator
profit float64
winningTrades int
losingTrades int
fee float64
maxDrawdown [4]float64 // 最大回撤
}
func NewBacktestAccount(cash float64, simulator *TradeSimulator) *BacktestAccount {
return &BacktestAccount{
// keep initial cash for return calculations
initialCash: cash,
cash: cash,
positions: make(map[int64]*trade.Position),
trades: make(map[int64]*Trade),
closeTrades: make(map[int64]*Trade),
simulator: simulator,
maxDrawdown: [4]float64{cash, cash, cash, cash},
}
}
// initialCash stores the starting capital for return calculations
// (placed here to avoid changing exported API)
func (a *BacktestAccount) InitialCash() float64 { return a.initialCash }
// 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 *BacktestAccount) SharpeRatio(rfAnnual float64) float64 {
if a.initialCash <= 0 {
return 0
}
n := len(a.closeTrades)
if n < 2 {
return 0
}
trades := make([]*Trade, 0, n)
for _, t := range a.closeTrades {
trades = append(trades, t)
}
sort.Slice(trades, func(i, j int) bool { return trades[i].CloseTime < trades[j].CloseTime })
// returns per closed trade (relative to initial capital)
returns := make([]float64, 0, n)
// periods in seconds between closes; length will be n-1 initially
periodsSec := make([]float64, 0, n-1)
for i, t := range trades {
returns = append(returns, t.Pnl/a.initialCash)
if i > 0 {
// CloseTs is in milliseconds in this codebase
dtSec := float64(t.CloseTime-trades[i-1].CloseTime) / 1000.0
if dtSec <= 0 {
dtSec = 1.0
}
periodsSec = append(periodsSec, dtSec)
}
}
if len(periodsSec) == 0 {
return 0
}
// average period (seconds) used to approximate period length for the first return
sumDt := 0.0
for _, d := range periodsSec {
sumDt += d
}
avgDt := sumDt / float64(len(periodsSec))
// Build final periods slice aligned with returns length
periods := make([]float64, 0, n)
periods = append(periods, avgDt)
periods = append(periods, periodsSec...)
const secsYear = 365.0 * 24.0 * 3600.0
excess := make([]float64, len(returns))
for i := range returns {
years := periods[i] / secsYear
excess[i] = returns[i] - rfAnnual*years
}
meanEx := mean(excess)
sd := stddev(excess)
if sd == 0 {
return 0
}
// approximate number of periods per year
periodsPerYear := secsYear / avgDt
return meanEx / sd * math.Sqrt(periodsPerYear)
}
func mean(x []float64) float64 {
if len(x) == 0 {
return 0
}
s := 0.0
for _, v := range x {
s += v
}
return s / float64(len(x))
}
func stddev(x []float64) float64 {
if len(x) <= 1 {
return 0
}
m := mean(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))
}
// CurrentEquity 根据当前价格对仓位进行 mark-to-market,返回账户净值
func (a *BacktestAccount) CurrentEquity(price float64) float64 {
equity := a.cash
for _, p := range a.positions {
switch p.Side {
case types.SideLong:
// equity += (price - p.EntryPx) * p.Qty
equity += price * p.Qty
case types.SideShort:
// equity += (p.EntryPx - price) * p.Qty
equity += (p.EntryPx - price + p.EntryPx) * p.Qty
}
}
return equity
}
// CurrentExposure 返回当前仓位的名义总敞口(绝对值)
func (a *BacktestAccount) CurrentExposure() float64 {
var sum float64
for _, p := range a.positions {
sum += (p.Qty * p.EntryPx)
}
return sum
}
// CanOpen 判断在给定价格下是否可以开仓(基于 MaxPosPct 和 MaxExposurePct)
// func (a *BacktestAccount) CanOpen(side types.Side, qty, price float64) bool {
// if qty <= 0 || price <= 0 {
// return false
// }
// equity := a.CurrentEquity(price)
// if equity <= 0 {
// return false
// }
// notional := math.Abs(qty * price)
// // 单仓位限制
// if a.MaxPosPct > 0 {
// if notional > a.MaxPosPct*equity {
// return false
// }
// }
// // 总敞口限制
// if a.MaxExposurePct > 0 {
// if a.CurrentExposure(price)+notional > a.MaxExposurePct*equity {
// return false
// }
// }
// return true
// }
// 获取未平仓交易单
func (a *BacktestAccount) OpenPositions() map[int64]*trade.Position {
return a.positions
}
// 获取未平仓交易单数
func (a *BacktestAccount) CountOpenPositions() int {
return len(a.positions)
}
// 订单下单
func (a *BacktestAccount) TradeOrder(ta trade.TradeArg) (ok bool, cause trade.Cause, err error) {
order, ok := a.simulator.ExecuteMarket(ta.Side, ta.Qty, ta.Price, ta.Time)
if !ok {
return
}
// apply cash/position, short side also need earnest money
cost := order.Price*order.Qty + order.Fee
if cost > a.cash {
ok = false
return
}
a.cash -= cost
// open/add position
pos := &trade.Position{TradeId: order.Id, Side: order.Side, Qty: order.Qty, EntryPx: order.Price, EntryTs: order.Time, PeakPx: order.Price, Fee: order.Fee}
a.positions[order.Id] = pos
a.trades[order.Id] = order
ok = true
return
}
// 将仓位进行平仓
func (a *BacktestAccount) ClosePosition(pos *trade.Position, kline types.Kline, cause trade.Cause) (err error) {
closeSide := lang.Ternary(pos.Side == types.SideLong, types.SideShort, types.SideLong)
closePrice := decimals.MustToFloat64(kline.Close)
t, ok := a.simulator.ExecuteMarket(closeSide, pos.Qty, closePrice, kline.Ts)
if !ok {
err = fmt.Errorf("close position error: %#v", pos)
return
}
t.CloseCause = cause
// apply cash change
// calc profit
var receive, profit float64
if pos.Side == types.SideLong {
profit = t.Qty*(t.Price-pos.EntryPx) - t.Fee - pos.Fee
receive = t.Price*pos.Qty - t.Fee
} else {
profit = (pos.EntryPx - t.Price) * t.Qty
receive = pos.EntryPx*pos.Qty + profit - t.Fee
profit = profit - t.Fee - pos.Fee
}
a.cash += receive
a.profit += profit
if profit > 0 {
a.winningTrades++
} else {
a.losingTrades++
}
a.fee += t.Fee
// remove position
delete(a.positions, pos.TradeId)
a.closeTrades[t.Id] = t
if trade, ok := a.trades[pos.TradeId]; ok {
trade.ClosePrice = closePrice
trade.CloseFee = t.Fee
trade.CloseTime = kline.Ts
trade.CloseCause = cause
trade.Pnl = profit
trade.HoldTime = conver.TimeDurationFormat(time.Duration(trade.CloseTime-trade.Time)*time.Millisecond, ".")
trade.PeakPx = pos.PeakPx
trade.Cash = a.CurrentEquity(closePrice)
}
// 记录最大回撤 [high, low, high, low]
currentEquity := a.CurrentEquity(closePrice)
if currentEquity < a.maxDrawdown[1] {
a.maxDrawdown[1] = currentEquity
}
if currentEquity > a.maxDrawdown[0] {
if a.maxDrawdown[0]-a.maxDrawdown[1] > a.maxDrawdown[2]-a.maxDrawdown[3] {
a.maxDrawdown[2], a.maxDrawdown[3] = a.maxDrawdown[0], a.maxDrawdown[1]
}
a.maxDrawdown[0] = currentEquity
a.maxDrawdown[1] = currentEquity
}
return
}