fifth version

This commit is contained in:
2026-06-08 09:03:37 +00:00
parent b9efa98758
commit 2d57c4ff1f
26 changed files with 896 additions and 159 deletions
+197 -61
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -20,40 +21,48 @@ import (
)
type Config struct {
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
UseMinuteModel bool
EntryWindow TimeWindow
ExitWindow TimeWindow
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
}
type TimeWindow struct {
@@ -203,6 +212,9 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
return Result{}, err
}
if ok {
if capacity := e.windowCapacity(candidate, preparedMinutes[instrumentUID]); capacity.IsPositive() {
candidate.capacity = capacity
}
candidatesByExitDate[candidate.exit.TradeDate.Format("2006-01-02")] = append(candidatesByExitDate[candidate.exit.TradeDate.Format("2006-01-02")], candidate)
}
}
@@ -243,7 +255,7 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
Portfolio: domain.Portfolio{Equity: equity, Cash: cash},
SelectedInstruments: len(dayCandidates),
LimitPrice: c.buy,
Lot: e.cfg.Lot,
Lot: c.lot,
EntryIntervalVolume: c.adv,
ExitIntervalVolume: c.adv,
Q05OvernightAbs: c.q05Abs,
@@ -261,7 +273,7 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
lots = executedLots
capacity = minuteCapacity
}
notional := c.buy.Mul(decimal.NewFromInt(lots)).Mul(decimal.NewFromInt(e.cfg.Lot))
notional := c.buy.Mul(decimal.NewFromInt(lots)).Mul(decimal.NewFromInt(c.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)
@@ -311,8 +323,12 @@ func (e Engine) minuteExecution(c candidate, minutes []domain.Candle, requestedL
if requestedLots <= 0 || len(minutes) == 0 {
return 0, decimal.Zero, false
}
entryLots, entryCapacity := e.fillableMinuteLots(minutes, c.entry.TradeDate, c.buy, domain.SideBuy, e.cfg.EntryWindow)
exitLots, exitCapacity := e.fillableMinuteLots(minutes, c.exit.TradeDate, c.sell, domain.SideSell, e.cfg.ExitWindow)
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)
lots := min(requestedLots, entryLots)
lots = min(lots, exitLots)
if lots <= 0 {
@@ -321,11 +337,11 @@ func (e Engine) minuteExecution(c candidate, minutes []domain.Candle, requestedL
return lots, money.Min(entryCapacity, exitCapacity), true
}
func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limitPrice decimal.Decimal, side domain.Side, window TimeWindow) (int64, decimal.Decimal) {
if !limitPrice.IsPositive() || e.cfg.Lot <= 0 {
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 {
return 0, decimal.Zero
}
lotNotional := limitPrice.Mul(decimal.NewFromInt(e.cfg.Lot))
lotNotional := limitPrice.Mul(decimal.NewFromInt(lot))
if !lotNotional.IsPositive() {
return 0, decimal.Zero
}
@@ -348,6 +364,36 @@ func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limi
return capacity.Div(lotNotional).Floor().IntPart(), capacity
}
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
}
func (w TimeWindow) Contains(ts time.Time) bool {
if w.Start == 0 && w.End == 0 {
return true
@@ -376,12 +422,14 @@ type candidate struct {
q05Abs decimal.Decimal
overnightGap decimal.Decimal
capacity decimal.Decimal
lot int64
}
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
}
lot := e.lotFor(instrumentUID)
history := candles[:exitIndex]
returns := make([]float64, 0, len(history)-1)
for j := 1; j < len(history); j++ {
@@ -405,7 +453,7 @@ func (e Engine) evaluateCandidate(instrumentUID string, candles []domain.Candle,
Add(e.cfg.CommissionRoundtripBps).
Add(e.cfg.RiskBufferBps)
netEdge := rawEdge.Sub(cost)
adv := features.ADV(history, e.cfg.Lot, 20)
adv := features.ADV(history, lot, 20)
switch {
case e.requireZeroCommission() && e.cfg.CommissionRoundtripBps.IsPositive():
return candidate{}, false, nil
@@ -428,6 +476,17 @@ func (e Engine) evaluateCandidate(instrumentUID string, candles []domain.Candle,
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)))
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
}
}
gap, err := features.OvernightReturn(exit.Open, entry.Close)
if err != nil {
return candidate{}, false, err
@@ -448,9 +507,31 @@ func (e Engine) evaluateCandidate(instrumentUID string, candles []domain.Candle,
q05Abs: q05Abs,
overnightGap: gap,
capacity: adv.Mul(e.cfg.MaxParticipationRate),
lot: lot,
}, true, nil
}
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
}
func (e Engine) requireZeroCommission() bool {
return e.cfg.RequireZeroCommission != nil && *e.cfg.RequireZeroCommission
}
@@ -536,46 +617,60 @@ func (r Result) Write(outputDir string) error {
}
func LoadCandlesCSV(reader io.Reader) (map[string][]domain.Candle, error) {
candles, _, err := LoadCandlesCSVWithMetadata(reader)
return candles, err
}
func LoadCandlesCSVWithMetadata(reader io.Reader) (map[string][]domain.Candle, map[string]InstrumentMetadata, error) {
r := csv.NewReader(reader)
r.FieldsPerRecord = -1
records, err := r.ReadAll()
if err != nil {
return nil, err
return nil, nil, err
}
out := make(map[string][]domain.Candle)
for i, record := range records {
if i == 0 && len(record) > 0 && record[0] == "instrument_uid" {
continue
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
}
start = 1
}
for i := start; i < len(records); i++ {
record := records[i]
if len(record) < 7 {
return nil, fmt.Errorf("line %d: expected 7 fields", i+1)
return nil, nil, fmt.Errorf("line %d: expected at least 7 fields", i+1)
}
date, err := parseCandleTime(record[1])
instrumentUID := csvValue(record, header, "instrument_uid", 0)
date, err := parseCandleTime(csvValue(record, header, "trade_date", 1))
if err != nil {
return nil, err
return nil, nil, err
}
open, err := decimal.NewFromString(record[2])
open, err := decimal.NewFromString(csvValue(record, header, "open", 2))
if err != nil {
return nil, err
return nil, nil, err
}
high, err := decimal.NewFromString(record[3])
high, err := decimal.NewFromString(csvValue(record, header, "high", 3))
if err != nil {
return nil, err
return nil, nil, err
}
low, err := decimal.NewFromString(record[4])
low, err := decimal.NewFromString(csvValue(record, header, "low", 4))
if err != nil {
return nil, err
return nil, nil, err
}
closePrice, err := decimal.NewFromString(record[5])
closePrice, err := decimal.NewFromString(csvValue(record, header, "close", 5))
if err != nil {
return nil, err
return nil, nil, err
}
volume, err := decimal.NewFromString(record[6])
volume, err := decimal.NewFromString(csvValue(record, header, "volume_lots", 6))
if err != nil {
return nil, err
return nil, nil, err
}
candle := domain.Candle{
InstrumentUID: record[0],
InstrumentUID: instrumentUID,
TradeDate: date,
Open: open,
High: high,
@@ -586,8 +681,49 @@ func LoadCandlesCSV(reader io.Reader) (map[string][]domain.Candle, error) {
LoadedAt: time.Now().UTC(),
}
out[candle.InstrumentUID] = append(out[candle.InstrumentUID], candle)
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, nil
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
}
return record[fallback], true
}
func parseCandleTime(raw string) (time.Time, error) {