Files

841 lines
25 KiB
Go
Raw Permalink Normal View History

2026-06-07 21:01:40 +00:00
package backtest
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
2026-06-08 09:03:37 +00:00
"strconv"
2026-06-08 07:36:52 +00:00
"strings"
2026-06-07 21:01:40 +00:00
"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 {
2026-06-08 09:03:37 +00:00
EntrySlippageBps decimal.Decimal
ExitSlippageBps decimal.Decimal
CommissionRoundtripBps decimal.Decimal
RiskBufferBps 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
MaxSpreadBpsMoneyMarket decimal.Decimal
MaxSpreadBpsBondFunds decimal.Decimal
MaxSpreadBpsEquityFunds 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
AssumedSpreadBpsByFundType map[string]decimal.Decimal
InstrumentFundTypes map[string]string
AssumedTickBps decimal.Decimal
Lot int64
LotsByInstrument map[string]int64
MinPriceIncrement decimal.Decimal
MinPriceIncrementsByInstrument map[string]decimal.Decimal
UseMinuteModel bool
EntryWindow TimeWindow
ExitWindow TimeWindow
}
type InstrumentMetadata struct {
Lot int64
MinPriceIncrement decimal.Decimal
2026-06-07 21:51:20 +00:00
}
type TimeWindow struct {
Start time.Duration
End time.Duration
2026-06-07 21:01:40 +00:00
}
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)
}
2026-06-08 07:36:52 +00:00
if cfg.MaxSpreadBpsMoneyMarket.IsZero() {
cfg.MaxSpreadBpsMoneyMarket = decimal.NewFromInt(5)
}
if cfg.MaxSpreadBpsBondFunds.IsZero() {
cfg.MaxSpreadBpsBondFunds = decimal.NewFromInt(10)
}
if cfg.MaxSpreadBpsEquityFunds.IsZero() {
cfg.MaxSpreadBpsEquityFunds = decimal.NewFromInt(25)
}
2026-06-07 21:01:40 +00:00
if cfg.MaxTickBps.IsZero() {
cfg.MaxTickBps = decimal.NewFromInt(10)
}
2026-06-08 07:05:01 +00:00
if cfg.RiskBufferBps.IsZero() {
cfg.RiskBufferBps = decimal.NewFromInt(5)
}
if cfg.AssumedSpreadBps.IsZero() {
cfg.AssumedSpreadBps = cfg.MaxSpreadBps
}
if cfg.AssumedTickBps.IsZero() {
cfg.AssumedTickBps = cfg.MaxTickBps
}
2026-06-08 07:36:52 +00:00
if cfg.RequireZeroCommission == nil {
requireZero := true
cfg.RequireZeroCommission = &requireZero
2026-06-07 21:01:40 +00:00
}
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
}
2026-06-07 21:51:20 +00:00
if cfg.EntryWindow.Start == 0 && cfg.EntryWindow.End == 0 {
cfg.EntryWindow = TimeWindow{Start: durationOfDay(18, 20, 0), End: durationOfDay(18, 38, 30)}
}
if cfg.ExitWindow.Start == 0 && cfg.ExitWindow.End == 0 {
cfg.ExitWindow = TimeWindow{Start: durationOfDay(10, 5, 0), End: durationOfDay(10, 25, 0)}
}
2026-06-07 21:01:40 +00:00
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)
2026-06-07 21:51:20 +00:00
tradingDateSet := make(map[string]struct{})
2026-06-07 21:01:40 +00:00
for instrumentUID, candles := range prepared {
for i := 1; i < len(candles); i++ {
2026-06-08 07:05:01 +00:00
if i >= max(e.cfg.RollingShort, e.cfg.RollingLong) {
2026-06-07 21:51:20 +00:00
tradingDateSet[candles[i].TradeDate.Format("2006-01-02")] = struct{}{}
}
2026-06-07 21:01:40 +00:00
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)
}
}
}
2026-06-07 21:51:20 +00:00
dates := make([]string, 0, len(tradingDateSet))
for date := range tradingDateSet {
2026-06-07 21:01:40 +00:00
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 {
2026-06-08 11:55:36 +00:00
entryIntervalVolume, exitIntervalVolume := e.windowVolumes(c, preparedMinutes[c.instrumentUID])
capacity := decimal.Zero
2026-06-08 14:58:56 +00:00
switch {
case entryIntervalVolume.IsPositive() && exitIntervalVolume.IsPositive():
2026-06-08 11:55:36 +00:00
capacity = money.Min(entryIntervalVolume, exitIntervalVolume).Mul(e.cfg.MaxParticipationRate)
2026-06-08 14:58:56 +00:00
case e.cfg.UseMinuteModel:
2026-06-08 11:55:36 +00:00
continue
2026-06-08 14:58:56 +00:00
default:
2026-06-08 11:55:36 +00:00
entryIntervalVolume = e.unconstrainedIntervalVolume(equity)
exitIntervalVolume = entryIntervalVolume
}
2026-06-07 21:01:40 +00:00
sized := sizer.Size(risk.SizingInput{
Portfolio: domain.Portfolio{Equity: equity, Cash: cash},
SelectedInstruments: len(dayCandidates),
LimitPrice: c.buy,
2026-06-08 09:03:37 +00:00
Lot: c.lot,
2026-06-08 11:55:36 +00:00
EntryIntervalVolume: entryIntervalVolume,
ExitIntervalVolume: exitIntervalVolume,
2026-06-07 21:01:40 +00:00
Q05OvernightAbs: c.q05Abs,
})
if sized.Lots <= 0 {
continue
}
lots := sized.Lots
if e.cfg.UseMinuteModel {
executedLots, minuteCapacity, ok := e.minuteExecution(c, preparedMinutes[c.instrumentUID], sized.Lots)
if !ok {
continue
}
lots = executedLots
capacity = minuteCapacity
}
2026-06-08 09:03:37 +00:00
notional := c.buy.Mul(decimal.NewFromInt(lots)).Mul(decimal.NewFromInt(c.lot))
2026-06-07 21:01:40 +00:00
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,
2026-06-08 07:36:52 +00:00
SpreadBps: c.spreadBps,
2026-06-07 21:01:40 +00:00
SlippageBps: e.cfg.EntrySlippageBps.Add(e.cfg.ExitSlippageBps),
OvernightGap: c.overnightGap,
CapacityRUB: capacity,
})
}
2026-06-07 21:51:20 +00:00
equity = equity.Add(dayPnL)
cash = equity
dayReturn := decimal.Zero
if dayStartEquity.IsPositive() {
dayReturn = dayPnL.Div(dayStartEquity)
2026-06-07 21:01:40 +00:00
}
2026-06-07 21:51:20 +00:00
points = append(points, Point{
Date: date,
Equity: equity,
Return: dayReturn,
})
2026-06-07 21:01:40 +00:00
}
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
}
2026-06-08 09:03:37 +00:00
lot := c.lot
if lot <= 0 {
lot = e.lotFor(c.instrumentUID)
}
entryLots, entryCapacity := e.fillableMinuteLots(minutes, c.entry.TradeDate, c.buy, lot, domain.SideBuy, e.cfg.EntryWindow)
exitLots, exitCapacity := e.fillableMinuteLots(minutes, c.exit.TradeDate, c.sell, lot, domain.SideSell, e.cfg.ExitWindow)
2026-06-07 21:01:40 +00:00
lots := min(requestedLots, entryLots)
lots = min(lots, exitLots)
if lots <= 0 {
return 0, decimal.Zero, false
}
return lots, money.Min(entryCapacity, exitCapacity), true
}
2026-06-08 09:03:37 +00:00
func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limitPrice decimal.Decimal, lot int64, side domain.Side, window TimeWindow) (int64, decimal.Decimal) {
if !limitPrice.IsPositive() || lot <= 0 {
2026-06-07 21:01:40 +00:00
return 0, decimal.Zero
}
2026-06-08 09:03:37 +00:00
lotNotional := limitPrice.Mul(decimal.NewFromInt(lot))
2026-06-07 21:01:40 +00:00
if !lotNotional.IsPositive() {
return 0, decimal.Zero
}
capacity := decimal.Zero
for _, candle := range minutes {
if !sameDate(candle.TradeDate, date) {
continue
}
2026-06-07 21:51:20 +00:00
if !window.Contains(candle.TradeDate) {
continue
}
2026-06-07 21:01:40 +00:00
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
}
2026-06-08 09:03:37 +00:00
func (e Engine) windowCapacity(c candidate, minutes []domain.Candle) decimal.Decimal {
2026-06-08 11:55:36 +00:00
entryVolume, exitVolume := e.windowVolumes(c, minutes)
if !entryVolume.IsPositive() || !exitVolume.IsPositive() {
2026-06-08 09:03:37 +00:00
return decimal.Zero
}
2026-06-08 11:55:36 +00:00
return money.Min(entryVolume, exitVolume).Mul(e.cfg.MaxParticipationRate)
}
func (e Engine) windowVolumes(c candidate, minutes []domain.Candle) (decimal.Decimal, decimal.Decimal) {
if len(minutes) == 0 {
return decimal.Zero, decimal.Zero
}
2026-06-08 09:03:37 +00:00
lot := c.lot
if lot <= 0 {
lot = e.lotFor(c.instrumentUID)
}
if lot <= 0 {
2026-06-08 11:55:36 +00:00
return decimal.Zero, decimal.Zero
2026-06-08 09:03:37 +00:00
}
entryVolume := e.windowNotional(minutes, c.entry.TradeDate, e.cfg.EntryWindow, lot)
exitVolume := e.windowNotional(minutes, c.exit.TradeDate, e.cfg.ExitWindow, lot)
2026-06-08 11:55:36 +00:00
return entryVolume, exitVolume
}
func (e Engine) unconstrainedIntervalVolume(equity decimal.Decimal) decimal.Decimal {
if !equity.IsPositive() || !e.cfg.MaxParticipationRate.IsPositive() {
2026-06-08 09:03:37 +00:00
return decimal.Zero
}
2026-06-08 11:55:36 +00:00
return equity.Div(e.cfg.MaxParticipationRate).Mul(decimal.NewFromInt(10))
2026-06-08 09:03:37 +00:00
}
func (e Engine) windowNotional(minutes []domain.Candle, date time.Time, window TimeWindow, lot int64) decimal.Decimal {
total := decimal.Zero
for _, candle := range minutes {
if !sameDate(candle.TradeDate, date) || !window.Contains(candle.TradeDate) {
continue
}
total = total.Add(candle.VolumeLots.Mul(decimal.NewFromInt(lot)).Mul(candle.Close))
}
return total
}
2026-06-07 21:51:20 +00:00
func (w TimeWindow) Contains(ts time.Time) bool {
if w.Start == 0 && w.End == 0 {
return true
}
tod := time.Duration(ts.Hour())*time.Hour +
time.Duration(ts.Minute())*time.Minute +
time.Duration(ts.Second())*time.Second
return tod >= w.Start && tod <= w.End
}
func durationOfDay(hour, minute, second int) time.Duration {
return time.Duration(hour)*time.Hour +
time.Duration(minute)*time.Minute +
time.Duration(second)*time.Second
}
2026-06-07 21:01:40 +00:00
type candidate struct {
instrumentUID string
entry domain.Candle
exit domain.Candle
buy decimal.Decimal
sell decimal.Decimal
netEdge decimal.Decimal
2026-06-08 07:36:52 +00:00
spreadBps decimal.Decimal
2026-06-07 21:01:40 +00:00
adv decimal.Decimal
q05Abs decimal.Decimal
overnightGap decimal.Decimal
2026-06-08 09:03:37 +00:00
lot int64
2026-06-07 21:01:40 +00:00
}
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
}
2026-06-08 09:03:37 +00:00
lot := e.lotFor(instrumentUID)
2026-06-08 14:25:44 +00:00
entryIndex := exitIndex - 1
if entryIndex <= 0 {
return candidate{}, false, nil
}
history := candles[:entryIndex]
2026-06-07 21:01:40 +00:00
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)
2026-06-08 07:05:01 +00:00
long := features.Rolling(returns, e.cfg.RollingLong, e.cfg.EWMALambda)
2026-06-07 21:01:40 +00:00
if !short.Available || !long.Available || short.StdDev == 0 {
return candidate{}, false, nil
}
rawEdge := decimal.NewFromFloat(short.Mean).Mul(decimal.NewFromInt(10_000))
2026-06-08 07:36:52 +00:00
spreadBps := e.assumedSpreadBps(instrumentUID)
cost := spreadBps.
2026-06-07 21:01:40 +00:00
Add(e.cfg.EntrySlippageBps).
Add(e.cfg.ExitSlippageBps).
2026-06-08 07:05:01 +00:00
Add(e.cfg.CommissionRoundtripBps).
Add(e.cfg.RiskBufferBps)
2026-06-07 21:01:40 +00:00
netEdge := rawEdge.Sub(cost)
2026-06-08 09:03:37 +00:00
adv := features.ADV(history, lot, 20)
2026-06-07 21:01:40 +00:00
switch {
2026-06-08 07:36:52 +00:00
case e.requireZeroCommission() && e.cfg.CommissionRoundtripBps.IsPositive():
2026-06-07 21:01:40 +00:00
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
2026-06-08 07:36:52 +00:00
case spreadBps.GreaterThan(e.maxSpreadBps(instrumentUID)):
2026-06-07 21:01:40 +00:00
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
}
2026-06-08 14:25:44 +00:00
entry := candles[entryIndex]
2026-06-07 21:01:40 +00:00
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)))
2026-06-08 09:03:37 +00:00
if tick := e.minPriceIncrementFor(instrumentUID); tick.IsPositive() {
var err error
buy, err = money.RoundToTick(buy, tick, money.RoundCeil)
if err != nil {
return candidate{}, false, err
}
sell, err = money.RoundToTick(sell, tick, money.RoundFloor)
if err != nil {
return candidate{}, false, err
}
}
2026-06-07 21:01:40 +00:00
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,
2026-06-08 07:36:52 +00:00
spreadBps: spreadBps,
2026-06-07 21:01:40 +00:00
adv: adv,
q05Abs: q05Abs,
overnightGap: gap,
2026-06-08 09:03:37 +00:00
lot: lot,
2026-06-07 21:01:40 +00:00
}, true, nil
}
2026-06-08 09:03:37 +00:00
func (e Engine) lotFor(instrumentUID string) int64 {
if e.cfg.LotsByInstrument != nil {
if lot := e.cfg.LotsByInstrument[instrumentUID]; lot > 0 {
return lot
}
}
if e.cfg.Lot > 0 {
return e.cfg.Lot
}
return 1
}
func (e Engine) minPriceIncrementFor(instrumentUID string) decimal.Decimal {
if e.cfg.MinPriceIncrementsByInstrument != nil {
if tick := e.cfg.MinPriceIncrementsByInstrument[instrumentUID]; tick.IsPositive() {
return tick
}
}
return e.cfg.MinPriceIncrement
}
2026-06-08 07:36:52 +00:00
func (e Engine) requireZeroCommission() bool {
return e.cfg.RequireZeroCommission != nil && *e.cfg.RequireZeroCommission
}
func (e Engine) assumedSpreadBps(instrumentUID string) decimal.Decimal {
fundType := normalizedFundType(e.cfg.InstrumentFundTypes[instrumentUID])
if !fundType.IsZeroValue {
if spread, ok := e.cfg.AssumedSpreadBpsByFundType[fundType.Key]; ok {
return spread
}
return e.maxSpreadBpsForFundType(fundType.Raw)
}
return e.cfg.AssumedSpreadBps
}
func (e Engine) maxSpreadBps(instrumentUID string) decimal.Decimal {
fundType := normalizedFundType(e.cfg.InstrumentFundTypes[instrumentUID])
if fundType.IsZeroValue {
return e.cfg.MaxSpreadBps
}
return e.maxSpreadBpsForFundType(fundType.Raw)
}
func (e Engine) maxSpreadBpsForFundType(fundType string) decimal.Decimal {
switch {
case strings.Contains(fundType, "money"):
return e.cfg.MaxSpreadBpsMoneyMarket
case strings.Contains(fundType, "bond"):
return e.cfg.MaxSpreadBpsBondFunds
case strings.Contains(fundType, "equity"):
return e.cfg.MaxSpreadBpsEquityFunds
default:
return e.cfg.MaxSpreadBps
}
}
type normalizedType struct {
Raw string
Key string
IsZeroValue bool
}
func normalizedFundType(raw string) normalizedType {
raw = strings.ToLower(strings.TrimSpace(raw))
if raw == "" {
return normalizedType{IsZeroValue: true}
}
key := strings.ReplaceAll(raw, "-", "_")
key = strings.ReplaceAll(key, " ", "_")
return normalizedType{Raw: raw, Key: key}
}
2026-06-07 21:01:40 +00:00
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) {
2026-06-08 09:03:37 +00:00
candles, _, err := LoadCandlesCSVWithMetadata(reader)
return candles, err
}
func LoadCandlesCSVWithMetadata(reader io.Reader) (map[string][]domain.Candle, map[string]InstrumentMetadata, error) {
2026-06-07 21:01:40 +00:00
r := csv.NewReader(reader)
r.FieldsPerRecord = -1
records, err := r.ReadAll()
if err != nil {
2026-06-08 09:03:37 +00:00
return nil, nil, err
2026-06-07 21:01:40 +00:00
}
out := make(map[string][]domain.Candle)
2026-06-08 09:03:37 +00:00
metadata := make(map[string]InstrumentMetadata)
header := map[string]int(nil)
start := 0
if len(records) > 0 && len(records[0]) > 0 && strings.EqualFold(strings.TrimSpace(records[0][0]), "instrument_uid") {
header = make(map[string]int, len(records[0]))
for i, name := range records[0] {
header[strings.ToLower(strings.TrimSpace(name))] = i
2026-06-07 21:01:40 +00:00
}
2026-06-08 09:03:37 +00:00
start = 1
}
for i := start; i < len(records); i++ {
record := records[i]
2026-06-07 21:01:40 +00:00
if len(record) < 7 {
2026-06-08 09:03:37 +00:00
return nil, nil, fmt.Errorf("line %d: expected at least 7 fields", i+1)
2026-06-07 21:01:40 +00:00
}
2026-06-08 09:03:37 +00:00
instrumentUID := csvValue(record, header, "instrument_uid", 0)
date, err := parseCandleTime(csvValue(record, header, "trade_date", 1))
2026-06-07 21:01:40 +00:00
if err != nil {
2026-06-08 09:03:37 +00:00
return nil, nil, err
2026-06-07 21:01:40 +00:00
}
2026-06-08 09:03:37 +00:00
open, err := decimal.NewFromString(csvValue(record, header, "open", 2))
2026-06-07 21:01:40 +00:00
if err != nil {
2026-06-08 09:03:37 +00:00
return nil, nil, err
2026-06-07 21:01:40 +00:00
}
2026-06-08 09:03:37 +00:00
high, err := decimal.NewFromString(csvValue(record, header, "high", 3))
2026-06-07 21:01:40 +00:00
if err != nil {
2026-06-08 09:03:37 +00:00
return nil, nil, err
2026-06-07 21:01:40 +00:00
}
2026-06-08 09:03:37 +00:00
low, err := decimal.NewFromString(csvValue(record, header, "low", 4))
2026-06-07 21:01:40 +00:00
if err != nil {
2026-06-08 09:03:37 +00:00
return nil, nil, err
2026-06-07 21:01:40 +00:00
}
2026-06-08 09:03:37 +00:00
closePrice, err := decimal.NewFromString(csvValue(record, header, "close", 5))
2026-06-07 21:01:40 +00:00
if err != nil {
2026-06-08 09:03:37 +00:00
return nil, nil, err
2026-06-07 21:01:40 +00:00
}
2026-06-08 09:03:37 +00:00
volume, err := decimal.NewFromString(csvValue(record, header, "volume_lots", 6))
2026-06-07 21:01:40 +00:00
if err != nil {
2026-06-08 09:03:37 +00:00
return nil, nil, err
2026-06-07 21:01:40 +00:00
}
candle := domain.Candle{
2026-06-08 09:03:37 +00:00
InstrumentUID: instrumentUID,
2026-06-07 21:01:40 +00:00
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)
2026-06-08 09:03:37 +00:00
meta := metadata[candle.InstrumentUID]
if raw, ok := optionalCSVValue(record, header, "lot", 7); ok && strings.TrimSpace(raw) != "" {
lot, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
return nil, nil, fmt.Errorf("line %d: parse lot: %w", i+1, err)
}
if lot > 0 {
meta.Lot = lot
}
}
if raw, ok := optionalCSVValue(record, header, "min_price_increment", 8); ok && strings.TrimSpace(raw) != "" {
tick, err := decimal.NewFromString(strings.TrimSpace(raw))
if err != nil {
return nil, nil, fmt.Errorf("line %d: parse min_price_increment: %w", i+1, err)
}
if tick.IsPositive() {
meta.MinPriceIncrement = tick
}
}
if meta.Lot > 0 || meta.MinPriceIncrement.IsPositive() {
metadata[candle.InstrumentUID] = meta
}
}
return out, metadata, nil
}
func csvValue(record []string, header map[string]int, name string, fallback int) string {
value, _ := optionalCSVValue(record, header, name, fallback)
return strings.TrimSpace(value)
}
func optionalCSVValue(record []string, header map[string]int, name string, fallback int) (string, bool) {
if header != nil {
idx, ok := header[name]
if !ok || idx < 0 || idx >= len(record) {
return "", false
}
return record[idx], true
}
if fallback < 0 || fallback >= len(record) {
return "", false
2026-06-07 21:01:40 +00:00
}
2026-06-08 09:03:37 +00:00
return record[fallback], true
2026-06-07 21:01:40 +00:00
}
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)
}