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 {
|
2026-06-08 09:03:37 +00:00
|
|
|
if capacity := e.windowCapacity(candidate, preparedMinutes[instrumentUID]); capacity.IsPositive() {
|
|
|
|
|
candidate.capacity = capacity
|
|
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
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 {
|
|
|
|
|
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-07 21:01:40 +00:00
|
|
|
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
|
|
|
|
|
}
|
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 {
|
|
|
|
|
if len(minutes) == 0 {
|
|
|
|
|
return decimal.Zero
|
|
|
|
|
}
|
|
|
|
|
lot := c.lot
|
|
|
|
|
if lot <= 0 {
|
|
|
|
|
lot = e.lotFor(c.instrumentUID)
|
|
|
|
|
}
|
|
|
|
|
if lot <= 0 {
|
|
|
|
|
return decimal.Zero
|
|
|
|
|
}
|
|
|
|
|
entryVolume := e.windowNotional(minutes, c.entry.TradeDate, e.cfg.EntryWindow, lot)
|
|
|
|
|
exitVolume := e.windowNotional(minutes, c.exit.TradeDate, e.cfg.ExitWindow, lot)
|
|
|
|
|
if !entryVolume.IsPositive() || !exitVolume.IsPositive() {
|
|
|
|
|
return decimal.Zero
|
|
|
|
|
}
|
|
|
|
|
return money.Min(entryVolume, exitVolume).Mul(e.cfg.MaxParticipationRate)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
capacity 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-07 21:01:40 +00:00
|
|
|
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)
|
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-08 09:41:20 +00:00
|
|
|
Add(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
|
|
|
|
|
}
|
|
|
|
|
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)))
|
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,
|
|
|
|
|
capacity: adv.Mul(e.cfg.MaxParticipationRate),
|
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)
|
|
|
|
|
}
|