first version
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"overnight-trading-bot/internal/domain"
|
||||
"overnight-trading-bot/internal/features"
|
||||
"overnight-trading-bot/internal/money"
|
||||
"overnight-trading-bot/internal/risk"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
EntrySlippageBps decimal.Decimal
|
||||
ExitSlippageBps decimal.Decimal
|
||||
CommissionRoundtripBps decimal.Decimal
|
||||
InitialEquity decimal.Decimal
|
||||
OutputDir string
|
||||
RollingShort int
|
||||
RollingLong int
|
||||
EWMALambda float64
|
||||
MinTStat60 decimal.Decimal
|
||||
MinWinRate60 decimal.Decimal
|
||||
MinNetEdgeBps decimal.Decimal
|
||||
MinADVRUB decimal.Decimal
|
||||
MaxSpreadBps decimal.Decimal
|
||||
MaxTickBps decimal.Decimal
|
||||
RequireZeroCommission bool
|
||||
MaxPositions int
|
||||
MaxPositionPct decimal.Decimal
|
||||
MaxTotalExposurePct decimal.Decimal
|
||||
MaxParticipationRate decimal.Decimal
|
||||
CashUsageBuffer decimal.Decimal
|
||||
RiskBudgetPct decimal.Decimal
|
||||
MinOrderNotionalRUB decimal.Decimal
|
||||
AssumedSpreadBps decimal.Decimal
|
||||
AssumedTickBps decimal.Decimal
|
||||
Lot int64
|
||||
UseMinuteModel bool
|
||||
}
|
||||
|
||||
type Trade struct {
|
||||
InstrumentUID string `json:"instrument_uid"`
|
||||
EntryDate string `json:"entry_date"`
|
||||
ExitDate string `json:"exit_date"`
|
||||
BuyPrice decimal.Decimal `json:"buy_price"`
|
||||
SellPrice decimal.Decimal `json:"sell_price"`
|
||||
Return decimal.Decimal `json:"return"`
|
||||
Lots int64 `json:"lots"`
|
||||
Notional decimal.Decimal `json:"notional"`
|
||||
NetPnL decimal.Decimal `json:"net_pnl"`
|
||||
SpreadBps decimal.Decimal `json:"spread_bps"`
|
||||
SlippageBps decimal.Decimal `json:"slippage_bps"`
|
||||
OvernightGap decimal.Decimal `json:"overnight_gap"`
|
||||
CapacityRUB decimal.Decimal `json:"capacity_rub"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Metrics Metrics `json:"metrics"`
|
||||
Trades []Trade `json:"trades"`
|
||||
Equity []Point `json:"equity"`
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
Date string `json:"date"`
|
||||
Equity decimal.Decimal `json:"equity"`
|
||||
Return decimal.Decimal `json:"return"`
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New(cfg Config) Engine {
|
||||
cfg = cfg.withDefaults()
|
||||
return Engine{cfg: cfg}
|
||||
}
|
||||
|
||||
func (cfg Config) withDefaults() Config {
|
||||
if cfg.InitialEquity.IsZero() {
|
||||
cfg.InitialEquity = decimal.NewFromInt(100_000)
|
||||
}
|
||||
if cfg.RollingShort == 0 {
|
||||
cfg.RollingShort = 60
|
||||
}
|
||||
if cfg.RollingLong == 0 {
|
||||
cfg.RollingLong = 252
|
||||
}
|
||||
if cfg.EWMALambda == 0 {
|
||||
cfg.EWMALambda = 0.08
|
||||
}
|
||||
if cfg.MinTStat60.IsZero() {
|
||||
cfg.MinTStat60 = decimal.NewFromFloat(1.25)
|
||||
}
|
||||
if cfg.MinWinRate60.IsZero() {
|
||||
cfg.MinWinRate60 = decimal.NewFromFloat(0.55)
|
||||
}
|
||||
if cfg.MinNetEdgeBps.IsZero() {
|
||||
cfg.MinNetEdgeBps = decimal.NewFromInt(10)
|
||||
}
|
||||
if cfg.MinADVRUB.IsZero() {
|
||||
cfg.MinADVRUB = decimal.NewFromInt(5_000_000)
|
||||
}
|
||||
if cfg.MaxSpreadBps.IsZero() {
|
||||
cfg.MaxSpreadBps = decimal.NewFromInt(20)
|
||||
}
|
||||
if cfg.MaxTickBps.IsZero() {
|
||||
cfg.MaxTickBps = decimal.NewFromInt(10)
|
||||
}
|
||||
if !cfg.RequireZeroCommission && cfg.CommissionRoundtripBps.IsZero() {
|
||||
cfg.RequireZeroCommission = true
|
||||
}
|
||||
if cfg.MaxPositions == 0 {
|
||||
cfg.MaxPositions = 5
|
||||
}
|
||||
if cfg.MaxPositionPct.IsZero() {
|
||||
cfg.MaxPositionPct = decimal.NewFromFloat(0.10)
|
||||
}
|
||||
if cfg.MaxTotalExposurePct.IsZero() {
|
||||
cfg.MaxTotalExposurePct = decimal.NewFromFloat(0.50)
|
||||
}
|
||||
if cfg.MaxParticipationRate.IsZero() {
|
||||
cfg.MaxParticipationRate = decimal.NewFromFloat(0.01)
|
||||
}
|
||||
if cfg.CashUsageBuffer.IsZero() {
|
||||
cfg.CashUsageBuffer = decimal.NewFromFloat(0.95)
|
||||
}
|
||||
if cfg.RiskBudgetPct.IsZero() {
|
||||
cfg.RiskBudgetPct = decimal.NewFromFloat(0.005)
|
||||
}
|
||||
if cfg.MinOrderNotionalRUB.IsZero() {
|
||||
cfg.MinOrderNotionalRUB = decimal.NewFromInt(1000)
|
||||
}
|
||||
if cfg.Lot == 0 {
|
||||
cfg.Lot = 1
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (e Engine) Run(candlesByInstrument map[string][]domain.Candle) (Result, error) {
|
||||
return e.RunWithMinuteCandles(candlesByInstrument, nil)
|
||||
}
|
||||
|
||||
func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Candle, minuteCandlesByInstrument map[string][]domain.Candle) (Result, error) {
|
||||
prepared := prepareCandles(candlesByInstrument)
|
||||
preparedMinutes := prepareCandles(minuteCandlesByInstrument)
|
||||
candidatesByExitDate := make(map[string][]candidate)
|
||||
for instrumentUID, candles := range prepared {
|
||||
for i := 1; i < len(candles); i++ {
|
||||
candidate, ok, err := e.evaluateCandidate(instrumentUID, candles, i)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if ok {
|
||||
candidatesByExitDate[candidate.exit.TradeDate.Format("2006-01-02")] = append(candidatesByExitDate[candidate.exit.TradeDate.Format("2006-01-02")], candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
dates := make([]string, 0, len(candidatesByExitDate))
|
||||
for date := range candidatesByExitDate {
|
||||
dates = append(dates, date)
|
||||
}
|
||||
sort.Strings(dates)
|
||||
|
||||
equity := e.cfg.InitialEquity
|
||||
cash := e.cfg.InitialEquity
|
||||
var trades []Trade
|
||||
points := []Point{{Date: "START", Equity: equity}}
|
||||
sizer := risk.NewSizer(risk.SizingConfig{
|
||||
MaxPositionPct: e.cfg.MaxPositionPct,
|
||||
MaxTotalExposurePct: e.cfg.MaxTotalExposurePct,
|
||||
MaxParticipationRate: e.cfg.MaxParticipationRate,
|
||||
CashUsageBuffer: e.cfg.CashUsageBuffer,
|
||||
RiskBudgetPerInstrumentPct: e.cfg.RiskBudgetPct,
|
||||
MinOrderNotionalRUB: e.cfg.MinOrderNotionalRUB,
|
||||
})
|
||||
for _, date := range dates {
|
||||
dayCandidates := candidatesByExitDate[date]
|
||||
sort.Slice(dayCandidates, func(i, j int) bool {
|
||||
if dayCandidates[i].netEdge.Equal(dayCandidates[j].netEdge) {
|
||||
return dayCandidates[i].instrumentUID < dayCandidates[j].instrumentUID
|
||||
}
|
||||
return dayCandidates[i].netEdge.GreaterThan(dayCandidates[j].netEdge)
|
||||
})
|
||||
if len(dayCandidates) > e.cfg.MaxPositions {
|
||||
dayCandidates = dayCandidates[:e.cfg.MaxPositions]
|
||||
}
|
||||
dayStartEquity := equity
|
||||
dayPnL := decimal.Zero
|
||||
for _, c := range dayCandidates {
|
||||
sized := sizer.Size(risk.SizingInput{
|
||||
Portfolio: domain.Portfolio{Equity: equity, Cash: cash},
|
||||
SelectedInstruments: len(dayCandidates),
|
||||
LimitPrice: c.buy,
|
||||
Lot: e.cfg.Lot,
|
||||
EntryIntervalVolume: c.adv,
|
||||
ExitIntervalVolume: c.adv,
|
||||
Q05OvernightAbs: c.q05Abs,
|
||||
})
|
||||
if sized.Lots <= 0 {
|
||||
continue
|
||||
}
|
||||
lots := sized.Lots
|
||||
capacity := c.capacity
|
||||
if e.cfg.UseMinuteModel {
|
||||
executedLots, minuteCapacity, ok := e.minuteExecution(c, preparedMinutes[c.instrumentUID], sized.Lots)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lots = executedLots
|
||||
capacity = minuteCapacity
|
||||
}
|
||||
notional := c.buy.Mul(decimal.NewFromInt(lots)).Mul(decimal.NewFromInt(e.cfg.Lot))
|
||||
ret := c.sell.Div(c.buy).Sub(decimal.NewFromInt(1)).Sub(money.FromBps(e.cfg.CommissionRoundtripBps))
|
||||
pnl := notional.Mul(ret)
|
||||
dayPnL = dayPnL.Add(pnl)
|
||||
cash = cash.Sub(notional)
|
||||
trades = append(trades, Trade{
|
||||
InstrumentUID: c.instrumentUID,
|
||||
EntryDate: c.entry.TradeDate.Format("2006-01-02"),
|
||||
ExitDate: c.exit.TradeDate.Format("2006-01-02"),
|
||||
BuyPrice: c.buy,
|
||||
SellPrice: c.sell,
|
||||
Return: ret,
|
||||
Lots: lots,
|
||||
Notional: notional,
|
||||
NetPnL: pnl,
|
||||
SpreadBps: e.cfg.AssumedSpreadBps,
|
||||
SlippageBps: e.cfg.EntrySlippageBps.Add(e.cfg.ExitSlippageBps),
|
||||
OvernightGap: c.overnightGap,
|
||||
CapacityRUB: capacity,
|
||||
})
|
||||
}
|
||||
if !dayPnL.IsZero() {
|
||||
equity = equity.Add(dayPnL)
|
||||
cash = equity
|
||||
points = append(points, Point{
|
||||
Date: date,
|
||||
Equity: equity,
|
||||
Return: dayPnL.Div(dayStartEquity),
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
if trades[i].ExitDate == trades[j].ExitDate {
|
||||
return trades[i].InstrumentUID < trades[j].InstrumentUID
|
||||
}
|
||||
return trades[i].ExitDate < trades[j].ExitDate
|
||||
})
|
||||
return Result{
|
||||
Metrics: ComputeMetrics(points, trades),
|
||||
Trades: trades,
|
||||
Equity: points,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e Engine) minuteExecution(c candidate, minutes []domain.Candle, requestedLots int64) (int64, decimal.Decimal, bool) {
|
||||
if requestedLots <= 0 || len(minutes) == 0 {
|
||||
return 0, decimal.Zero, false
|
||||
}
|
||||
entryLots, entryCapacity := e.fillableMinuteLots(minutes, c.entry.TradeDate, c.buy, domain.SideBuy)
|
||||
exitLots, exitCapacity := e.fillableMinuteLots(minutes, c.exit.TradeDate, c.sell, domain.SideSell)
|
||||
lots := min(requestedLots, entryLots)
|
||||
lots = min(lots, exitLots)
|
||||
if lots <= 0 {
|
||||
return 0, decimal.Zero, false
|
||||
}
|
||||
return lots, money.Min(entryCapacity, exitCapacity), true
|
||||
}
|
||||
|
||||
func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limitPrice decimal.Decimal, side domain.Side) (int64, decimal.Decimal) {
|
||||
if !limitPrice.IsPositive() || e.cfg.Lot <= 0 {
|
||||
return 0, decimal.Zero
|
||||
}
|
||||
lotNotional := limitPrice.Mul(decimal.NewFromInt(e.cfg.Lot))
|
||||
if !lotNotional.IsPositive() {
|
||||
return 0, decimal.Zero
|
||||
}
|
||||
capacity := decimal.Zero
|
||||
for _, candle := range minutes {
|
||||
if !sameDate(candle.TradeDate, date) {
|
||||
continue
|
||||
}
|
||||
reachable := side == domain.SideBuy && candle.Low.LessThanOrEqual(limitPrice)
|
||||
reachable = reachable || side == domain.SideSell && candle.High.GreaterThanOrEqual(limitPrice)
|
||||
if !reachable {
|
||||
continue
|
||||
}
|
||||
minuteCapacity := candle.VolumeLots.Mul(lotNotional).Mul(e.cfg.MaxParticipationRate)
|
||||
capacity = capacity.Add(minuteCapacity)
|
||||
}
|
||||
return capacity.Div(lotNotional).Floor().IntPart(), capacity
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
instrumentUID string
|
||||
entry domain.Candle
|
||||
exit domain.Candle
|
||||
buy decimal.Decimal
|
||||
sell decimal.Decimal
|
||||
netEdge decimal.Decimal
|
||||
adv decimal.Decimal
|
||||
q05Abs decimal.Decimal
|
||||
overnightGap decimal.Decimal
|
||||
capacity decimal.Decimal
|
||||
}
|
||||
|
||||
func (e Engine) evaluateCandidate(instrumentUID string, candles []domain.Candle, exitIndex int) (candidate, bool, error) {
|
||||
if exitIndex < e.cfg.RollingShort || exitIndex <= 0 {
|
||||
return candidate{}, false, nil
|
||||
}
|
||||
history := candles[:exitIndex]
|
||||
returns := make([]float64, 0, len(history)-1)
|
||||
for j := 1; j < len(history); j++ {
|
||||
r, err := features.OvernightReturn(history[j].Open, history[j-1].Close)
|
||||
if err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
rf, _ := r.Float64()
|
||||
returns = append(returns, rf)
|
||||
}
|
||||
short := features.Rolling(returns, e.cfg.RollingShort, e.cfg.EWMALambda)
|
||||
long := features.Rolling(returns, min(e.cfg.RollingLong, len(returns)), e.cfg.EWMALambda)
|
||||
if !short.Available || !long.Available || short.StdDev == 0 {
|
||||
return candidate{}, false, nil
|
||||
}
|
||||
rawEdge := decimal.NewFromFloat(short.Mean).Mul(decimal.NewFromInt(10_000))
|
||||
cost := e.cfg.AssumedSpreadBps.
|
||||
Add(e.cfg.EntrySlippageBps).
|
||||
Add(e.cfg.ExitSlippageBps).
|
||||
Add(e.cfg.CommissionRoundtripBps)
|
||||
netEdge := rawEdge.Sub(cost)
|
||||
adv := features.ADV(history, e.cfg.Lot, 20)
|
||||
switch {
|
||||
case e.cfg.RequireZeroCommission && e.cfg.CommissionRoundtripBps.IsPositive():
|
||||
return candidate{}, false, nil
|
||||
case !decimal.NewFromFloat(short.Mean).IsPositive() || !decimal.NewFromFloat(long.Mean).IsPositive():
|
||||
return candidate{}, false, nil
|
||||
case decimal.NewFromFloat(short.TStat).LessThan(e.cfg.MinTStat60):
|
||||
return candidate{}, false, nil
|
||||
case decimal.NewFromFloat(short.WinRate).LessThan(e.cfg.MinWinRate60):
|
||||
return candidate{}, false, nil
|
||||
case netEdge.LessThan(e.cfg.MinNetEdgeBps):
|
||||
return candidate{}, false, nil
|
||||
case e.cfg.AssumedSpreadBps.GreaterThan(e.cfg.MaxSpreadBps):
|
||||
return candidate{}, false, nil
|
||||
case e.cfg.AssumedTickBps.GreaterThan(e.cfg.MaxTickBps):
|
||||
return candidate{}, false, nil
|
||||
case adv.LessThan(e.cfg.MinADVRUB):
|
||||
return candidate{}, false, nil
|
||||
}
|
||||
entry := candles[exitIndex-1]
|
||||
exit := candles[exitIndex]
|
||||
buy := entry.Close.Mul(decimal.NewFromInt(1).Add(money.FromBps(e.cfg.EntrySlippageBps)))
|
||||
sell := exit.Open.Mul(decimal.NewFromInt(1).Sub(money.FromBps(e.cfg.ExitSlippageBps)))
|
||||
gap, err := features.OvernightReturn(exit.Open, entry.Close)
|
||||
if err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
q05Abs := decimal.NewFromFloat(features.Quantile(returns, 0.05))
|
||||
if q05Abs.IsNegative() {
|
||||
q05Abs = q05Abs.Neg()
|
||||
}
|
||||
return candidate{
|
||||
instrumentUID: instrumentUID,
|
||||
entry: entry,
|
||||
exit: exit,
|
||||
buy: buy,
|
||||
sell: sell,
|
||||
netEdge: netEdge,
|
||||
adv: adv,
|
||||
q05Abs: q05Abs,
|
||||
overnightGap: gap,
|
||||
capacity: adv.Mul(e.cfg.MaxParticipationRate),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func prepareCandles(candlesByInstrument map[string][]domain.Candle) map[string][]domain.Candle {
|
||||
prepared := make(map[string][]domain.Candle, len(candlesByInstrument))
|
||||
for instrumentUID, candles := range candlesByInstrument {
|
||||
cp := append([]domain.Candle(nil), candles...)
|
||||
sort.Slice(cp, func(i, j int) bool {
|
||||
return cp[i].TradeDate.Before(cp[j].TradeDate)
|
||||
})
|
||||
prepared[instrumentUID] = cp
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
|
||||
func (r Result) Write(outputDir string) error {
|
||||
if outputDir == "" {
|
||||
outputDir = "./backtest_out"
|
||||
}
|
||||
if err := os.MkdirAll(outputDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
summary, err := json.MarshalIndent(r.Metrics, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outputDir, "summary.json"), summary, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeTrades(filepath.Join(outputDir, "trades.csv"), r.Trades); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeEquity(filepath.Join(outputDir, "equity.csv"), r.Equity)
|
||||
}
|
||||
|
||||
func LoadCandlesCSV(reader io.Reader) (map[string][]domain.Candle, error) {
|
||||
r := csv.NewReader(reader)
|
||||
r.FieldsPerRecord = -1
|
||||
records, err := r.ReadAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string][]domain.Candle)
|
||||
for i, record := range records {
|
||||
if i == 0 && len(record) > 0 && record[0] == "instrument_uid" {
|
||||
continue
|
||||
}
|
||||
if len(record) < 7 {
|
||||
return nil, fmt.Errorf("line %d: expected 7 fields", i+1)
|
||||
}
|
||||
date, err := parseCandleTime(record[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
open, err := decimal.NewFromString(record[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
high, err := decimal.NewFromString(record[3])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
low, err := decimal.NewFromString(record[4])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closePrice, err := decimal.NewFromString(record[5])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
volume, err := decimal.NewFromString(record[6])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candle := domain.Candle{
|
||||
InstrumentUID: record[0],
|
||||
TradeDate: date,
|
||||
Open: open,
|
||||
High: high,
|
||||
Low: low,
|
||||
Close: closePrice,
|
||||
VolumeLots: volume,
|
||||
Source: "csv",
|
||||
LoadedAt: time.Now().UTC(),
|
||||
}
|
||||
out[candle.InstrumentUID] = append(out[candle.InstrumentUID], candle)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseCandleTime(raw string) (time.Time, error) {
|
||||
layouts := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
var lastErr error
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, raw)
|
||||
if err == nil {
|
||||
return parsed.UTC(), nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return time.Time{}, lastErr
|
||||
}
|
||||
|
||||
func sameDate(a, b time.Time) bool {
|
||||
return dateOnly(a).Equal(dateOnly(b))
|
||||
}
|
||||
|
||||
func dateOnly(t time.Time) time.Time {
|
||||
y, m, d := t.UTC().Date()
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func writeTrades(path string, trades []Trade) error {
|
||||
// #nosec G304 -- path is the user-selected backtest output destination.
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
w := csv.NewWriter(f)
|
||||
defer w.Flush()
|
||||
if err := w.Write([]string{"instrument_uid", "entry_date", "exit_date", "buy_price", "sell_price", "return", "lots", "notional", "net_pnl", "spread_bps", "slippage_bps", "overnight_gap", "capacity_rub"}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, trade := range trades {
|
||||
if err := w.Write([]string{
|
||||
trade.InstrumentUID,
|
||||
trade.EntryDate,
|
||||
trade.ExitDate,
|
||||
trade.BuyPrice.String(),
|
||||
trade.SellPrice.String(),
|
||||
trade.Return.String(),
|
||||
fmt.Sprintf("%d", trade.Lots),
|
||||
trade.Notional.String(),
|
||||
trade.NetPnL.String(),
|
||||
trade.SpreadBps.String(),
|
||||
trade.SlippageBps.String(),
|
||||
trade.OvernightGap.String(),
|
||||
trade.CapacityRUB.String(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.Error()
|
||||
}
|
||||
|
||||
func writeEquity(path string, points []Point) error {
|
||||
// #nosec G304 -- path is the user-selected backtest output destination.
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
w := csv.NewWriter(f)
|
||||
defer w.Flush()
|
||||
if err := w.Write([]string{"date", "equity", "return"}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, point := range points {
|
||||
if err := w.Write([]string{point.Date, point.Equity.String(), point.Return.String()}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.Error()
|
||||
}
|
||||
|
||||
func ParseDecimalFlag(raw string) (decimal.Decimal, error) {
|
||||
if raw == "" {
|
||||
return decimal.Zero, nil
|
||||
}
|
||||
return decimal.NewFromString(raw)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"overnight-trading-bot/internal/domain"
|
||||
)
|
||||
|
||||
func TestBacktestNoLookAheadWithFutureOnlyEdge(t *testing.T) {
|
||||
var candles []domain.Candle
|
||||
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := 0; i < 80; i++ {
|
||||
open := decimal.NewFromInt(100)
|
||||
if i == 79 {
|
||||
open = decimal.NewFromInt(110)
|
||||
}
|
||||
candles = append(candles, domain.Candle{
|
||||
InstrumentUID: "uid",
|
||||
TradeDate: start.AddDate(0, 0, i),
|
||||
Open: open,
|
||||
High: open,
|
||||
Low: open,
|
||||
Close: decimal.NewFromInt(100),
|
||||
})
|
||||
}
|
||||
result, err := New(Config{}).Run(map[string][]domain.Candle{"uid": candles})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Trades) != 0 {
|
||||
t.Fatalf("future-only edge leaked into signals: %d trades", len(result.Trades))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinuteExecutionRequiresReachableLimitAndParticipation(t *testing.T) {
|
||||
engine := New(Config{
|
||||
Lot: 10,
|
||||
MaxParticipationRate: decimal.NewFromFloat(0.10),
|
||||
})
|
||||
entryDate := time.Date(2024, 1, 2, 18, 25, 0, 0, time.UTC)
|
||||
exitDate := time.Date(2024, 1, 3, 10, 5, 0, 0, time.UTC)
|
||||
c := candidate{
|
||||
instrumentUID: "uid",
|
||||
entry: domain.Candle{TradeDate: entryDate},
|
||||
exit: domain.Candle{TradeDate: exitDate},
|
||||
buy: decimal.NewFromInt(100),
|
||||
sell: decimal.NewFromInt(105),
|
||||
}
|
||||
minutes := []domain.Candle{
|
||||
{TradeDate: entryDate, Low: decimal.NewFromInt(99), High: decimal.NewFromInt(101), VolumeLots: decimal.NewFromInt(20)},
|
||||
{TradeDate: exitDate, Low: decimal.NewFromInt(104), High: decimal.NewFromInt(106), VolumeLots: decimal.NewFromInt(20)},
|
||||
}
|
||||
lots, capacity, ok := engine.minuteExecution(c, minutes, 5)
|
||||
if !ok {
|
||||
t.Fatal("expected minute execution")
|
||||
}
|
||||
if lots != 2 {
|
||||
t.Fatalf("lots=%d, want 2", lots)
|
||||
}
|
||||
if !capacity.Equal(decimal.NewFromInt(2000)) {
|
||||
t.Fatalf("capacity=%s, want 2000", capacity)
|
||||
}
|
||||
c.sell = decimal.NewFromInt(110)
|
||||
if _, _, ok := engine.minuteExecution(c, minutes, 5); ok {
|
||||
t.Fatal("sell limit should be unreachable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type Metrics struct {
|
||||
TotalReturn float64 `json:"total_return"`
|
||||
CAGR float64 `json:"cagr"`
|
||||
AnnualizedVolatility float64 `json:"annualized_volatility"`
|
||||
SharpeRatio float64 `json:"sharpe_ratio"`
|
||||
SortinoRatio float64 `json:"sortino_ratio"`
|
||||
MaxDrawdown float64 `json:"max_drawdown"`
|
||||
CalmarRatio float64 `json:"calmar_ratio"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
AverageTradeReturn float64 `json:"average_trade_return"`
|
||||
MedianTradeReturn float64 `json:"median_trade_return"`
|
||||
ProfitFactor float64 `json:"profit_factor"`
|
||||
AverageSpreadBps float64 `json:"average_spread_bps"`
|
||||
AverageSlippageBps float64 `json:"average_slippage_bps"`
|
||||
NumberOfTrades int `json:"number_of_trades"`
|
||||
WorstOvernightGap float64 `json:"worst_overnight_gap"`
|
||||
VaR95 float64 `json:"var_95"`
|
||||
CVaR95 float64 `json:"cvar_95"`
|
||||
CapacityEstimate float64 `json:"capacity_estimate"`
|
||||
}
|
||||
|
||||
func ComputeMetrics(points []Point, trades []Trade) Metrics {
|
||||
if len(points) == 0 {
|
||||
return Metrics{}
|
||||
}
|
||||
start, _ := points[0].Equity.Float64()
|
||||
end, _ := points[len(points)-1].Equity.Float64()
|
||||
returns := make([]float64, 0, len(points)-1)
|
||||
for _, point := range points[1:] {
|
||||
r, _ := point.Return.Float64()
|
||||
returns = append(returns, r)
|
||||
}
|
||||
tradeReturns := make([]float64, 0, len(trades))
|
||||
spreads := make([]float64, 0, len(trades))
|
||||
slippages := make([]float64, 0, len(trades))
|
||||
profits := 0.0
|
||||
losses := 0.0
|
||||
wins := 0
|
||||
worstGap := 0.0
|
||||
capacity := 0.0
|
||||
for _, trade := range trades {
|
||||
r, _ := trade.Return.Float64()
|
||||
tradeReturns = append(tradeReturns, r)
|
||||
spread, _ := trade.SpreadBps.Float64()
|
||||
spreads = append(spreads, spread)
|
||||
slippage, _ := trade.SlippageBps.Float64()
|
||||
slippages = append(slippages, slippage)
|
||||
if r > 0 {
|
||||
wins++
|
||||
profits += r
|
||||
} else {
|
||||
losses += r
|
||||
}
|
||||
gap, _ := trade.OvernightGap.Float64()
|
||||
if gap < worstGap {
|
||||
worstGap = gap
|
||||
}
|
||||
tradeCapacity, _ := trade.CapacityRUB.Float64()
|
||||
if tradeCapacity > 0 && (capacity == 0 || tradeCapacity < capacity) {
|
||||
capacity = tradeCapacity
|
||||
}
|
||||
}
|
||||
totalReturn := 0.0
|
||||
if start > 0 {
|
||||
totalReturn = end/start - 1
|
||||
}
|
||||
vol := stddev(returns) * math.Sqrt(252)
|
||||
meanReturn := mean(returns)
|
||||
sharpe := 0.0
|
||||
if std := stddev(returns); std > 0 {
|
||||
sharpe = meanReturn / std * math.Sqrt(252)
|
||||
}
|
||||
sortino := 0.0
|
||||
if down := downsideStddev(returns); down > 0 {
|
||||
sortino = meanReturn / down * math.Sqrt(252)
|
||||
}
|
||||
tradingDays := math.Max(float64(len(returns)), 1)
|
||||
cagr := 0.0
|
||||
if start > 0 && end > 0 {
|
||||
cagr = math.Pow(end/start, 252/tradingDays) - 1
|
||||
}
|
||||
maxDD := maxDrawdown(points)
|
||||
calmar := 0.0
|
||||
if maxDD != 0 {
|
||||
calmar = cagr / math.Abs(maxDD)
|
||||
}
|
||||
pf := 0.0
|
||||
if losses != 0 {
|
||||
pf = profits / math.Abs(losses)
|
||||
}
|
||||
var95 := percentile(returns, 0.05)
|
||||
cvar95 := conditionalMean(returns, var95)
|
||||
return Metrics{
|
||||
TotalReturn: totalReturn,
|
||||
CAGR: cagr,
|
||||
AnnualizedVolatility: vol,
|
||||
SharpeRatio: sharpe,
|
||||
SortinoRatio: sortino,
|
||||
MaxDrawdown: maxDD,
|
||||
CalmarRatio: calmar,
|
||||
WinRate: ratio(wins, len(tradeReturns)),
|
||||
AverageTradeReturn: mean(tradeReturns),
|
||||
MedianTradeReturn: percentile(tradeReturns, 0.50),
|
||||
ProfitFactor: pf,
|
||||
AverageSpreadBps: mean(spreads),
|
||||
AverageSlippageBps: mean(slippages),
|
||||
NumberOfTrades: len(trades),
|
||||
WorstOvernightGap: worstGap,
|
||||
VaR95: var95,
|
||||
CVaR95: cvar95,
|
||||
CapacityEstimate: capacity,
|
||||
}
|
||||
}
|
||||
|
||||
func maxDrawdown(points []Point) float64 {
|
||||
peak := 0.0
|
||||
maxDD := 0.0
|
||||
for _, point := range points {
|
||||
e, _ := point.Equity.Float64()
|
||||
if e > peak {
|
||||
peak = e
|
||||
}
|
||||
if peak > 0 {
|
||||
dd := e/peak - 1
|
||||
if dd < maxDD {
|
||||
maxDD = dd
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxDD
|
||||
}
|
||||
|
||||
func mean(values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sum := 0.0
|
||||
for _, value := range values {
|
||||
sum += value
|
||||
}
|
||||
return sum / float64(len(values))
|
||||
}
|
||||
|
||||
func stddev(values []float64) float64 {
|
||||
if len(values) < 2 {
|
||||
return 0
|
||||
}
|
||||
m := mean(values)
|
||||
sum := 0.0
|
||||
for _, value := range values {
|
||||
diff := value - m
|
||||
sum += diff * diff
|
||||
}
|
||||
return math.Sqrt(sum / float64(len(values)-1))
|
||||
}
|
||||
|
||||
func downsideStddev(values []float64) float64 {
|
||||
var downs []float64
|
||||
for _, value := range values {
|
||||
if value < 0 {
|
||||
downs = append(downs, value)
|
||||
}
|
||||
}
|
||||
return stddev(downs)
|
||||
}
|
||||
|
||||
func percentile(values []float64, q float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
cp := append([]float64(nil), values...)
|
||||
sort.Float64s(cp)
|
||||
pos := q * float64(len(cp)-1)
|
||||
lower := int(math.Floor(pos))
|
||||
upper := int(math.Ceil(pos))
|
||||
if lower == upper {
|
||||
return cp[lower]
|
||||
}
|
||||
weight := pos - float64(lower)
|
||||
return cp[lower]*(1-weight) + cp[upper]*weight
|
||||
}
|
||||
|
||||
func conditionalMean(values []float64, threshold float64) float64 {
|
||||
var selected []float64
|
||||
for _, value := range values {
|
||||
if value <= threshold {
|
||||
selected = append(selected, value)
|
||||
}
|
||||
}
|
||||
return mean(selected)
|
||||
}
|
||||
|
||||
func ratio(n, d int) float64 {
|
||||
if d == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(n) / float64(d)
|
||||
}
|
||||
|
||||
func point(date string, equity, ret string) Point {
|
||||
e, _ := decimal.NewFromString(equity)
|
||||
r, _ := decimal.NewFromString(ret)
|
||||
return Point{Date: date, Equity: e, Return: r}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package backtest
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMetrics(t *testing.T) {
|
||||
got := ComputeMetrics([]Point{
|
||||
point("START", "100", "0"),
|
||||
point("2024-01-02", "110", "0.10"),
|
||||
point("2024-01-03", "99", "-0.10"),
|
||||
}, []Trade{{Return: point("", "0", "0.10").Return}, {Return: point("", "0", "-0.10").Return}})
|
||||
if got.NumberOfTrades != 2 || got.WinRate != 0.5 || got.MaxDrawdown >= 0 || got.VaR95 >= 0 {
|
||||
t.Fatalf("unexpected metrics: %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user