fifth version
This commit is contained in:
+36
-2
@@ -49,7 +49,7 @@ func run() error {
|
|||||||
defer func() {
|
defer func() {
|
||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
}()
|
}()
|
||||||
candles, err := backtest.LoadCandlesCSV(file)
|
candles, metadata, err := backtest.LoadCandlesCSVWithMetadata(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load candles: %w", err)
|
return fmt.Errorf("load candles: %w", err)
|
||||||
}
|
}
|
||||||
@@ -62,10 +62,12 @@ func run() error {
|
|||||||
defer func() {
|
defer func() {
|
||||||
_ = minuteFile.Close()
|
_ = minuteFile.Close()
|
||||||
}()
|
}()
|
||||||
minuteCandles, err = backtest.LoadCandlesCSV(minuteFile)
|
var minuteMetadata map[string]backtest.InstrumentMetadata
|
||||||
|
minuteCandles, minuteMetadata, err = backtest.LoadCandlesCSVWithMetadata(minuteFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load minute candles: %w", err)
|
return fmt.Errorf("load minute candles: %w", err)
|
||||||
}
|
}
|
||||||
|
mergeMetadata(metadata, minuteMetadata)
|
||||||
}
|
}
|
||||||
if *useMinuteModel && len(minuteCandles) == 0 {
|
if *useMinuteModel && len(minuteCandles) == 0 {
|
||||||
return fmt.Errorf("-minute-candles is required when -use-minute-model=true")
|
return fmt.Errorf("-minute-candles is required when -use-minute-model=true")
|
||||||
@@ -114,6 +116,7 @@ func run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("max tick: %w", err)
|
return fmt.Errorf("max tick: %w", err)
|
||||||
}
|
}
|
||||||
|
lotsByInstrument, ticksByInstrument := metadataMaps(metadata)
|
||||||
engine := backtest.New(backtest.Config{
|
engine := backtest.New(backtest.Config{
|
||||||
EntrySlippageBps: entry,
|
EntrySlippageBps: entry,
|
||||||
ExitSlippageBps: exit,
|
ExitSlippageBps: exit,
|
||||||
@@ -131,6 +134,8 @@ func run() error {
|
|||||||
MaxTickBps: tick,
|
MaxTickBps: tick,
|
||||||
AssumedSpreadBps: assumed,
|
AssumedSpreadBps: assumed,
|
||||||
RequireZeroCommission: requireZeroCommission,
|
RequireZeroCommission: requireZeroCommission,
|
||||||
|
LotsByInstrument: lotsByInstrument,
|
||||||
|
MinPriceIncrementsByInstrument: ticksByInstrument,
|
||||||
UseMinuteModel: *useMinuteModel,
|
UseMinuteModel: *useMinuteModel,
|
||||||
})
|
})
|
||||||
result, err := engine.RunWithMinuteCandles(candles, minuteCandles)
|
result, err := engine.RunWithMinuteCandles(candles, minuteCandles)
|
||||||
@@ -143,3 +148,32 @@ func run() error {
|
|||||||
fmt.Printf("backtest complete: trades=%d total_return=%.6f\n", result.Metrics.NumberOfTrades, result.Metrics.TotalReturn)
|
fmt.Printf("backtest complete: trades=%d total_return=%.6f\n", result.Metrics.NumberOfTrades, result.Metrics.TotalReturn)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mergeMetadata(dst, src map[string]backtest.InstrumentMetadata) {
|
||||||
|
for uid, meta := range src {
|
||||||
|
current := dst[uid]
|
||||||
|
if current.Lot <= 0 {
|
||||||
|
current.Lot = meta.Lot
|
||||||
|
}
|
||||||
|
if !current.MinPriceIncrement.IsPositive() {
|
||||||
|
current.MinPriceIncrement = meta.MinPriceIncrement
|
||||||
|
}
|
||||||
|
if current.Lot > 0 || current.MinPriceIncrement.IsPositive() {
|
||||||
|
dst[uid] = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataMaps(metadata map[string]backtest.InstrumentMetadata) (map[string]int64, map[string]decimal.Decimal) {
|
||||||
|
lots := make(map[string]int64)
|
||||||
|
ticks := make(map[string]decimal.Decimal)
|
||||||
|
for uid, meta := range metadata {
|
||||||
|
if meta.Lot > 0 {
|
||||||
|
lots[uid] = meta.Lot
|
||||||
|
}
|
||||||
|
if meta.MinPriceIncrement.IsPositive() {
|
||||||
|
ticks[uid] = meta.MinPriceIncrement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lots, ticks
|
||||||
|
}
|
||||||
|
|||||||
+20
-4
@@ -114,10 +114,12 @@ func Run(ctx context.Context, opts Options) error {
|
|||||||
defer closer()
|
defer closer()
|
||||||
}
|
}
|
||||||
accountIDHash := accountHash(cfg.TInvest.AccountID)
|
accountIDHash := accountHash(cfg.TInvest.AccountID)
|
||||||
|
clock := timeutil.RealClock{Loc: cfg.Location}
|
||||||
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
|
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
|
||||||
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours)*time.Hour).
|
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours)*time.Hour).
|
||||||
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec)*time.Second).
|
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec)*time.Second).
|
||||||
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB)
|
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB).
|
||||||
|
WithClock(clock)
|
||||||
diffs, err := recon.Run(ctx)
|
diffs, err := recon.Run(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("pre-unhalt reconciliation: %w", err)
|
return fmt.Errorf("pre-unhalt reconciliation: %w", err)
|
||||||
@@ -159,10 +161,12 @@ func Run(ctx context.Context, opts Options) error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
accountIDHash := accountHash(cfg.TInvest.AccountID)
|
accountIDHash := accountHash(cfg.TInvest.AccountID)
|
||||||
|
clock := timeutil.RealClock{Loc: cfg.Location}
|
||||||
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
|
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
|
||||||
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours)*time.Hour).
|
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours)*time.Hour).
|
||||||
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec)*time.Second).
|
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec)*time.Second).
|
||||||
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB)
|
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB).
|
||||||
|
WithClock(clock)
|
||||||
sm := statemachine.New(repo, cfg.App.Mode)
|
sm := statemachine.New(repo, cfg.App.Mode)
|
||||||
if _, err := sm.Recover(ctx, recon); err != nil {
|
if _, err := sm.Recover(ctx, recon); err != nil {
|
||||||
_ = notifier.Alert(ctx, fmt.Sprintf("state recovery failed: %s", err))
|
_ = notifier.Alert(ctx, fmt.Sprintf("state recovery failed: %s", err))
|
||||||
@@ -184,14 +188,25 @@ func Run(ctx context.Context, opts Options) error {
|
|||||||
}
|
}
|
||||||
runCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
runCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
clock := timeutil.RealClock{Loc: cfg.Location}
|
|
||||||
runtime := buildScheduler(clock, sm, cfg, repo, gateway, notifier, recon, accountIDHash, log)
|
runtime := buildScheduler(clock, sm, cfg, repo, gateway, notifier, recon, accountIDHash, log)
|
||||||
return runtime.Run(runCtx)
|
if err := runtime.Run(runCtx); err != nil {
|
||||||
|
if runCtx.Err() == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.App.ShutdownTimeoutSec)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if shutdownErr := runtime.GracefulShutdown(shutdownCtx); shutdownErr != nil {
|
||||||
|
return fmt.Errorf("%w; graceful shutdown: %v", err, shutdownErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Config, repo *mysqlrepo.Repository, gateway tinvest.Gateway, notifier notify.Notifier, recon reconciliation.Engine, accountIDHash string, log *slog.Logger) scheduler.Scheduler {
|
func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Config, repo *mysqlrepo.Repository, gateway tinvest.Gateway, notifier notify.Notifier, recon reconciliation.Engine, accountIDHash string, log *slog.Logger) scheduler.Scheduler {
|
||||||
registry := instruments.NewRegistry(repo, gateway)
|
registry := instruments.NewRegistry(repo, gateway)
|
||||||
loader := marketdata.NewLoader(repo, gateway)
|
loader := marketdata.NewLoader(repo, gateway)
|
||||||
|
loader.SetClock(clock)
|
||||||
pipeline := features.NewPipeline(repo, features.PipelineConfig{
|
pipeline := features.NewPipeline(repo, features.PipelineConfig{
|
||||||
RollingShort: cfg.Strategy.RollingShort,
|
RollingShort: cfg.Strategy.RollingShort,
|
||||||
RollingLong: cfg.Strategy.RollingLong,
|
RollingLong: cfg.Strategy.RollingLong,
|
||||||
@@ -243,6 +258,7 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con
|
|||||||
MaxQuoteAge: time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second,
|
MaxQuoteAge: time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second,
|
||||||
})
|
})
|
||||||
execEngine := execution.NewEngine(cfg.App.Mode, cfg.TInvest.AccountID, gateway, repo)
|
execEngine := execution.NewEngine(cfg.App.Mode, cfg.TInvest.AccountID, gateway, repo)
|
||||||
|
execEngine.SetClock(clock)
|
||||||
execEngine.SetMaxQuoteAge(time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second)
|
execEngine.SetMaxQuoteAge(time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second)
|
||||||
execEngine.SetFreeOrderCountPolicy(cfg.Commission.FreeOrderCountPolicy)
|
execEngine.SetFreeOrderCountPolicy(cfg.Commission.FreeOrderCountPolicy)
|
||||||
services := scheduler.Services{
|
services := scheduler.Services{
|
||||||
|
|||||||
+163
-27
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -51,11 +52,19 @@ type Config struct {
|
|||||||
InstrumentFundTypes map[string]string
|
InstrumentFundTypes map[string]string
|
||||||
AssumedTickBps decimal.Decimal
|
AssumedTickBps decimal.Decimal
|
||||||
Lot int64
|
Lot int64
|
||||||
|
LotsByInstrument map[string]int64
|
||||||
|
MinPriceIncrement decimal.Decimal
|
||||||
|
MinPriceIncrementsByInstrument map[string]decimal.Decimal
|
||||||
UseMinuteModel bool
|
UseMinuteModel bool
|
||||||
EntryWindow TimeWindow
|
EntryWindow TimeWindow
|
||||||
ExitWindow TimeWindow
|
ExitWindow TimeWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InstrumentMetadata struct {
|
||||||
|
Lot int64
|
||||||
|
MinPriceIncrement decimal.Decimal
|
||||||
|
}
|
||||||
|
|
||||||
type TimeWindow struct {
|
type TimeWindow struct {
|
||||||
Start time.Duration
|
Start time.Duration
|
||||||
End time.Duration
|
End time.Duration
|
||||||
@@ -203,6 +212,9 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
|
|||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
if ok {
|
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)
|
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},
|
Portfolio: domain.Portfolio{Equity: equity, Cash: cash},
|
||||||
SelectedInstruments: len(dayCandidates),
|
SelectedInstruments: len(dayCandidates),
|
||||||
LimitPrice: c.buy,
|
LimitPrice: c.buy,
|
||||||
Lot: e.cfg.Lot,
|
Lot: c.lot,
|
||||||
EntryIntervalVolume: c.adv,
|
EntryIntervalVolume: c.adv,
|
||||||
ExitIntervalVolume: c.adv,
|
ExitIntervalVolume: c.adv,
|
||||||
Q05OvernightAbs: c.q05Abs,
|
Q05OvernightAbs: c.q05Abs,
|
||||||
@@ -261,7 +273,7 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
|
|||||||
lots = executedLots
|
lots = executedLots
|
||||||
capacity = minuteCapacity
|
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))
|
ret := c.sell.Div(c.buy).Sub(decimal.NewFromInt(1)).Sub(money.FromBps(e.cfg.CommissionRoundtripBps))
|
||||||
pnl := notional.Mul(ret)
|
pnl := notional.Mul(ret)
|
||||||
dayPnL = dayPnL.Add(pnl)
|
dayPnL = dayPnL.Add(pnl)
|
||||||
@@ -311,8 +323,12 @@ func (e Engine) minuteExecution(c candidate, minutes []domain.Candle, requestedL
|
|||||||
if requestedLots <= 0 || len(minutes) == 0 {
|
if requestedLots <= 0 || len(minutes) == 0 {
|
||||||
return 0, decimal.Zero, false
|
return 0, decimal.Zero, false
|
||||||
}
|
}
|
||||||
entryLots, entryCapacity := e.fillableMinuteLots(minutes, c.entry.TradeDate, c.buy, domain.SideBuy, e.cfg.EntryWindow)
|
lot := c.lot
|
||||||
exitLots, exitCapacity := e.fillableMinuteLots(minutes, c.exit.TradeDate, c.sell, domain.SideSell, e.cfg.ExitWindow)
|
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(requestedLots, entryLots)
|
||||||
lots = min(lots, exitLots)
|
lots = min(lots, exitLots)
|
||||||
if lots <= 0 {
|
if lots <= 0 {
|
||||||
@@ -321,11 +337,11 @@ func (e Engine) minuteExecution(c candidate, minutes []domain.Candle, requestedL
|
|||||||
return lots, money.Min(entryCapacity, exitCapacity), true
|
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) {
|
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() || e.cfg.Lot <= 0 {
|
if !limitPrice.IsPositive() || lot <= 0 {
|
||||||
return 0, decimal.Zero
|
return 0, decimal.Zero
|
||||||
}
|
}
|
||||||
lotNotional := limitPrice.Mul(decimal.NewFromInt(e.cfg.Lot))
|
lotNotional := limitPrice.Mul(decimal.NewFromInt(lot))
|
||||||
if !lotNotional.IsPositive() {
|
if !lotNotional.IsPositive() {
|
||||||
return 0, decimal.Zero
|
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
|
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 {
|
func (w TimeWindow) Contains(ts time.Time) bool {
|
||||||
if w.Start == 0 && w.End == 0 {
|
if w.Start == 0 && w.End == 0 {
|
||||||
return true
|
return true
|
||||||
@@ -376,12 +422,14 @@ type candidate struct {
|
|||||||
q05Abs decimal.Decimal
|
q05Abs decimal.Decimal
|
||||||
overnightGap decimal.Decimal
|
overnightGap decimal.Decimal
|
||||||
capacity decimal.Decimal
|
capacity decimal.Decimal
|
||||||
|
lot int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e Engine) evaluateCandidate(instrumentUID string, candles []domain.Candle, exitIndex int) (candidate, bool, error) {
|
func (e Engine) evaluateCandidate(instrumentUID string, candles []domain.Candle, exitIndex int) (candidate, bool, error) {
|
||||||
if exitIndex < e.cfg.RollingShort || exitIndex <= 0 {
|
if exitIndex < e.cfg.RollingShort || exitIndex <= 0 {
|
||||||
return candidate{}, false, nil
|
return candidate{}, false, nil
|
||||||
}
|
}
|
||||||
|
lot := e.lotFor(instrumentUID)
|
||||||
history := candles[:exitIndex]
|
history := candles[:exitIndex]
|
||||||
returns := make([]float64, 0, len(history)-1)
|
returns := make([]float64, 0, len(history)-1)
|
||||||
for j := 1; j < len(history); j++ {
|
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.CommissionRoundtripBps).
|
||||||
Add(e.cfg.RiskBufferBps)
|
Add(e.cfg.RiskBufferBps)
|
||||||
netEdge := rawEdge.Sub(cost)
|
netEdge := rawEdge.Sub(cost)
|
||||||
adv := features.ADV(history, e.cfg.Lot, 20)
|
adv := features.ADV(history, lot, 20)
|
||||||
switch {
|
switch {
|
||||||
case e.requireZeroCommission() && e.cfg.CommissionRoundtripBps.IsPositive():
|
case e.requireZeroCommission() && e.cfg.CommissionRoundtripBps.IsPositive():
|
||||||
return candidate{}, false, nil
|
return candidate{}, false, nil
|
||||||
@@ -428,6 +476,17 @@ func (e Engine) evaluateCandidate(instrumentUID string, candles []domain.Candle,
|
|||||||
exit := candles[exitIndex]
|
exit := candles[exitIndex]
|
||||||
buy := entry.Close.Mul(decimal.NewFromInt(1).Add(money.FromBps(e.cfg.EntrySlippageBps)))
|
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)))
|
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)
|
gap, err := features.OvernightReturn(exit.Open, entry.Close)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return candidate{}, false, err
|
return candidate{}, false, err
|
||||||
@@ -448,9 +507,31 @@ func (e Engine) evaluateCandidate(instrumentUID string, candles []domain.Candle,
|
|||||||
q05Abs: q05Abs,
|
q05Abs: q05Abs,
|
||||||
overnightGap: gap,
|
overnightGap: gap,
|
||||||
capacity: adv.Mul(e.cfg.MaxParticipationRate),
|
capacity: adv.Mul(e.cfg.MaxParticipationRate),
|
||||||
|
lot: lot,
|
||||||
}, true, nil
|
}, 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 {
|
func (e Engine) requireZeroCommission() bool {
|
||||||
return e.cfg.RequireZeroCommission != nil && *e.cfg.RequireZeroCommission
|
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) {
|
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 := csv.NewReader(reader)
|
||||||
r.FieldsPerRecord = -1
|
r.FieldsPerRecord = -1
|
||||||
records, err := r.ReadAll()
|
records, err := r.ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
out := make(map[string][]domain.Candle)
|
out := make(map[string][]domain.Candle)
|
||||||
for i, record := range records {
|
metadata := make(map[string]InstrumentMetadata)
|
||||||
if i == 0 && len(record) > 0 && record[0] == "instrument_uid" {
|
header := map[string]int(nil)
|
||||||
continue
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
candle := domain.Candle{
|
candle := domain.Candle{
|
||||||
InstrumentUID: record[0],
|
InstrumentUID: instrumentUID,
|
||||||
TradeDate: date,
|
TradeDate: date,
|
||||||
Open: open,
|
Open: open,
|
||||||
High: high,
|
High: high,
|
||||||
@@ -586,8 +681,49 @@ func LoadCandlesCSV(reader io.Reader) (map[string][]domain.Candle, error) {
|
|||||||
LoadedAt: time.Now().UTC(),
|
LoadedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
out[candle.InstrumentUID] = append(out[candle.InstrumentUID], candle)
|
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)
|
||||||
}
|
}
|
||||||
return out, nil
|
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
|
||||||
|
}
|
||||||
|
return record[fallback], true
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseCandleTime(raw string) (time.Time, error) {
|
func parseCandleTime(raw string) (time.Time, error) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package backtest
|
package backtest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -69,3 +70,86 @@ func TestMinuteExecutionRequiresReachableLimitAndParticipation(t *testing.T) {
|
|||||||
t.Fatal("sell limit should be unreachable")
|
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)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ type FeatureSet struct {
|
|||||||
MuOn60 decimal.Decimal
|
MuOn60 decimal.Decimal
|
||||||
MuOn252 decimal.Decimal
|
MuOn252 decimal.Decimal
|
||||||
SigmaOn60 decimal.Decimal
|
SigmaOn60 decimal.Decimal
|
||||||
|
Q05On60Abs decimal.Decimal
|
||||||
TStatOn60 decimal.Decimal
|
TStatOn60 decimal.Decimal
|
||||||
WinOn60 decimal.Decimal
|
WinOn60 decimal.Decimal
|
||||||
EWMAOn decimal.Decimal
|
EWMAOn decimal.Decimal
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"overnight-trading-bot/internal/domain"
|
"overnight-trading-bot/internal/domain"
|
||||||
"overnight-trading-bot/internal/repository"
|
"overnight-trading-bot/internal/repository"
|
||||||
"overnight-trading-bot/internal/risk"
|
"overnight-trading-bot/internal/risk"
|
||||||
|
"overnight-trading-bot/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrBrokerOrdersDisabled = errors.New("broker orders are disabled for current mode")
|
var ErrBrokerOrdersDisabled = errors.New("broker orders are disabled for current mode")
|
||||||
@@ -35,6 +36,7 @@ type Engine struct {
|
|||||||
store repository.Repository
|
store repository.Repository
|
||||||
maxQuoteAge time.Duration
|
maxQuoteAge time.Duration
|
||||||
freeOrderCountPolicy string
|
freeOrderCountPolicy string
|
||||||
|
clock timeutil.Clock
|
||||||
mu sync.Map
|
mu sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,13 +52,26 @@ type MonitorConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewEngine(mode domain.Mode, accountID string, gateway Gateway, store repository.Repository) Engine {
|
func NewEngine(mode domain.Mode, accountID string, gateway Gateway, store repository.Repository) Engine {
|
||||||
return Engine{mode: mode, accountID: accountID, gateway: gateway, store: store, freeOrderCountPolicy: FreeOrderPolicySubmitted}
|
return Engine{
|
||||||
|
mode: mode,
|
||||||
|
accountID: accountID,
|
||||||
|
gateway: gateway,
|
||||||
|
store: store,
|
||||||
|
freeOrderCountPolicy: FreeOrderPolicySubmitted,
|
||||||
|
clock: timeutil.RealClock{},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) SetMaxQuoteAge(maxQuoteAge time.Duration) {
|
func (e *Engine) SetMaxQuoteAge(maxQuoteAge time.Duration) {
|
||||||
e.maxQuoteAge = maxQuoteAge
|
e.maxQuoteAge = maxQuoteAge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) SetClock(clock timeutil.Clock) {
|
||||||
|
if clock != nil {
|
||||||
|
e.clock = clock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) SetFreeOrderCountPolicy(policy string) {
|
func (e *Engine) SetFreeOrderCountPolicy(policy string) {
|
||||||
switch policy {
|
switch policy {
|
||||||
case FreeOrderPolicyCancelCounts:
|
case FreeOrderPolicyCancelCounts:
|
||||||
@@ -78,7 +93,7 @@ func (e *Engine) PlaceEntry(ctx context.Context, accountIDHash string, instrumen
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Order{}, err
|
return domain.Order{}, err
|
||||||
}
|
}
|
||||||
return e.PlaceLimit(ctx, domain.Order{
|
return e.placeLimit(ctx, domain.Order{
|
||||||
ClientOrderID: ClientOrderID(tradeDate, instrument.InstrumentUID, domain.SideBuy, attempt),
|
ClientOrderID: ClientOrderID(tradeDate, instrument.InstrumentUID, domain.SideBuy, attempt),
|
||||||
AccountIDHash: accountIDHash,
|
AccountIDHash: accountIDHash,
|
||||||
InstrumentUID: instrument.InstrumentUID,
|
InstrumentUID: instrument.InstrumentUID,
|
||||||
@@ -90,7 +105,7 @@ func (e *Engine) PlaceEntry(ctx context.Context, accountIDHash string, instrumen
|
|||||||
Status: domain.OrderStatusNew,
|
Status: domain.OrderStatusNew,
|
||||||
AttemptNo: attempt,
|
AttemptNo: attempt,
|
||||||
RawStateJSON: "{}",
|
RawStateJSON: "{}",
|
||||||
})
|
}, instrument.FreeOrderLimitPerDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) PlaceExit(ctx context.Context, accountIDHash string, instrument domain.Instrument, tradeDate time.Time, lots int64, book domain.OrderBook, improveTicks int, attempt int) (domain.Order, error) {
|
func (e *Engine) PlaceExit(ctx context.Context, accountIDHash string, instrument domain.Instrument, tradeDate time.Time, lots int64, book domain.OrderBook, improveTicks int, attempt int) (domain.Order, error) {
|
||||||
@@ -105,7 +120,7 @@ func (e *Engine) PlaceExit(ctx context.Context, accountIDHash string, instrument
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Order{}, err
|
return domain.Order{}, err
|
||||||
}
|
}
|
||||||
return e.PlaceLimit(ctx, domain.Order{
|
return e.placeLimit(ctx, domain.Order{
|
||||||
ClientOrderID: ClientOrderID(tradeDate, instrument.InstrumentUID, domain.SideSell, attempt),
|
ClientOrderID: ClientOrderID(tradeDate, instrument.InstrumentUID, domain.SideSell, attempt),
|
||||||
AccountIDHash: accountIDHash,
|
AccountIDHash: accountIDHash,
|
||||||
InstrumentUID: instrument.InstrumentUID,
|
InstrumentUID: instrument.InstrumentUID,
|
||||||
@@ -117,10 +132,14 @@ func (e *Engine) PlaceExit(ctx context.Context, accountIDHash string, instrument
|
|||||||
Status: domain.OrderStatusNew,
|
Status: domain.OrderStatusNew,
|
||||||
AttemptNo: attempt,
|
AttemptNo: attempt,
|
||||||
RawStateJSON: "{}",
|
RawStateJSON: "{}",
|
||||||
})
|
}, instrument.FreeOrderLimitPerDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Order, error) {
|
func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Order, error) {
|
||||||
|
return e.placeLimit(ctx, order, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) placeLimit(ctx context.Context, order domain.Order, freeOrderLimit int) (domain.Order, error) {
|
||||||
lock := e.lockFor(order.InstrumentUID)
|
lock := e.lockFor(order.InstrumentUID)
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
@@ -134,7 +153,7 @@ func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Ord
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if e.mode == domain.ModePaper {
|
if e.mode == domain.ModePaper {
|
||||||
return e.placePaperLimit(ctx, order)
|
return e.placePaperLimit(ctx, order, freeOrderLimit)
|
||||||
}
|
}
|
||||||
if !e.mode.AllowsBrokerOrders() {
|
if !e.mode.AllowsBrokerOrders() {
|
||||||
order.Status = domain.OrderStatusNew
|
order.Status = domain.OrderStatusNew
|
||||||
@@ -147,7 +166,7 @@ func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Ord
|
|||||||
return domain.Order{}, errors.New("gateway is nil")
|
return domain.Order{}, errors.New("gateway is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := e.nowUTC()
|
||||||
draft := order
|
draft := order
|
||||||
draft.Status = domain.OrderStatusSent
|
draft.Status = domain.OrderStatusSent
|
||||||
draft.CreatedAt = now
|
draft.CreatedAt = now
|
||||||
@@ -156,8 +175,13 @@ func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Ord
|
|||||||
draft.RawStateJSON = "{}"
|
draft.RawStateJSON = "{}"
|
||||||
}
|
}
|
||||||
if e.store != nil {
|
if e.store != nil {
|
||||||
if err := e.store.UpsertOrder(ctx, draft); err != nil {
|
if err := e.store.RunInTx(ctx, func(ctx context.Context, repo repository.Repository) error {
|
||||||
return domain.Order{}, fmt.Errorf("persist draft order: %w", err)
|
if err := repo.UpsertOrder(ctx, draft); err != nil {
|
||||||
|
return fmt.Errorf("persist draft order: %w", err)
|
||||||
|
}
|
||||||
|
return repo.ReserveFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1, freeOrderLimit)
|
||||||
|
}); err != nil {
|
||||||
|
return domain.Order{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
posted, err := e.gateway.PostLimitOrder(ctx, e.accountID, order.InstrumentUID, order.Side, order.QuantityLots, order.LimitPrice, order.ClientOrderID)
|
posted, err := e.gateway.PostLimitOrder(ctx, e.accountID, order.InstrumentUID, order.Side, order.QuantityLots, order.LimitPrice, order.ClientOrderID)
|
||||||
@@ -180,20 +204,15 @@ func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Ord
|
|||||||
posted.CreatedAt = now
|
posted.CreatedAt = now
|
||||||
posted.UpdatedAt = posted.CreatedAt
|
posted.UpdatedAt = posted.CreatedAt
|
||||||
if e.store != nil {
|
if e.store != nil {
|
||||||
if err := e.store.RunInTx(ctx, func(ctx context.Context, repo repository.Repository) error {
|
if err := e.store.UpsertOrder(ctx, posted); err != nil {
|
||||||
if err := repo.UpsertOrder(ctx, posted); err != nil {
|
|
||||||
return fmt.Errorf("persist posted order: %w", err)
|
|
||||||
}
|
|
||||||
return repo.IncrementFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1)
|
|
||||||
}); err != nil {
|
|
||||||
return domain.Order{}, err
|
return domain.Order{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return posted, nil
|
return posted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) placePaperLimit(ctx context.Context, order domain.Order) (domain.Order, error) {
|
func (e *Engine) placePaperLimit(ctx context.Context, order domain.Order, freeOrderLimit int) (domain.Order, error) {
|
||||||
now := time.Now().UTC()
|
now := e.nowUTC()
|
||||||
order.BrokerOrderID = "paper-" + order.ClientOrderID
|
order.BrokerOrderID = "paper-" + order.ClientOrderID
|
||||||
order.FilledLots = order.QuantityLots
|
order.FilledLots = order.QuantityLots
|
||||||
order.AvgFillPrice = order.LimitPrice
|
order.AvgFillPrice = order.LimitPrice
|
||||||
@@ -206,7 +225,7 @@ func (e *Engine) placePaperLimit(ctx context.Context, order domain.Order) (domai
|
|||||||
if err := repo.UpsertOrder(ctx, order); err != nil {
|
if err := repo.UpsertOrder(ctx, order); err != nil {
|
||||||
return fmt.Errorf("persist paper order: %w", err)
|
return fmt.Errorf("persist paper order: %w", err)
|
||||||
}
|
}
|
||||||
return repo.IncrementFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1)
|
return repo.ReserveFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1, freeOrderLimit)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return domain.Order{}, err
|
return domain.Order{}, err
|
||||||
}
|
}
|
||||||
@@ -286,12 +305,10 @@ func (e *Engine) MonitorUntil(ctx context.Context, order domain.Order, cfg Monit
|
|||||||
if cfg.MaxAttempts <= 0 {
|
if cfg.MaxAttempts <= 0 {
|
||||||
cfg.MaxAttempts = 1
|
cfg.MaxAttempts = 1
|
||||||
}
|
}
|
||||||
lastPost := time.Now()
|
lastPost := e.nowUTC()
|
||||||
current := order
|
current := order
|
||||||
aggregate := order
|
aggregate := order
|
||||||
seen := map[string]domain.Order{order.ClientOrderID: order}
|
seen := map[string]domain.Order{order.ClientOrderID: order}
|
||||||
ticker := time.NewTicker(cfg.PollInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
for {
|
||||||
previous := seen[current.ClientOrderID]
|
previous := seen[current.ClientOrderID]
|
||||||
refreshed, err := e.Refresh(ctx, current)
|
refreshed, err := e.Refresh(ctx, current)
|
||||||
@@ -311,7 +328,7 @@ func (e *Engine) MonitorUntil(ctx context.Context, order domain.Order, cfg Monit
|
|||||||
if isTerminal(current.Status) {
|
if isTerminal(current.Status) {
|
||||||
return aggregate, nil
|
return aggregate, nil
|
||||||
}
|
}
|
||||||
if !cfg.Deadline.IsZero() && !time.Now().Before(cfg.Deadline) {
|
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
||||||
if err := e.Cancel(ctx, current); err != nil {
|
if err := e.Cancel(ctx, current); err != nil {
|
||||||
return aggregate, err
|
return aggregate, err
|
||||||
}
|
}
|
||||||
@@ -324,7 +341,7 @@ func (e *Engine) MonitorUntil(ctx context.Context, order domain.Order, cfg Monit
|
|||||||
return aggregate, nil
|
return aggregate, nil
|
||||||
}
|
}
|
||||||
shouldRepost := cfg.RepostAfter > 0 &&
|
shouldRepost := cfg.RepostAfter > 0 &&
|
||||||
time.Since(lastPost) >= cfg.RepostAfter &&
|
e.nowUTC().Sub(lastPost) >= cfg.RepostAfter &&
|
||||||
current.AttemptNo < cfg.MaxAttempts &&
|
current.AttemptNo < cfg.MaxAttempts &&
|
||||||
aggregate.FilledLots < aggregate.QuantityLots &&
|
aggregate.FilledLots < aggregate.QuantityLots &&
|
||||||
cfg.Quote != nil
|
cfg.Quote != nil
|
||||||
@@ -337,13 +354,11 @@ func (e *Engine) MonitorUntil(ctx context.Context, order domain.Order, cfg Monit
|
|||||||
current = next
|
current = next
|
||||||
seen[current.ClientOrderID] = current
|
seen[current.ClientOrderID] = current
|
||||||
}
|
}
|
||||||
lastPost = time.Now()
|
lastPost = e.nowUTC()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
select {
|
if !e.sleep(ctx, cfg.PollInterval) {
|
||||||
case <-ctx.Done():
|
|
||||||
return aggregate, ctx.Err()
|
return aggregate, ctx.Err()
|
||||||
case <-ticker.C:
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,7 +387,7 @@ func (e *Engine) MonitorOnce(ctx context.Context, order domain.Order, cfg Monito
|
|||||||
if isTerminal(current.Status) {
|
if isTerminal(current.Status) {
|
||||||
return aggregate, nil
|
return aggregate, nil
|
||||||
}
|
}
|
||||||
if !cfg.Deadline.IsZero() && !time.Now().Before(cfg.Deadline) {
|
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
||||||
if err := e.Cancel(ctx, current); err != nil {
|
if err := e.Cancel(ctx, current); err != nil {
|
||||||
return aggregate, err
|
return aggregate, err
|
||||||
}
|
}
|
||||||
@@ -385,7 +400,7 @@ func (e *Engine) MonitorOnce(ctx context.Context, order domain.Order, cfg Monito
|
|||||||
return aggregate, nil
|
return aggregate, nil
|
||||||
}
|
}
|
||||||
shouldRepost := cfg.RepostAfter > 0 &&
|
shouldRepost := cfg.RepostAfter > 0 &&
|
||||||
repostDue(current, cfg.RepostAfter) &&
|
e.repostDue(current, cfg.RepostAfter) &&
|
||||||
current.AttemptNo < cfg.MaxAttempts &&
|
current.AttemptNo < cfg.MaxAttempts &&
|
||||||
aggregate.FilledLots < aggregate.QuantityLots &&
|
aggregate.FilledLots < aggregate.QuantityLots &&
|
||||||
cfg.Quote != nil
|
cfg.Quote != nil
|
||||||
@@ -409,7 +424,7 @@ func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConf
|
|||||||
if err := e.ensureRepostBudget(ctx, order, cfg.Instrument); err != nil {
|
if err := e.ensureRepostBudget(ctx, order, cfg.Instrument); err != nil {
|
||||||
return domain.Order{}, false, err
|
return domain.Order{}, false, err
|
||||||
}
|
}
|
||||||
if !cfg.Deadline.IsZero() && !time.Now().Before(cfg.Deadline) {
|
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
||||||
return order, false, nil
|
return order, false, nil
|
||||||
}
|
}
|
||||||
book, err := cfg.Quote(ctx, order.InstrumentUID)
|
book, err := cfg.Quote(ctx, order.InstrumentUID)
|
||||||
@@ -432,7 +447,7 @@ func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConf
|
|||||||
cancelled.Status = domain.OrderStatusFilled
|
cancelled.Status = domain.OrderStatusFilled
|
||||||
return cancelled, true, nil
|
return cancelled, true, nil
|
||||||
}
|
}
|
||||||
if !cfg.Deadline.IsZero() && !time.Now().Before(cfg.Deadline) {
|
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
||||||
return cancelled, true, nil
|
return cancelled, true, nil
|
||||||
}
|
}
|
||||||
book, err = cfg.Quote(ctx, order.InstrumentUID)
|
book, err = cfg.Quote(ctx, order.InstrumentUID)
|
||||||
@@ -440,7 +455,7 @@ func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConf
|
|||||||
return domain.Order{}, false, err
|
return domain.Order{}, false, err
|
||||||
}
|
}
|
||||||
if cfg.RepostCheck != nil {
|
if cfg.RepostCheck != nil {
|
||||||
if err := cfg.RepostCheck(ctx, order, cfg.Instrument, book); err != nil {
|
if err := cfg.RepostCheck(ctx, cancelled, cfg.Instrument, book); err != nil {
|
||||||
return cancelled, true, nil
|
return cancelled, true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,25 +483,16 @@ func (e *Engine) waitTerminal(ctx context.Context, order domain.Order, cfg Monit
|
|||||||
if isTerminal(current.Status) {
|
if isTerminal(current.Status) {
|
||||||
return current, nil
|
return current, nil
|
||||||
}
|
}
|
||||||
if !cfg.Deadline.IsZero() && !time.Now().Before(cfg.Deadline) {
|
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
||||||
return current, nil
|
return current, nil
|
||||||
}
|
}
|
||||||
timer := time.NewTimer(cfg.PollInterval)
|
if !e.sleep(ctx, cfg.PollInterval) {
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
if !timer.Stop() {
|
|
||||||
select {
|
|
||||||
case <-timer.C:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return domain.Order{}, ctx.Err()
|
return domain.Order{}, ctx.Err()
|
||||||
case <-timer.C:
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func repostDue(order domain.Order, after time.Duration) bool {
|
func (e *Engine) repostDue(order domain.Order, after time.Duration) bool {
|
||||||
if after <= 0 {
|
if after <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -497,7 +503,7 @@ func repostDue(order domain.Order, after time.Duration) bool {
|
|||||||
if basis.IsZero() {
|
if basis.IsZero() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return time.Since(basis) >= after
|
return e.nowUTC().Sub(basis) >= after
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) ensureRepostBudget(ctx context.Context, order domain.Order, instrument domain.Instrument) error {
|
func (e *Engine) ensureRepostBudget(ctx context.Context, order domain.Order, instrument domain.Instrument) error {
|
||||||
@@ -530,7 +536,7 @@ func (e *Engine) checkQuoteFresh(book domain.OrderBook) error {
|
|||||||
if book.ReceivedAt.IsZero() {
|
if book.ReceivedAt.IsZero() {
|
||||||
return fmt.Errorf("quote received timestamp is missing")
|
return fmt.Errorf("quote received timestamp is missing")
|
||||||
}
|
}
|
||||||
age := time.Since(book.ReceivedAt)
|
age := e.nowUTC().Sub(book.ReceivedAt)
|
||||||
if age > e.maxQuoteAge {
|
if age > e.maxQuoteAge {
|
||||||
return fmt.Errorf("quote age %s exceeds %s", age, e.maxQuoteAge)
|
return fmt.Errorf("quote age %s exceeds %s", age, e.maxQuoteAge)
|
||||||
}
|
}
|
||||||
@@ -541,11 +547,29 @@ func (e *Engine) lockFor(instrumentUID string) *sync.Mutex {
|
|||||||
value, _ := e.mu.LoadOrStore(instrumentUID, &sync.Mutex{})
|
value, _ := e.mu.LoadOrStore(instrumentUID, &sync.Mutex{})
|
||||||
lock, ok := value.(*sync.Mutex)
|
lock, ok := value.(*sync.Mutex)
|
||||||
if !ok {
|
if !ok {
|
||||||
panic("execution lock has unexpected type")
|
lock = &sync.Mutex{}
|
||||||
|
e.mu.Store(instrumentUID, lock)
|
||||||
}
|
}
|
||||||
return lock
|
return lock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) nowUTC() time.Time {
|
||||||
|
if e.clock == nil {
|
||||||
|
return time.Now().UTC()
|
||||||
|
}
|
||||||
|
return e.clock.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) sleep(ctx context.Context, d time.Duration) bool {
|
||||||
|
if d <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if e.clock == nil {
|
||||||
|
return timeutil.RealClock{}.Sleep(ctx.Done(), d)
|
||||||
|
}
|
||||||
|
return e.clock.Sleep(ctx.Done(), d)
|
||||||
|
}
|
||||||
|
|
||||||
func bestBidAsk(book domain.OrderBook) (decimal.Decimal, decimal.Decimal, error) {
|
func bestBidAsk(book domain.OrderBook) (decimal.Decimal, decimal.Decimal, error) {
|
||||||
bid, ok := book.BestBid()
|
bid, ok := book.BestBid()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -2,16 +2,30 @@ package execution
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
|
|
||||||
"overnight-trading-bot/internal/domain"
|
"overnight-trading-bot/internal/domain"
|
||||||
|
"overnight-trading-bot/internal/risk"
|
||||||
"overnight-trading-bot/internal/testutil"
|
"overnight-trading-bot/internal/testutil"
|
||||||
"overnight-trading-bot/internal/tinvest"
|
"overnight-trading-bot/internal/tinvest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type fixedClock struct {
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fixedClock) Now() time.Time {
|
||||||
|
return c.now
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fixedClock) Sleep(<-chan struct{}, time.Duration) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func TestClientOrderIDIncludesAttempt(t *testing.T) {
|
func TestClientOrderIDIncludesAttempt(t *testing.T) {
|
||||||
date := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
|
date := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
|
||||||
first := ClientOrderID(date, "uid:TRUR", domain.SideBuy, 1)
|
first := ClientOrderID(date, "uid:TRUR", domain.SideBuy, 1)
|
||||||
@@ -66,6 +80,86 @@ func TestPlaceLimitSuppressesDuplicateSubmit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlaceEntryReservesFreeOrderBudgetAtomically(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := testutil.NewMemoryRepository()
|
||||||
|
gateway := tinvest.NewFakeGateway()
|
||||||
|
engine := NewEngine(domain.ModeSandbox, "account", gateway, repo)
|
||||||
|
instrument := domain.Instrument{
|
||||||
|
InstrumentUID: "uid",
|
||||||
|
Lot: 1,
|
||||||
|
MinPriceIncrement: decimal.NewFromInt(1),
|
||||||
|
FreeOrderLimitPerDay: 1,
|
||||||
|
}
|
||||||
|
book := domain.OrderBook{
|
||||||
|
InstrumentUID: "uid",
|
||||||
|
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
|
||||||
|
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}},
|
||||||
|
ReceivedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
|
||||||
|
if _, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 1, book, 1, 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 1, book, 1, 2)
|
||||||
|
if !errors.Is(err, risk.ErrFreeOrderBudget) {
|
||||||
|
t.Fatalf("expected free order budget error, got %v", err)
|
||||||
|
}
|
||||||
|
if got := len(gateway.Orders); got != 1 {
|
||||||
|
t.Fatalf("broker orders=%d, want no second post", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitorOnceUsesInjectedClockForDeadline(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := testutil.NewMemoryRepository()
|
||||||
|
gateway := tinvest.NewFakeGateway()
|
||||||
|
engine := NewEngine(domain.ModeSandbox, "account", gateway, repo)
|
||||||
|
clock := &fixedClock{now: time.Date(2030, 1, 1, 10, 0, 0, 0, time.UTC)}
|
||||||
|
engine.SetClock(clock)
|
||||||
|
order, err := engine.PlaceLimit(ctx, domain.Order{
|
||||||
|
ClientOrderID: "clocked",
|
||||||
|
AccountIDHash: "hash",
|
||||||
|
InstrumentUID: "uid",
|
||||||
|
TradeDate: clock.now,
|
||||||
|
Side: domain.SideBuy,
|
||||||
|
OrderType: domain.OrderTypeLimit,
|
||||||
|
LimitPrice: decimal.NewFromInt(100),
|
||||||
|
QuantityLots: 1,
|
||||||
|
Status: domain.OrderStatusNew,
|
||||||
|
AttemptNo: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !order.CreatedAt.Equal(clock.now) {
|
||||||
|
t.Fatalf("created_at=%s, want injected clock %s", order.CreatedAt, clock.now)
|
||||||
|
}
|
||||||
|
monitored, err := engine.MonitorOnce(ctx, order, MonitorConfig{
|
||||||
|
Deadline: clock.now.Add(time.Minute),
|
||||||
|
PollInterval: time.Millisecond,
|
||||||
|
MaxAttempts: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if monitored.Status == domain.OrderStatusExpired {
|
||||||
|
t.Fatalf("order expired before injected deadline: %+v", monitored)
|
||||||
|
}
|
||||||
|
clock.now = clock.now.Add(time.Minute)
|
||||||
|
monitored, err = engine.MonitorOnce(ctx, order, MonitorConfig{
|
||||||
|
Deadline: clock.now,
|
||||||
|
PollInterval: time.Millisecond,
|
||||||
|
MaxAttempts: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if monitored.Status != domain.OrderStatusExpired {
|
||||||
|
t.Fatalf("status=%s, want EXPIRED at injected deadline", monitored.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPaperPlaceEntryFillsAndCountsSubmittedOrder(t *testing.T) {
|
func TestPaperPlaceEntryFillsAndCountsSubmittedOrder(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := testutil.NewMemoryRepository()
|
repo := testutil.NewMemoryRepository()
|
||||||
|
|||||||
@@ -109,14 +109,14 @@ func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate ti
|
|||||||
}
|
}
|
||||||
short := Rolling(overnight, cfg.RollingShort, cfg.EWMALambda)
|
short := Rolling(overnight, cfg.RollingShort, cfg.EWMALambda)
|
||||||
long := Rolling(overnight, cfg.RollingLong, cfg.EWMALambda)
|
long := Rolling(overnight, cfg.RollingLong, cfg.EWMALambda)
|
||||||
|
q05Abs := rollingQ05Abs(overnight, cfg.RollingShort)
|
||||||
adv := ADV(candles, instrument.Lot, 20)
|
adv := ADV(candles, instrument.Lot, 20)
|
||||||
rawEdgeBps := decimal.NewFromFloat(short.Mean).Mul(decimal.NewFromInt(10_000))
|
rawEdgeBps := decimal.NewFromFloat(short.Mean).Mul(decimal.NewFromInt(10_000))
|
||||||
instrumentCommission := instrument.ExpectedCommissionBpsPerSide.Mul(decimal.NewFromInt(2))
|
commission := roundTripCommissionBps(instrument, cfg)
|
||||||
expectedCost := spread.SpreadBps.
|
expectedCost := spread.SpreadBps.
|
||||||
Add(cfg.EntrySlippageBps).
|
Add(cfg.EntrySlippageBps).
|
||||||
Add(cfg.ExitSlippageBps).
|
Add(cfg.ExitSlippageBps).
|
||||||
Add(cfg.CommissionRoundtripBps).
|
Add(commission).
|
||||||
Add(instrumentCommission).
|
|
||||||
Add(cfg.RiskBufferBps)
|
Add(cfg.RiskBufferBps)
|
||||||
return domain.FeatureSet{
|
return domain.FeatureSet{
|
||||||
InstrumentUID: instrument.InstrumentUID,
|
InstrumentUID: instrument.InstrumentUID,
|
||||||
@@ -126,6 +126,7 @@ func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate ti
|
|||||||
MuOn60: decimal.NewFromFloat(short.Mean),
|
MuOn60: decimal.NewFromFloat(short.Mean),
|
||||||
MuOn252: decimal.NewFromFloat(long.Mean),
|
MuOn252: decimal.NewFromFloat(long.Mean),
|
||||||
SigmaOn60: decimal.NewFromFloat(short.StdDev),
|
SigmaOn60: decimal.NewFromFloat(short.StdDev),
|
||||||
|
Q05On60Abs: q05Abs,
|
||||||
TStatOn60: decimal.NewFromFloat(short.TStat),
|
TStatOn60: decimal.NewFromFloat(short.TStat),
|
||||||
WinOn60: decimal.NewFromFloat(short.WinRate),
|
WinOn60: decimal.NewFromFloat(short.WinRate),
|
||||||
EWMAOn: decimal.NewFromFloat(short.EWMA),
|
EWMAOn: decimal.NewFromFloat(short.EWMA),
|
||||||
@@ -141,6 +142,26 @@ func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate ti
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rollingQ05Abs(values []float64, window int) decimal.Decimal {
|
||||||
|
if window <= 0 || len(values) < window {
|
||||||
|
return decimal.Zero
|
||||||
|
}
|
||||||
|
sample := values[len(values)-window:]
|
||||||
|
q05 := decimal.NewFromFloat(Quantile(sample, 0.05))
|
||||||
|
if q05.IsNegative() {
|
||||||
|
return q05.Neg()
|
||||||
|
}
|
||||||
|
return q05
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundTripCommissionBps(instrument domain.Instrument, cfg PipelineConfig) decimal.Decimal {
|
||||||
|
instrumentCommission := instrument.ExpectedCommissionBpsPerSide.Mul(decimal.NewFromInt(2))
|
||||||
|
if instrumentCommission.IsPositive() {
|
||||||
|
return instrumentCommission
|
||||||
|
}
|
||||||
|
return cfg.CommissionRoundtripBps
|
||||||
|
}
|
||||||
|
|
||||||
func historicalDailyCandles(candles []domain.Candle, tradeDate time.Time) []domain.Candle {
|
func historicalDailyCandles(candles []domain.Candle, tradeDate time.Time) []domain.Candle {
|
||||||
tradeDay := dateOnly(tradeDate)
|
tradeDay := dateOnly(tradeDate)
|
||||||
out := make([]domain.Candle, 0, len(candles))
|
out := make([]domain.Candle, 0, len(candles))
|
||||||
|
|||||||
@@ -41,14 +41,93 @@ func TestComputeExpectedCostIncludesCommissionAndSlippage(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !got.ExpectedCostBps.Equal(decimal.NewFromInt(26)) {
|
if !got.ExpectedCostBps.Equal(decimal.NewFromInt(22)) {
|
||||||
t.Fatalf("expected cost=%s, want 26", got.ExpectedCostBps)
|
t.Fatalf("expected cost=%s, want 22", got.ExpectedCostBps)
|
||||||
}
|
}
|
||||||
if !got.EntryIntervalVolume.Equal(decimal.NewFromInt(10000)) || !got.ExitIntervalVolume.Equal(decimal.NewFromInt(9000)) {
|
if !got.EntryIntervalVolume.Equal(decimal.NewFromInt(10000)) || !got.ExitIntervalVolume.Equal(decimal.NewFromInt(9000)) {
|
||||||
t.Fatalf("interval volumes were not preserved: %+v", got)
|
t.Fatalf("interval volumes were not preserved: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestComputeExpectedCostFallsBackToConfigCommission(t *testing.T) {
|
||||||
|
candles := flatCandles(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 6)
|
||||||
|
got, err := Compute(domain.Instrument{
|
||||||
|
InstrumentUID: "uid",
|
||||||
|
Lot: 1,
|
||||||
|
}, candles, candles[5].TradeDate, SpreadResult{SpreadBps: decimal.NewFromInt(10)}, PipelineConfig{
|
||||||
|
RollingShort: 2,
|
||||||
|
RollingLong: 2,
|
||||||
|
EWMALambda: 0.08,
|
||||||
|
RiskBufferBps: decimal.NewFromInt(5),
|
||||||
|
EntrySlippageBps: decimal.NewFromInt(2),
|
||||||
|
ExitSlippageBps: decimal.NewFromInt(3),
|
||||||
|
CommissionRoundtripBps: decimal.NewFromInt(4),
|
||||||
|
}, decimal.Zero, decimal.Zero)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.ExpectedCostBps.Equal(decimal.NewFromInt(24)) {
|
||||||
|
t.Fatalf("expected cost=%s, want 24", got.ExpectedCostBps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeStoresHistoricalQ05Abs(t *testing.T) {
|
||||||
|
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
returns := []string{"-0.10", "0.01", "0.02", "0.03", "0.04"}
|
||||||
|
candles := []domain.Candle{{
|
||||||
|
InstrumentUID: "uid",
|
||||||
|
TradeDate: start,
|
||||||
|
Open: decimal.NewFromInt(100),
|
||||||
|
Close: decimal.NewFromInt(100),
|
||||||
|
VolumeLots: decimal.NewFromInt(1),
|
||||||
|
}}
|
||||||
|
for i, raw := range returns {
|
||||||
|
r, err := decimal.NewFromString(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
open := decimal.NewFromInt(100).Mul(decimal.NewFromInt(1).Add(r))
|
||||||
|
candles = append(candles, domain.Candle{
|
||||||
|
InstrumentUID: "uid",
|
||||||
|
TradeDate: start.AddDate(0, 0, i+1),
|
||||||
|
Open: open,
|
||||||
|
Close: decimal.NewFromInt(100),
|
||||||
|
VolumeLots: decimal.NewFromInt(1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
got, err := Compute(domain.Instrument{InstrumentUID: "uid", Lot: 1}, candles, start.AddDate(0, 0, 6), SpreadResult{}, PipelineConfig{
|
||||||
|
RollingShort: 5,
|
||||||
|
RollingLong: 5,
|
||||||
|
EWMALambda: 0.08,
|
||||||
|
}, decimal.Zero, decimal.Zero)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := decimal.NewFromFloat(0.078)
|
||||||
|
diff := got.Q05On60Abs.Sub(want)
|
||||||
|
if diff.IsNegative() {
|
||||||
|
diff = diff.Neg()
|
||||||
|
}
|
||||||
|
if diff.GreaterThan(decimal.NewFromFloat(0.000001)) {
|
||||||
|
t.Fatalf("Q05On60Abs=%s, want about %s", got.Q05On60Abs, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flatCandles(start time.Time, count int) []domain.Candle {
|
||||||
|
candles := make([]domain.Candle, 0, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
price := decimal.NewFromInt(int64(100 + i))
|
||||||
|
candles = append(candles, domain.Candle{
|
||||||
|
InstrumentUID: "uid",
|
||||||
|
TradeDate: start.AddDate(0, 0, i),
|
||||||
|
Open: price,
|
||||||
|
Close: price,
|
||||||
|
VolumeLots: decimal.NewFromInt(1000),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return candles
|
||||||
|
}
|
||||||
|
|
||||||
func TestIntervalVolume(t *testing.T) {
|
func TestIntervalVolume(t *testing.T) {
|
||||||
got := IntervalVolume([]domain.Candle{
|
got := IntervalVolume([]domain.Candle{
|
||||||
{Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(10)},
|
{Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(10)},
|
||||||
|
|||||||
@@ -7,16 +7,24 @@ import (
|
|||||||
|
|
||||||
"overnight-trading-bot/internal/domain"
|
"overnight-trading-bot/internal/domain"
|
||||||
"overnight-trading-bot/internal/repository"
|
"overnight-trading-bot/internal/repository"
|
||||||
|
"overnight-trading-bot/internal/timeutil"
|
||||||
"overnight-trading-bot/internal/tinvest"
|
"overnight-trading-bot/internal/tinvest"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Loader struct {
|
type Loader struct {
|
||||||
repo repository.Repository
|
repo repository.Repository
|
||||||
gateway tinvest.Gateway
|
gateway tinvest.Gateway
|
||||||
|
clock timeutil.Clock
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLoader(repo repository.Repository, gateway tinvest.Gateway) Loader {
|
func NewLoader(repo repository.Repository, gateway tinvest.Gateway) Loader {
|
||||||
return Loader{repo: repo, gateway: gateway}
|
return Loader{repo: repo, gateway: gateway, clock: timeutil.RealClock{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loader) SetClock(clock timeutil.Clock) {
|
||||||
|
if clock != nil {
|
||||||
|
l.clock = clock
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l Loader) BackfillDaily(ctx context.Context, instruments []domain.Instrument, from, to time.Time) error {
|
func (l Loader) BackfillDaily(ctx context.Context, instruments []domain.Instrument, from, to time.Time) error {
|
||||||
@@ -59,9 +67,16 @@ func (l Loader) LatestQuote(ctx context.Context, instrumentUID string, depth int
|
|||||||
if book.ReceivedAt.IsZero() {
|
if book.ReceivedAt.IsZero() {
|
||||||
return domain.OrderBook{}, fmt.Errorf("quote received timestamp is missing")
|
return domain.OrderBook{}, fmt.Errorf("quote received timestamp is missing")
|
||||||
}
|
}
|
||||||
age := time.Since(book.ReceivedAt)
|
age := l.nowUTC().Sub(book.ReceivedAt)
|
||||||
if maxAge > 0 && age > maxAge {
|
if maxAge > 0 && age > maxAge {
|
||||||
return domain.OrderBook{}, fmt.Errorf("quote age %s exceeds %s", age, maxAge)
|
return domain.OrderBook{}, fmt.Errorf("quote age %s exceeds %s", age, maxAge)
|
||||||
}
|
}
|
||||||
return book, nil
|
return book, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l Loader) nowUTC() time.Time {
|
||||||
|
if l.clock == nil {
|
||||||
|
return time.Now().UTC()
|
||||||
|
}
|
||||||
|
return l.clock.Now().UTC()
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrInvalidTick = errors.New("tick must be positive")
|
ErrInvalidTick = errors.New("tick must be positive")
|
||||||
ErrInvalidBase = errors.New("base must be positive")
|
ErrInvalidBase = errors.New("base must be positive")
|
||||||
|
ErrInvalidQuotation = errors.New("decimal cannot be represented as protobuf quotation")
|
||||||
)
|
)
|
||||||
|
|
||||||
type RoundMode int
|
type RoundMode int
|
||||||
@@ -28,7 +29,7 @@ func QuotationToDecimal(q *pb.Quotation) decimal.Decimal {
|
|||||||
return decimal.NewFromInt(q.GetUnits()).Add(decimal.New(int64(q.GetNano()), -9))
|
return decimal.NewFromInt(q.GetUnits()).Add(decimal.New(int64(q.GetNano()), -9))
|
||||||
}
|
}
|
||||||
|
|
||||||
func DecimalToQuotation(d decimal.Decimal) *pb.Quotation {
|
func DecimalToQuotation(d decimal.Decimal) (*pb.Quotation, error) {
|
||||||
units := d.Truncate(0)
|
units := d.Truncate(0)
|
||||||
nano := d.Sub(units).Mul(decimal.NewFromInt(1_000_000_000)).Round(0)
|
nano := d.Sub(units).Mul(decimal.NewFromInt(1_000_000_000)).Round(0)
|
||||||
if nano.Equal(decimal.NewFromInt(1_000_000_000)) {
|
if nano.Equal(decimal.NewFromInt(1_000_000_000)) {
|
||||||
@@ -41,12 +42,12 @@ func DecimalToQuotation(d decimal.Decimal) *pb.Quotation {
|
|||||||
}
|
}
|
||||||
nanoPart := nano.IntPart()
|
nanoPart := nano.IntPart()
|
||||||
if nanoPart < -999_999_999 || nanoPart > 999_999_999 {
|
if nanoPart < -999_999_999 || nanoPart > 999_999_999 {
|
||||||
panic("decimal quotation nano is out of protobuf range")
|
return nil, ErrInvalidQuotation
|
||||||
}
|
}
|
||||||
return &pb.Quotation{
|
return &pb.Quotation{
|
||||||
Units: units.IntPart(),
|
Units: units.IntPart(),
|
||||||
Nano: int32(nanoPart), // #nosec G115 -- nanoPart is bounded above.
|
Nano: int32(nanoPart), // #nosec G115 -- nanoPart is bounded above.
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func MoneyValueToDecimal(v *pb.MoneyValue) decimal.Decimal {
|
func MoneyValueToDecimal(v *pb.MoneyValue) decimal.Decimal {
|
||||||
|
|||||||
@@ -37,3 +37,18 @@ func TestRoundToTick(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecimalToQuotationHandlesRoundingCarry(t *testing.T) {
|
||||||
|
tooPrecise := d("0.0000000005")
|
||||||
|
if _, err := DecimalToQuotation(tooPrecise); err != nil {
|
||||||
|
t.Fatalf("roundable quotation returned error: %v", err)
|
||||||
|
}
|
||||||
|
hugeNano := d("0.9999999996")
|
||||||
|
got, err := DecimalToQuotation(hugeNano)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("carry quotation returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got.Units != 1 || got.Nano != 0 {
|
||||||
|
t.Fatalf("quotation=%+v, want carry to 1/0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (m Manager) OnEntryFill(ctx context.Context, accountIDHash string, instrume
|
|||||||
Lot: lot,
|
Lot: lot,
|
||||||
AvgBuyPrice: order.AvgFillPrice,
|
AvgBuyPrice: order.AvgFillPrice,
|
||||||
CommissionTotal: order.Commission,
|
CommissionTotal: order.Commission,
|
||||||
Status: domain.PositionHoldingOvernight,
|
Status: domain.PositionEntryFilled,
|
||||||
OpenedAt: &now,
|
OpenedAt: &now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ func TestOnEntryFillKeepsBuyCommission(t *testing.T) {
|
|||||||
if !pos.CommissionTotal.Equal(decimal.NewFromInt(3)) {
|
if !pos.CommissionTotal.Equal(decimal.NewFromInt(3)) {
|
||||||
t.Fatalf("commission=%s, want 3", pos.CommissionTotal)
|
t.Fatalf("commission=%s, want 3", pos.CommissionTotal)
|
||||||
}
|
}
|
||||||
|
if pos.Status != domain.PositionEntryFilled {
|
||||||
|
t.Fatalf("status=%s, want ENTRY_FILLED", pos.Status)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOnExitFillPartialUsesExecutedLots(t *testing.T) {
|
func TestOnExitFillPartialUsesExecutedLots(t *testing.T) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"overnight-trading-bot/internal/domain"
|
"overnight-trading-bot/internal/domain"
|
||||||
"overnight-trading-bot/internal/money"
|
"overnight-trading-bot/internal/money"
|
||||||
"overnight-trading-bot/internal/repository"
|
"overnight-trading-bot/internal/repository"
|
||||||
|
"overnight-trading-bot/internal/timeutil"
|
||||||
"overnight-trading-bot/internal/tinvest"
|
"overnight-trading-bot/internal/tinvest"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ type Engine struct {
|
|||||||
commissionTolerance decimal.Decimal
|
commissionTolerance decimal.Decimal
|
||||||
requireZeroCommission bool
|
requireZeroCommission bool
|
||||||
quarantineOnNonZero bool
|
quarantineOnNonZero bool
|
||||||
|
clock timeutil.Clock
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(repo repository.Repository, gateway tinvest.Gateway, accountID, accountIDHash string) Engine {
|
func New(repo repository.Repository, gateway tinvest.Gateway, accountID, accountIDHash string) Engine {
|
||||||
@@ -40,6 +42,7 @@ func New(repo repository.Repository, gateway tinvest.Gateway, accountID, account
|
|||||||
accountIDHash: accountIDHash,
|
accountIDHash: accountIDHash,
|
||||||
window: 72 * time.Hour,
|
window: 72 * time.Hour,
|
||||||
commissionTolerance: defaultCommissionTolerance,
|
commissionTolerance: defaultCommissionTolerance,
|
||||||
|
clock: timeutil.RealClock{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +69,13 @@ func (e Engine) WithCommissionPolicy(requireZero, quarantineOnNonZero bool, tole
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e Engine) WithClock(clock timeutil.Clock) Engine {
|
||||||
|
if clock != nil {
|
||||||
|
e.clock = clock
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
func (e Engine) Run(ctx context.Context) ([]domain.ReconciliationDiff, error) {
|
func (e Engine) Run(ctx context.Context) ([]domain.ReconciliationDiff, error) {
|
||||||
if e.mu != nil {
|
if e.mu != nil {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
@@ -79,7 +89,7 @@ func (e Engine) Run(ctx context.Context) ([]domain.ReconciliationDiff, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := e.nowUTC()
|
||||||
localByBroker := make(map[string]domain.Order, len(localOrders))
|
localByBroker := make(map[string]domain.Order, len(localOrders))
|
||||||
brokerByID := make(map[string]domain.Order, len(brokerOrders))
|
brokerByID := make(map[string]domain.Order, len(brokerOrders))
|
||||||
for _, order := range localOrders {
|
for _, order := range localOrders {
|
||||||
@@ -175,7 +185,7 @@ func (e Engine) Run(ctx context.Context) ([]domain.ReconciliationDiff, error) {
|
|||||||
}
|
}
|
||||||
if err := e.repo.QuarantineInstrument(ctx, diff.InstrumentUID, diff.Message); err != nil {
|
if err := e.repo.QuarantineInstrument(ctx, diff.InstrumentUID, diff.Message); err != nil {
|
||||||
_ = e.repo.InsertRiskEvent(ctx, domain.RiskEvent{
|
_ = e.repo.InsertRiskEvent(ctx, domain.RiskEvent{
|
||||||
TS: time.Now().UTC(),
|
TS: now,
|
||||||
Severity: domain.SeverityCritical,
|
Severity: domain.SeverityCritical,
|
||||||
EventType: "quarantine_failed",
|
EventType: "quarantine_failed",
|
||||||
InstrumentUID: diff.InstrumentUID,
|
InstrumentUID: diff.InstrumentUID,
|
||||||
@@ -192,6 +202,13 @@ func (e Engine) Run(ctx context.Context) ([]domain.ReconciliationDiff, error) {
|
|||||||
return diffs, nil
|
return diffs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e Engine) nowUTC() time.Time {
|
||||||
|
if e.clock == nil {
|
||||||
|
return time.Now().UTC()
|
||||||
|
}
|
||||||
|
return e.clock.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
func (e Engine) isInFlight(order domain.Order, now time.Time) bool {
|
func (e Engine) isInFlight(order domain.Order, now time.Time) bool {
|
||||||
if e.inFlightGrace <= 0 || order.CreatedAt.IsZero() {
|
if e.inFlightGrace <= 0 || order.CreatedAt.IsZero() {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -171,6 +171,50 @@ func TestReconciliationSkipsFreshInFlightLocalOrders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReconciliationUsesInjectedClockForInFlightGrace(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := testutil.NewMemoryRepository()
|
||||||
|
gateway := tinvest.NewFakeGateway()
|
||||||
|
clock := &fixedClock{now: time.Date(2000, 1, 1, 10, 0, 0, 0, time.UTC)}
|
||||||
|
if err := repo.UpsertOrder(ctx, domain.Order{
|
||||||
|
ClientOrderID: "fresh",
|
||||||
|
AccountIDHash: "hash",
|
||||||
|
InstrumentUID: "uid",
|
||||||
|
TradeDate: clock.now,
|
||||||
|
Side: domain.SideBuy,
|
||||||
|
OrderType: domain.OrderTypeLimit,
|
||||||
|
QuantityLots: 1,
|
||||||
|
Status: domain.OrderStatusSent,
|
||||||
|
CreatedAt: clock.now.Add(-5 * time.Second),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
diffs, err := New(repo, gateway, "account", "hash").
|
||||||
|
WithClock(clock).
|
||||||
|
WithInFlightGrace(10 * time.Second).
|
||||||
|
Run(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, diff := range diffs {
|
||||||
|
if diff.Kind == "local_order_without_broker_id" || diff.Kind == "missing_local_order" {
|
||||||
|
t.Fatalf("fresh in-flight order produced diff: %+v", diffs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixedClock struct {
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fixedClock) Now() time.Time {
|
||||||
|
return c.now
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fixedClock) Sleep(<-chan struct{}, time.Duration) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func TestReconciliationFindsCashMismatch(t *testing.T) {
|
func TestReconciliationFindsCashMismatch(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := testutil.NewMemoryRepository()
|
repo := testutil.NewMemoryRepository()
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE features DROP COLUMN q05_on_60_abs;
|
||||||
|
|
||||||
|
UPDATE schema_meta SET meta_value='0006' WHERE meta_key='schema_version';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE features
|
||||||
|
ADD COLUMN q05_on_60_abs DECIMAL(20,10) NOT NULL DEFAULT 0 AFTER sigma_on_60;
|
||||||
|
|
||||||
|
UPDATE schema_meta SET meta_value='0007' WHERE meta_key='schema_version';
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"overnight-trading-bot/internal/domain"
|
"overnight-trading-bot/internal/domain"
|
||||||
"overnight-trading-bot/internal/repository"
|
"overnight-trading-bot/internal/repository"
|
||||||
|
"overnight-trading-bot/internal/risk"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ repository.Repository = (*Repository)(nil)
|
var _ repository.Repository = (*Repository)(nil)
|
||||||
@@ -224,13 +225,13 @@ ON DUPLICATE KEY UPDATE
|
|||||||
func (r *Repository) mergeFeatures(ctx context.Context, oldInstrumentUID, newInstrumentUID string) error {
|
func (r *Repository) mergeFeatures(ctx context.Context, oldInstrumentUID, newInstrumentUID string) error {
|
||||||
_, err := r.execer().ExecContext(ctx, `
|
_, err := r.execer().ExecContext(ctx, `
|
||||||
INSERT INTO features (
|
INSERT INTO features (
|
||||||
instrument_uid, trade_date, r_on, r_day, mu_on_60, mu_on_252, sigma_on_60,
|
instrument_uid, trade_date, r_on, r_day, mu_on_60, mu_on_252, sigma_on_60, q05_on_60_abs,
|
||||||
tstat_on_60, win_on_60, ewma_on, spread_bps, half_spread_bps, tick_bps,
|
tstat_on_60, win_on_60, ewma_on, spread_bps, half_spread_bps, tick_bps,
|
||||||
adv_20, expected_cost_bps, net_edge_bps, entry_interval_volume,
|
adv_20, expected_cost_bps, net_edge_bps, entry_interval_volume,
|
||||||
exit_interval_volume, calculated_at
|
exit_interval_volume, calculated_at
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
?, trade_date, r_on, r_day, mu_on_60, mu_on_252, sigma_on_60,
|
?, trade_date, r_on, r_day, mu_on_60, mu_on_252, sigma_on_60, q05_on_60_abs,
|
||||||
tstat_on_60, win_on_60, ewma_on, spread_bps, half_spread_bps, tick_bps,
|
tstat_on_60, win_on_60, ewma_on, spread_bps, half_spread_bps, tick_bps,
|
||||||
adv_20, expected_cost_bps, net_edge_bps, entry_interval_volume,
|
adv_20, expected_cost_bps, net_edge_bps, entry_interval_volume,
|
||||||
exit_interval_volume, calculated_at
|
exit_interval_volume, calculated_at
|
||||||
@@ -238,7 +239,7 @@ FROM features WHERE instrument_uid=?
|
|||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
r_on=VALUES(r_on), r_day=VALUES(r_day), mu_on_60=VALUES(mu_on_60),
|
r_on=VALUES(r_on), r_day=VALUES(r_day), mu_on_60=VALUES(mu_on_60),
|
||||||
mu_on_252=VALUES(mu_on_252), sigma_on_60=VALUES(sigma_on_60),
|
mu_on_252=VALUES(mu_on_252), sigma_on_60=VALUES(sigma_on_60),
|
||||||
tstat_on_60=VALUES(tstat_on_60), win_on_60=VALUES(win_on_60),
|
q05_on_60_abs=VALUES(q05_on_60_abs), tstat_on_60=VALUES(tstat_on_60), win_on_60=VALUES(win_on_60),
|
||||||
ewma_on=VALUES(ewma_on), spread_bps=VALUES(spread_bps),
|
ewma_on=VALUES(ewma_on), spread_bps=VALUES(spread_bps),
|
||||||
half_spread_bps=VALUES(half_spread_bps), tick_bps=VALUES(tick_bps),
|
half_spread_bps=VALUES(half_spread_bps), tick_bps=VALUES(tick_bps),
|
||||||
adv_20=VALUES(adv_20), expected_cost_bps=VALUES(expected_cost_bps),
|
adv_20=VALUES(adv_20), expected_cost_bps=VALUES(expected_cost_bps),
|
||||||
@@ -385,19 +386,19 @@ func (r *Repository) UpsertFeature(ctx context.Context, feature domain.FeatureSe
|
|||||||
}
|
}
|
||||||
_, err := sqlx.NamedExecContext(ctx, r.execer(), `
|
_, err := sqlx.NamedExecContext(ctx, r.execer(), `
|
||||||
INSERT INTO features (
|
INSERT INTO features (
|
||||||
instrument_uid, trade_date, r_on, r_day, mu_on_60, mu_on_252, sigma_on_60,
|
instrument_uid, trade_date, r_on, r_day, mu_on_60, mu_on_252, sigma_on_60, q05_on_60_abs,
|
||||||
tstat_on_60, win_on_60, ewma_on, spread_bps, half_spread_bps, tick_bps,
|
tstat_on_60, win_on_60, ewma_on, spread_bps, half_spread_bps, tick_bps,
|
||||||
adv_20, expected_cost_bps, net_edge_bps, entry_interval_volume,
|
adv_20, expected_cost_bps, net_edge_bps, entry_interval_volume,
|
||||||
exit_interval_volume, calculated_at
|
exit_interval_volume, calculated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:instrument_uid, :trade_date, :r_on, :r_day, :mu_on_60, :mu_on_252, :sigma_on_60,
|
:instrument_uid, :trade_date, :r_on, :r_day, :mu_on_60, :mu_on_252, :sigma_on_60, :q05_on_60_abs,
|
||||||
:tstat_on_60, :win_on_60, :ewma_on, :spread_bps, :half_spread_bps, :tick_bps,
|
:tstat_on_60, :win_on_60, :ewma_on, :spread_bps, :half_spread_bps, :tick_bps,
|
||||||
:adv_20, :expected_cost_bps, :net_edge_bps, :entry_interval_volume,
|
:adv_20, :expected_cost_bps, :net_edge_bps, :entry_interval_volume,
|
||||||
:exit_interval_volume, :calculated_at
|
:exit_interval_volume, :calculated_at
|
||||||
) ON DUPLICATE KEY UPDATE
|
) ON DUPLICATE KEY UPDATE
|
||||||
r_on=VALUES(r_on), r_day=VALUES(r_day), mu_on_60=VALUES(mu_on_60),
|
r_on=VALUES(r_on), r_day=VALUES(r_day), mu_on_60=VALUES(mu_on_60),
|
||||||
mu_on_252=VALUES(mu_on_252), sigma_on_60=VALUES(sigma_on_60),
|
mu_on_252=VALUES(mu_on_252), sigma_on_60=VALUES(sigma_on_60),
|
||||||
tstat_on_60=VALUES(tstat_on_60), win_on_60=VALUES(win_on_60),
|
q05_on_60_abs=VALUES(q05_on_60_abs), tstat_on_60=VALUES(tstat_on_60), win_on_60=VALUES(win_on_60),
|
||||||
ewma_on=VALUES(ewma_on), spread_bps=VALUES(spread_bps),
|
ewma_on=VALUES(ewma_on), spread_bps=VALUES(spread_bps),
|
||||||
half_spread_bps=VALUES(half_spread_bps), tick_bps=VALUES(tick_bps),
|
half_spread_bps=VALUES(half_spread_bps), tick_bps=VALUES(tick_bps),
|
||||||
adv_20=VALUES(adv_20), expected_cost_bps=VALUES(expected_cost_bps),
|
adv_20=VALUES(adv_20), expected_cost_bps=VALUES(expected_cost_bps),
|
||||||
@@ -453,7 +454,9 @@ func (r *Repository) UpsertOrder(ctx context.Context, order domain.Order) error
|
|||||||
if order.CreatedAt.IsZero() {
|
if order.CreatedAt.IsZero() {
|
||||||
order.CreatedAt = now
|
order.CreatedAt = now
|
||||||
}
|
}
|
||||||
|
if order.UpdatedAt.IsZero() {
|
||||||
order.UpdatedAt = now
|
order.UpdatedAt = now
|
||||||
|
}
|
||||||
if order.RawStateJSON == "" {
|
if order.RawStateJSON == "" {
|
||||||
order.RawStateJSON = "{}"
|
order.RawStateJSON = "{}"
|
||||||
}
|
}
|
||||||
@@ -596,6 +599,47 @@ ON DUPLICATE KEY UPDATE orders_sent=orders_sent+VALUES(orders_sent)`, dateOnly(t
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ReserveFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error {
|
||||||
|
if delta <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
return r.IncrementFreeOrders(ctx, tradeDate, instrumentUID, delta)
|
||||||
|
}
|
||||||
|
return r.RunInTx(ctx, func(ctx context.Context, repo repository.Repository) error {
|
||||||
|
txRepo, ok := repo.(*Repository)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("unexpected repository implementation")
|
||||||
|
}
|
||||||
|
return txRepo.reserveFreeOrdersLocked(ctx, tradeDate, instrumentUID, delta, limit)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) reserveFreeOrdersLocked(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error {
|
||||||
|
tradeDay := dateOnly(tradeDate)
|
||||||
|
if _, err := r.execer().ExecContext(ctx, `
|
||||||
|
INSERT IGNORE INTO free_order_counters (trade_date, instrument_uid, orders_sent)
|
||||||
|
VALUES (?, ?, 0)`, tradeDay, instrumentUID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var sent int
|
||||||
|
if err := r.getContext(ctx, &sent, `
|
||||||
|
SELECT orders_sent FROM free_order_counters
|
||||||
|
WHERE trade_date=? AND instrument_uid=?
|
||||||
|
FOR UPDATE`, tradeDay, instrumentUID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
remaining := limit - sent
|
||||||
|
if remaining < delta {
|
||||||
|
return fmt.Errorf("%w: %s remaining=%d needed=%d", risk.ErrFreeOrderBudget, instrumentUID, remaining, delta)
|
||||||
|
}
|
||||||
|
_, err := r.execer().ExecContext(ctx, `
|
||||||
|
UPDATE free_order_counters
|
||||||
|
SET orders_sent=orders_sent+?
|
||||||
|
WHERE trade_date=? AND instrument_uid=?`, delta, tradeDay, instrumentUID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) GetSystemState(ctx context.Context) (domain.SystemState, bool, string, error) {
|
func (r *Repository) GetSystemState(ctx context.Context) (domain.SystemState, bool, string, error) {
|
||||||
var row struct {
|
var row struct {
|
||||||
State string `db:"state"`
|
State string `db:"state"`
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type featureRow struct {
|
|||||||
MuOn60 decimal.Decimal `db:"mu_on_60"`
|
MuOn60 decimal.Decimal `db:"mu_on_60"`
|
||||||
MuOn252 decimal.Decimal `db:"mu_on_252"`
|
MuOn252 decimal.Decimal `db:"mu_on_252"`
|
||||||
SigmaOn60 decimal.Decimal `db:"sigma_on_60"`
|
SigmaOn60 decimal.Decimal `db:"sigma_on_60"`
|
||||||
|
Q05On60Abs decimal.Decimal `db:"q05_on_60_abs"`
|
||||||
TStatOn60 decimal.Decimal `db:"tstat_on_60"`
|
TStatOn60 decimal.Decimal `db:"tstat_on_60"`
|
||||||
WinOn60 decimal.Decimal `db:"win_on_60"`
|
WinOn60 decimal.Decimal `db:"win_on_60"`
|
||||||
EWMAOn decimal.Decimal `db:"ewma_on"`
|
EWMAOn decimal.Decimal `db:"ewma_on"`
|
||||||
@@ -80,6 +81,7 @@ func featureRowFromDomain(feature domain.FeatureSet) featureRow {
|
|||||||
MuOn60: feature.MuOn60,
|
MuOn60: feature.MuOn60,
|
||||||
MuOn252: feature.MuOn252,
|
MuOn252: feature.MuOn252,
|
||||||
SigmaOn60: feature.SigmaOn60,
|
SigmaOn60: feature.SigmaOn60,
|
||||||
|
Q05On60Abs: feature.Q05On60Abs,
|
||||||
TStatOn60: feature.TStatOn60,
|
TStatOn60: feature.TStatOn60,
|
||||||
WinOn60: feature.WinOn60,
|
WinOn60: feature.WinOn60,
|
||||||
EWMAOn: feature.EWMAOn,
|
EWMAOn: feature.EWMAOn,
|
||||||
@@ -104,6 +106,7 @@ func (r featureRow) domain() domain.FeatureSet {
|
|||||||
MuOn60: r.MuOn60,
|
MuOn60: r.MuOn60,
|
||||||
MuOn252: r.MuOn252,
|
MuOn252: r.MuOn252,
|
||||||
SigmaOn60: r.SigmaOn60,
|
SigmaOn60: r.SigmaOn60,
|
||||||
|
Q05On60Abs: r.Q05On60Abs,
|
||||||
TStatOn60: r.TStatOn60,
|
TStatOn60: r.TStatOn60,
|
||||||
WinOn60: r.WinOn60,
|
WinOn60: r.WinOn60,
|
||||||
EWMAOn: r.EWMAOn,
|
EWMAOn: r.EWMAOn,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Repository interface {
|
|||||||
InsertRiskEvent(ctx context.Context, event domain.RiskEvent) error
|
InsertRiskEvent(ctx context.Context, event domain.RiskEvent) error
|
||||||
GetFreeOrdersSent(ctx context.Context, tradeDate time.Time, instrumentUID string) (int, error)
|
GetFreeOrdersSent(ctx context.Context, tradeDate time.Time, instrumentUID string) (int, error)
|
||||||
IncrementFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int) error
|
IncrementFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int) error
|
||||||
|
ReserveFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error
|
||||||
|
|
||||||
GetSystemState(ctx context.Context) (domain.SystemState, bool, string, error)
|
GetSystemState(ctx context.Context) (domain.SystemState, bool, string, error)
|
||||||
SaveSystemState(ctx context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, contextJSON string) error
|
SaveSystemState(ctx context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, contextJSON string) error
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ func (s *Scheduler) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s Scheduler) GracefulShutdown(ctx context.Context) error {
|
||||||
|
if s.svc.Repo == nil || s.svc.Execution == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.cancelActiveOrders(ctx, domain.SideBuy, domain.OrderStatusCancelled, "shutdown_cancel_active_orders"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.cancelActiveOrders(ctx, domain.SideSell, domain.OrderStatusCancelled, "shutdown_cancel_active_orders")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Scheduler) Step(ctx context.Context) error {
|
func (s *Scheduler) Step(ctx context.Context) error {
|
||||||
if err := s.checkInfrastructure(ctx); err != nil {
|
if err := s.checkInfrastructure(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -370,7 +380,7 @@ func (s Scheduler) sizeSignal(portfolio domain.Portfolio, instrument domain.Inst
|
|||||||
Lot: instrument.Lot,
|
Lot: instrument.Lot,
|
||||||
EntryIntervalVolume: feature.EntryIntervalVolume,
|
EntryIntervalVolume: feature.EntryIntervalVolume,
|
||||||
ExitIntervalVolume: feature.ExitIntervalVolume,
|
ExitIntervalVolume: feature.ExitIntervalVolume,
|
||||||
Q05OvernightAbs: money.Abs(feature.SigmaOn60).Mul(decimal.NewFromFloat(1.65)),
|
Q05OvernightAbs: feature.Q05On60Abs,
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +554,7 @@ func (s *Scheduler) monitorEntryOrders(ctx context.Context, now time.Time) error
|
|||||||
return s.svc.MarketData.LatestQuote(ctx, instrumentUID, s.cfg.QuoteDepth, s.cfg.MaxQuoteAge)
|
return s.svc.MarketData.LatestQuote(ctx, instrumentUID, s.cfg.QuoteDepth, s.cfg.MaxQuoteAge)
|
||||||
},
|
},
|
||||||
RepostCheck: func(ctx context.Context, order domain.Order, instrument domain.Instrument, book domain.OrderBook) error {
|
RepostCheck: func(ctx context.Context, order domain.Order, instrument domain.Instrument, book domain.OrderBook) error {
|
||||||
return s.repostPreTradeCheck(ctx, now, order, instrument, book)
|
return s.repostPreTradeCheck(ctx, s.nowUTC().In(s.cfg.Location), order, instrument, book)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -570,9 +580,31 @@ func (s *Scheduler) holdOvernight(ctx context.Context) error {
|
|||||||
if err := s.closeEntryWindow(ctx); err != nil {
|
if err := s.closeEntryWindow(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.promoteEntryFilledPositions(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.periodicReconcile(ctx)
|
return s.periodicReconcile(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s Scheduler) promoteEntryFilledPositions(ctx context.Context) error {
|
||||||
|
positionsList, err := s.svc.Repo.ListOpenPositions(ctx, s.svc.AccountIDHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := s.nowUTC()
|
||||||
|
for _, pos := range positionsList {
|
||||||
|
if pos.Status != domain.PositionEntryFilled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pos.Status = domain.PositionHoldingOvernight
|
||||||
|
pos.UpdatedAt = now
|
||||||
|
if err := s.svc.Repo.UpsertPosition(ctx, pos); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Scheduler) placeExitOrders(ctx context.Context, now time.Time) error {
|
func (s *Scheduler) placeExitOrders(ctx context.Context, now time.Time) error {
|
||||||
if err := s.transitionTo(ctx, domain.StatePlaceExitOrders); err != nil {
|
if err := s.transitionTo(ctx, domain.StatePlaceExitOrders); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -694,7 +726,7 @@ func (s *Scheduler) monitorExitOrders(ctx context.Context, now time.Time) error
|
|||||||
return s.svc.MarketData.LatestQuote(ctx, instrumentUID, s.cfg.QuoteDepth, s.cfg.MaxQuoteAge)
|
return s.svc.MarketData.LatestQuote(ctx, instrumentUID, s.cfg.QuoteDepth, s.cfg.MaxQuoteAge)
|
||||||
},
|
},
|
||||||
RepostCheck: func(ctx context.Context, order domain.Order, instrument domain.Instrument, book domain.OrderBook) error {
|
RepostCheck: func(ctx context.Context, order domain.Order, instrument domain.Instrument, book domain.OrderBook) error {
|
||||||
return s.repostPreTradeCheck(ctx, now, order, instrument, book)
|
return s.repostPreTradeCheck(ctx, s.nowUTC().In(s.cfg.Location), order, instrument, book)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1080,7 +1112,7 @@ func (s *Scheduler) failOpenPositionsAtHardDeadline(ctx context.Context) error {
|
|||||||
now := s.nowUTC()
|
now := s.nowUTC()
|
||||||
for _, pos := range positionsList {
|
for _, pos := range positionsList {
|
||||||
switch pos.Status {
|
switch pos.Status {
|
||||||
case domain.PositionHoldingOvernight, domain.PositionExitPartiallyFilled, domain.PositionExitOrderSent:
|
case domain.PositionEntryFilled, domain.PositionHoldingOvernight, domain.PositionExitPartiallyFilled, domain.PositionExitOrderSent:
|
||||||
pos.Status = domain.PositionExitFailed
|
pos.Status = domain.PositionExitFailed
|
||||||
pos.UpdatedAt = now
|
pos.UpdatedAt = now
|
||||||
if err := s.svc.Repo.UpsertPosition(ctx, pos); err != nil {
|
if err := s.svc.Repo.UpsertPosition(ctx, pos); err != nil {
|
||||||
|
|||||||
@@ -650,6 +650,49 @@ func TestPlaceExitUsesCurrentTradeDateForOrderAndFreeCounter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGracefulShutdownCancelsActiveOrders(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := testutil.NewMemoryRepository()
|
||||||
|
gateway := tinvest.NewFakeGateway()
|
||||||
|
tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
|
||||||
|
order := domain.Order{
|
||||||
|
ClientOrderID: "shutdown-order",
|
||||||
|
BrokerOrderID: "broker-shutdown-order",
|
||||||
|
AccountIDHash: "hash",
|
||||||
|
InstrumentUID: "uid",
|
||||||
|
TradeDate: tradeDate,
|
||||||
|
Side: domain.SideBuy,
|
||||||
|
OrderType: domain.OrderTypeLimit,
|
||||||
|
LimitPrice: decimal.NewFromInt(100),
|
||||||
|
QuantityLots: 1,
|
||||||
|
Status: domain.OrderStatusSent,
|
||||||
|
RawStateJSON: "{}",
|
||||||
|
}
|
||||||
|
if err := repo.UpsertOrder(ctx, order); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gateway.Orders[order.BrokerOrderID] = order
|
||||||
|
execEngine := execution.NewEngine(domain.ModeSandbox, "account", gateway, repo)
|
||||||
|
s := Scheduler{
|
||||||
|
cfg: Config{Mode: domain.ModeSandbox},
|
||||||
|
svc: Services{
|
||||||
|
Repo: repo,
|
||||||
|
Execution: &execEngine,
|
||||||
|
AccountIDHash: "hash",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := s.GracefulShutdown(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
orders, err := repo.ListOrders(ctx, "hash", tradeDate, tradeDate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(orders) != 1 || orders[0].Status != domain.OrderStatusCancelled {
|
||||||
|
t.Fatalf("orders=%+v, want cancelled", orders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustTOD(raw string) timeutil.TimeOfDay {
|
func mustTOD(raw string) timeutil.TimeOfDay {
|
||||||
tod, err := timeutil.ParseTimeOfDay(raw)
|
tod, err := timeutil.ParseTimeOfDay(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"overnight-trading-bot/internal/domain"
|
"overnight-trading-bot/internal/domain"
|
||||||
"overnight-trading-bot/internal/repository"
|
"overnight-trading-bot/internal/repository"
|
||||||
|
"overnight-trading-bot/internal/risk"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ repository.Repository = (*MemoryRepository)(nil)
|
var _ repository.Repository = (*MemoryRepository)(nil)
|
||||||
@@ -263,6 +264,20 @@ func (r *MemoryRepository) IncrementFreeOrders(_ context.Context, tradeDate time
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) ReserveFreeOrders(_ context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if delta <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := featureKey(instrumentUID, tradeDate)
|
||||||
|
if limit > 0 && r.FreeOrders[key]+delta > limit {
|
||||||
|
return risk.ErrFreeOrderBudget
|
||||||
|
}
|
||||||
|
r.FreeOrders[key] += delta
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MemoryRepository) GetSystemState(_ context.Context) (domain.SystemState, bool, string, error) {
|
func (r *MemoryRepository) GetSystemState(_ context.Context) (domain.SystemState, bool, string, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|||||||
@@ -177,11 +177,15 @@ func (g *RealGateway) PostLimitOrder(ctx context.Context, accountID, instrumentU
|
|||||||
if side == domain.SideSell {
|
if side == domain.SideSell {
|
||||||
direction = pb.OrderDirection_ORDER_DIRECTION_SELL
|
direction = pb.OrderDirection_ORDER_DIRECTION_SELL
|
||||||
}
|
}
|
||||||
|
quotation, err := money.DecimalToQuotation(price)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Order{}, err
|
||||||
|
}
|
||||||
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PostOrderResponse, error) {
|
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PostOrderResponse, error) {
|
||||||
return g.orders.PostOrder(&investgo.PostOrderRequest{
|
return g.orders.PostOrder(&investgo.PostOrderRequest{
|
||||||
InstrumentId: instrumentUID,
|
InstrumentId: instrumentUID,
|
||||||
Quantity: lots,
|
Quantity: lots,
|
||||||
Price: money.DecimalToQuotation(price),
|
Price: quotation,
|
||||||
Direction: direction,
|
Direction: direction,
|
||||||
AccountId: accountID,
|
AccountId: accountID,
|
||||||
OrderType: pb.OrderType_ORDER_TYPE_LIMIT,
|
OrderType: pb.OrderType_ORDER_TYPE_LIMIT,
|
||||||
|
|||||||
@@ -39,11 +39,15 @@ func (g *SandboxGateway) PostLimitOrder(ctx context.Context, accountID, instrume
|
|||||||
if side == domain.SideSell {
|
if side == domain.SideSell {
|
||||||
direction = pb.OrderDirection_ORDER_DIRECTION_SELL
|
direction = pb.OrderDirection_ORDER_DIRECTION_SELL
|
||||||
}
|
}
|
||||||
|
quotation, err := money.DecimalToQuotation(price)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Order{}, err
|
||||||
|
}
|
||||||
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PostOrderResponse, error) {
|
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PostOrderResponse, error) {
|
||||||
return g.sandbox.PostSandboxOrder(&investgo.PostOrderRequest{
|
return g.sandbox.PostSandboxOrder(&investgo.PostOrderRequest{
|
||||||
InstrumentId: instrumentUID,
|
InstrumentId: instrumentUID,
|
||||||
Quantity: lots,
|
Quantity: lots,
|
||||||
Price: money.DecimalToQuotation(price),
|
Price: quotation,
|
||||||
Direction: direction,
|
Direction: direction,
|
||||||
AccountId: accountID,
|
AccountId: accountID,
|
||||||
OrderType: pb.OrderType_ORDER_TYPE_LIMIT,
|
OrderType: pb.OrderType_ORDER_TYPE_LIMIT,
|
||||||
|
|||||||
Reference in New Issue
Block a user