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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -69,3 +70,86 @@ func TestMinuteExecutionRequiresReachableLimitAndParticipation(t *testing.T) {
|
||||
t.Fatal("sell limit should be unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateCandidateUsesInstrumentLotAndTick(t *testing.T) {
|
||||
engine := New(Config{
|
||||
RollingShort: 2,
|
||||
RollingLong: 2,
|
||||
MinTStat60: decimal.NewFromInt(-1),
|
||||
MinWinRate60: decimal.NewFromFloat(0.1),
|
||||
MinNetEdgeBps: decimal.NewFromInt(-1000),
|
||||
MinADVRUB: decimal.NewFromInt(1),
|
||||
Lot: 1,
|
||||
LotsByInstrument: map[string]int64{"uid": 10},
|
||||
MinPriceIncrementsByInstrument: map[string]decimal.Decimal{"uid": decimal.NewFromFloat(0.05)},
|
||||
EntrySlippageBps: decimal.NewFromInt(13),
|
||||
ExitSlippageBps: decimal.NewFromInt(13),
|
||||
})
|
||||
candles := candidateCandles("uid")
|
||||
got, ok, err := engine.evaluateCandidate("uid", candles, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected candidate")
|
||||
}
|
||||
if got.lot != 10 {
|
||||
t.Fatalf("lot=%d, want 10", got.lot)
|
||||
}
|
||||
if !got.adv.Equal(decimal.NewFromInt(10_000)) {
|
||||
t.Fatalf("adv=%s, want 10000", got.adv)
|
||||
}
|
||||
if !got.buy.Equal(decimal.NewFromFloat(100.15)) {
|
||||
t.Fatalf("buy=%s, want rounded 100.15", got.buy)
|
||||
}
|
||||
if !got.sell.Equal(decimal.NewFromFloat(104.85)) {
|
||||
t.Fatalf("sell=%s, want rounded 104.85", got.sell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowCapacityUsesMinuteEntryAndExitWindows(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)
|
||||
got := engine.windowCapacity(candidate{
|
||||
instrumentUID: "uid",
|
||||
entry: domain.Candle{TradeDate: entryDate},
|
||||
exit: domain.Candle{TradeDate: exitDate},
|
||||
}, []domain.Candle{
|
||||
{TradeDate: entryDate, Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(20)},
|
||||
{TradeDate: exitDate, Close: decimal.NewFromInt(200), VolumeLots: decimal.NewFromInt(5)},
|
||||
{TradeDate: time.Date(2024, 1, 3, 12, 0, 0, 0, time.UTC), Close: decimal.NewFromInt(999), VolumeLots: decimal.NewFromInt(999)},
|
||||
})
|
||||
if !got.Equal(decimal.NewFromInt(1000)) {
|
||||
t.Fatalf("capacity=%s, want min(entry=20000, exit=10000)*0.10 = 1000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCandlesCSVWithMetadata(t *testing.T) {
|
||||
raw := strings.NewReader(`instrument_uid,trade_date,open,high,low,close,volume_lots,lot,min_price_increment
|
||||
uid,2024-01-02,100,101,99,100,10,10,0.05
|
||||
`)
|
||||
candles, metadata, err := LoadCandlesCSVWithMetadata(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(candles["uid"]) != 1 {
|
||||
t.Fatalf("candles=%+v", candles)
|
||||
}
|
||||
if metadata["uid"].Lot != 10 || !metadata["uid"].MinPriceIncrement.Equal(decimal.NewFromFloat(0.05)) {
|
||||
t.Fatalf("metadata=%+v", metadata["uid"])
|
||||
}
|
||||
}
|
||||
|
||||
func candidateCandles(uid string) []domain.Candle {
|
||||
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
return []domain.Candle{
|
||||
{InstrumentUID: uid, TradeDate: start, Open: decimal.NewFromInt(100), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(10)},
|
||||
{InstrumentUID: uid, TradeDate: start.AddDate(0, 0, 1), Open: decimal.NewFromInt(101), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(10)},
|
||||
{InstrumentUID: uid, TradeDate: start.AddDate(0, 0, 2), Open: decimal.NewFromInt(102), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(10)},
|
||||
{InstrumentUID: uid, TradeDate: start.AddDate(0, 0, 3), Open: decimal.NewFromInt(105), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(10)},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user