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 } // 记录最大回撤 [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 }