fifth version
This commit is contained in:
+197
-61
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user