This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
@@ -72,6 +73,9 @@ func run() error {
|
||||
if *useMinuteModel && len(minuteCandles) == 0 {
|
||||
return fmt.Errorf("-minute-candles is required when -use-minute-model=true")
|
||||
}
|
||||
if err := validateMetadata(candles, metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := decimal.NewFromString(*entrySlip)
|
||||
if err != nil {
|
||||
return fmt.Errorf("entry slippage: %w", err)
|
||||
@@ -149,6 +153,20 @@ func run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMetadata(candles map[string][]domain.Candle, metadata map[string]backtest.InstrumentMetadata) error {
|
||||
var missing []string
|
||||
for instrumentUID := range candles {
|
||||
meta := metadata[instrumentUID]
|
||||
if meta.Lot <= 0 || !meta.MinPriceIncrement.IsPositive() {
|
||||
missing = append(missing, instrumentUID)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing lot/min_price_increment metadata for instruments: %s", strings.Join(missing, ","))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeMetadata(dst, src map[string]backtest.InstrumentMetadata) {
|
||||
for uid, meta := range src {
|
||||
current := dst[uid]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"overnight-trading-bot/internal/backtest"
|
||||
"overnight-trading-bot/internal/domain"
|
||||
)
|
||||
|
||||
func TestValidateMetadataRejectsMissingLotOrTick(t *testing.T) {
|
||||
candles := map[string][]domain.Candle{
|
||||
"uid": {{InstrumentUID: "uid", TradeDate: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC)}},
|
||||
}
|
||||
err := validateMetadata(candles, map[string]backtest.InstrumentMetadata{
|
||||
"uid": {Lot: 10},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing lot/min_price_increment metadata") {
|
||||
t.Fatalf("err=%v, want missing metadata error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMetadataAcceptsCompleteMetadata(t *testing.T) {
|
||||
candles := map[string][]domain.Candle{
|
||||
"uid": {{InstrumentUID: "uid", TradeDate: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC)}},
|
||||
}
|
||||
err := validateMetadata(candles, map[string]backtest.InstrumentMetadata{
|
||||
"uid": {Lot: 10, MinPriceIncrement: decimal.RequireFromString("0.01")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"overnight-trading-bot/internal/domain"
|
||||
)
|
||||
|
||||
const moscowOffset = 3 * time.Hour
|
||||
|
||||
type modeDayRow struct {
|
||||
Mode string `db:"mode"`
|
||||
Days int `db:"days"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
dsn := flag.String("dsn", os.Getenv("DB_DSN"), "MySQL/MariaDB DSN")
|
||||
fromRaw := flag.String("from", "", "optional start date YYYY-MM-DD")
|
||||
toRaw := flag.String("to", "", "optional end date YYYY-MM-DD, inclusive")
|
||||
check := flag.Bool("check", true, "fail when live readiness thresholds are not met")
|
||||
minReadonly := flag.Int("min-readonly-days", 20, "minimum live_readonly days")
|
||||
minPaper := flag.Int("min-paper-days", 20, "minimum paper days")
|
||||
minSandbox := flag.Int("min-sandbox-days", 10, "minimum sandbox days")
|
||||
flag.Parse()
|
||||
if *dsn == "" {
|
||||
return fmt.Errorf("DB_DSN is required")
|
||||
}
|
||||
from, err := parseOptionalDate(*fromRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("from: %w", err)
|
||||
}
|
||||
to, err := parseOptionalDate(*toRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("to: %w", err)
|
||||
}
|
||||
db, err := sqlx.Open("mysql", *dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = db.Close()
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("ping db: %w", err)
|
||||
}
|
||||
counts, err := loadModeDayCounts(ctx, db, from, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printCounts(counts)
|
||||
if !*check {
|
||||
return nil
|
||||
}
|
||||
thresholds := map[domain.Mode]int{
|
||||
domain.ModeLiveReadonly: *minReadonly,
|
||||
domain.ModePaper: *minPaper,
|
||||
domain.ModeSandbox: *minSandbox,
|
||||
}
|
||||
return checkThresholds(counts, thresholds)
|
||||
}
|
||||
|
||||
func loadModeDayCounts(ctx context.Context, db *sqlx.DB, from, to time.Time) (map[domain.Mode]int, error) {
|
||||
query := `SELECT mode, COUNT(DISTINCT DATE(DATE_ADD(ts, INTERVAL 3 HOUR))) AS days FROM system_state_history WHERE DAYOFWEEK(DATE_ADD(ts, INTERVAL 3 HOUR)) BETWEEN 2 AND 6`
|
||||
var args []any
|
||||
if !from.IsZero() {
|
||||
query += ` AND ts >= ?`
|
||||
args = append(args, from.Add(-moscowOffset))
|
||||
}
|
||||
if !to.IsZero() {
|
||||
query += ` AND ts < ?`
|
||||
args = append(args, to.AddDate(0, 0, 1).Add(-moscowOffset))
|
||||
}
|
||||
query += ` GROUP BY mode`
|
||||
var rows []modeDayRow
|
||||
if err := db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("query mode days: %w", err)
|
||||
}
|
||||
counts := make(map[domain.Mode]int, len(rows))
|
||||
for _, row := range rows {
|
||||
mode, err := domain.ParseMode(row.Mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts[mode] = row.Days
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func printCounts(counts map[domain.Mode]int) {
|
||||
modes := make([]string, 0, len(counts))
|
||||
for mode := range counts {
|
||||
modes = append(modes, string(mode))
|
||||
}
|
||||
sort.Strings(modes)
|
||||
for _, rawMode := range modes {
|
||||
mode := domain.Mode(rawMode)
|
||||
fmt.Printf("%s=%d\n", mode, counts[mode])
|
||||
}
|
||||
}
|
||||
|
||||
func checkThresholds(counts map[domain.Mode]int, thresholds map[domain.Mode]int) error {
|
||||
var failed []string
|
||||
for mode, threshold := range thresholds {
|
||||
if counts[mode] < threshold {
|
||||
failed = append(failed, fmt.Sprintf("%s=%d/%d", mode, counts[mode], threshold))
|
||||
}
|
||||
}
|
||||
sort.Strings(failed)
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("mode day thresholds not met: %s", strings.Join(failed, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseOptionalDate(raw string) (time.Time, error) {
|
||||
if raw == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return time.ParseInLocation("2006-01-02", raw, time.UTC)
|
||||
}
|
||||
Reference in New Issue
Block a user