first version
This commit is contained in:
+328
-13
@@ -2,38 +2,353 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"overnight-trading-bot/internal/config"
|
||||
"overnight-trading-bot/internal/domain"
|
||||
"overnight-trading-bot/internal/execution"
|
||||
"overnight-trading-bot/internal/features"
|
||||
"overnight-trading-bot/internal/healthcheck"
|
||||
"overnight-trading-bot/internal/instruments"
|
||||
"overnight-trading-bot/internal/logging"
|
||||
"overnight-trading-bot/internal/marketdata"
|
||||
"overnight-trading-bot/internal/notify"
|
||||
"overnight-trading-bot/internal/position"
|
||||
"overnight-trading-bot/internal/reconciliation"
|
||||
mysqlrepo "overnight-trading-bot/internal/repository/mysql"
|
||||
"overnight-trading-bot/internal/risk"
|
||||
"overnight-trading-bot/internal/scheduler"
|
||||
signalengine "overnight-trading-bot/internal/signal"
|
||||
"overnight-trading-bot/internal/statemachine"
|
||||
"overnight-trading-bot/internal/timeutil"
|
||||
"overnight-trading-bot/internal/tinvest"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
ConfigPath string
|
||||
Stdout io.Writer
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
ModeOverride string
|
||||
Unhalt bool
|
||||
Reason string
|
||||
Healthcheck bool
|
||||
HealthcheckURL string
|
||||
RunOnce bool
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, opts Options) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.ConfigPath == "" {
|
||||
return errors.New("config path is required")
|
||||
if opts.Healthcheck {
|
||||
target := opts.HealthcheckURL
|
||||
if target == "" {
|
||||
target = "http://127.0.0.1:3300/health"
|
||||
}
|
||||
return healthcheck.CheckEndpoint(ctx, target)
|
||||
}
|
||||
|
||||
if opts.Stdout == nil {
|
||||
opts.Stdout = io.Discard
|
||||
}
|
||||
|
||||
if _, err := os.Stat(opts.ConfigPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("config file %q does not exist; copy config.example.yaml to config.yaml and fill credentials", opts.ConfigPath)
|
||||
if opts.Stderr == nil {
|
||||
opts.Stderr = io.Discard
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load ENV config: %w", err)
|
||||
}
|
||||
if opts.ModeOverride != "" {
|
||||
mode, err := domain.ParseMode(opts.ModeOverride)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.App.Mode = mode
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log := logging.New(cfg.App.LogLevel, opts.Stdout)
|
||||
log.Info("overnight trading bot starting", "mode", cfg.App.Mode)
|
||||
|
||||
return fmt.Errorf("check config file %q: %w", opts.ConfigPath, err)
|
||||
if cfg.App.Mode == domain.ModeBacktest && !opts.Unhalt {
|
||||
_, _ = fmt.Fprintf(opts.Stdout, "overnight trading bot initialized in %s mode\n", cfg.App.Mode)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.Stdout, "overnight trading bot initialized with config %q\n", opts.ConfigPath)
|
||||
return nil
|
||||
db, err := openDB(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = db.Close()
|
||||
}()
|
||||
if cfg.DB.MigrationsAutoApply {
|
||||
if err := mysqlrepo.ApplyMigrations(ctx, db.DB); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
repo := mysqlrepo.NewRepository(db)
|
||||
if opts.Unhalt {
|
||||
if strings.TrimSpace(opts.Reason) == "" {
|
||||
return errors.New("-unhalt requires -reason")
|
||||
}
|
||||
gateway, closer, err := buildGateway(ctx, cfg, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closer != nil {
|
||||
defer closer()
|
||||
}
|
||||
accountIDHash := accountHash(cfg.TInvest.AccountID)
|
||||
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
|
||||
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours) * time.Hour).
|
||||
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec) * time.Second)
|
||||
diffs, err := recon.Run(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pre-unhalt reconciliation: %w", err)
|
||||
}
|
||||
if reconciliation.HasCritical(diffs) {
|
||||
return fmt.Errorf("pre-unhalt reconciliation has critical diffs: %d", len(diffs))
|
||||
}
|
||||
if err := repo.Unhalt(ctx, opts.Reason); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(opts.Stdout, "system unhalted: %s\n", opts.Reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
gateway, closer, err := buildGateway(ctx, cfg, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closer != nil {
|
||||
defer closer()
|
||||
}
|
||||
notifier, err := notify.NewTelegram(notify.TelegramConfig{
|
||||
BotToken: cfg.Telegram.BotToken,
|
||||
ChatID: cfg.Telegram.ChatID,
|
||||
NotifyInfo: cfg.Telegram.NotifyInfo,
|
||||
NotifyWarn: cfg.Telegram.NotifyWarn,
|
||||
NotifyAlert: cfg.Telegram.NotifyAlert,
|
||||
NotifyReport: cfg.Telegram.NotifyReport,
|
||||
AuditSink: repo,
|
||||
}, log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create notifier: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = notifier.Close()
|
||||
}()
|
||||
|
||||
accountIDHash := accountHash(cfg.TInvest.AccountID)
|
||||
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
|
||||
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours) * time.Hour).
|
||||
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec) * time.Second)
|
||||
sm := statemachine.New(repo, cfg.App.Mode)
|
||||
if _, err := sm.Recover(ctx, recon); err != nil {
|
||||
log.Warn("state recovery did not resume trading", "err", err)
|
||||
}
|
||||
health := healthcheck.New(db.DB, gateway, time.Duration(cfg.Risk.MaxClockDriftSec)*time.Second)
|
||||
health.Start(cfg.App.HealthcheckAddr)
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.App.ShutdownTimeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
_ = health.Shutdown(shutdownCtx)
|
||||
}()
|
||||
if err := notifier.Info(ctx, fmt.Sprintf("bot started in %s mode", cfg.App.Mode)); err != nil {
|
||||
log.Warn("notify startup failed", "err", err)
|
||||
}
|
||||
if opts.RunOnce {
|
||||
_, _ = fmt.Fprintf(opts.Stdout, "overnight trading bot initialized in %s mode\n", cfg.App.Mode)
|
||||
return nil
|
||||
}
|
||||
runCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
clock := timeutil.RealClock{Loc: cfg.Location}
|
||||
runtime := buildScheduler(clock, sm, cfg, repo, gateway, notifier, recon, accountIDHash, log)
|
||||
return runtime.Run(runCtx)
|
||||
}
|
||||
|
||||
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)
|
||||
loader := marketdata.NewLoader(repo, gateway)
|
||||
pipeline := features.NewPipeline(repo, features.PipelineConfig{
|
||||
RollingShort: cfg.Strategy.RollingShort,
|
||||
RollingLong: cfg.Strategy.RollingLong,
|
||||
EWMALambda: cfg.Strategy.EWMALambda,
|
||||
RiskBufferBps: cfg.Strategy.RiskBufferBps,
|
||||
EntrySlippageBps: cfg.Backtest.EntrySlippageBps,
|
||||
ExitSlippageBps: cfg.Backtest.ExitSlippageBps,
|
||||
CommissionRoundtripBps: cfg.Backtest.CommissionRoundtripBps,
|
||||
EntryWindow: timeutil.Window{
|
||||
Start: cfg.Execution.EntryWindowStart,
|
||||
End: cfg.Execution.EntryWindowEnd,
|
||||
},
|
||||
ExitWindow: timeutil.Window{
|
||||
Start: cfg.Execution.ExitWindowStart,
|
||||
End: cfg.Execution.ExitWindowEnd,
|
||||
},
|
||||
Location: cfg.Location,
|
||||
})
|
||||
signalEngine := signalengine.New(signalengine.Config{
|
||||
MinTStat60: cfg.Strategy.MinTStat60,
|
||||
MinWinRate60: cfg.Strategy.MinWinRate60,
|
||||
MinNetEdgeBps: cfg.Strategy.MinNetEdgeBps,
|
||||
MinADVRUB: cfg.Liquidity.MinADVRUB,
|
||||
MaxSpreadBpsDefault: cfg.Liquidity.MaxSpreadBpsDefault,
|
||||
MaxSpreadBpsMoneyMarket: cfg.Liquidity.MaxSpreadBpsMoneyMarket,
|
||||
MaxSpreadBpsBondFunds: cfg.Liquidity.MaxSpreadBpsBondFunds,
|
||||
MaxSpreadBpsEquityFunds: cfg.Liquidity.MaxSpreadBpsEquityFunds,
|
||||
MaxTickBps: cfg.Liquidity.MaxTickBps,
|
||||
RequireZeroCommission: cfg.Commission.RequireZeroCommission,
|
||||
MaxPositions: cfg.Strategy.MaxPositions,
|
||||
})
|
||||
sizer := risk.NewSizer(risk.SizingConfig{
|
||||
MaxPositionPct: cfg.Risk.MaxPositionPct,
|
||||
MaxTotalExposurePct: cfg.Risk.MaxTotalExposurePct,
|
||||
MaxParticipationRate: cfg.Liquidity.MaxParticipationRate,
|
||||
CashUsageBuffer: cfg.Risk.CashUsageBuffer,
|
||||
RiskBudgetPerInstrumentPct: cfg.Risk.RiskBudgetPerInstrumentPct,
|
||||
MinOrderNotionalRUB: cfg.Risk.MinOrderNotionalRUB,
|
||||
})
|
||||
freeOrders := risk.NewFreeOrderBudget(repo)
|
||||
riskManager := risk.NewManager(repo, risk.ManagerConfig{
|
||||
MaxDailyLossPct: cfg.Risk.MaxDailyLossPct,
|
||||
MaxWeeklyLossPct: cfg.Risk.MaxWeeklyLossPct,
|
||||
MaxMonthlyDrawdownPct: cfg.Risk.MaxMonthlyDrawdownPct,
|
||||
MaxAvgSlippageBps10Trades: cfg.Risk.MaxAvgSlippageBps10Trades,
|
||||
MaxOpenPositions: cfg.Risk.MaxOpenPositions,
|
||||
MinTimeToClose: time.Duration(cfg.Execution.MinTimeToCloseSec) * time.Second,
|
||||
MaxQuoteAge: time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second,
|
||||
})
|
||||
execEngine := execution.NewEngine(cfg.App.Mode, cfg.TInvest.AccountID, gateway, repo)
|
||||
execEngine.SetMaxQuoteAge(time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second)
|
||||
services := scheduler.Services{
|
||||
Repo: repo,
|
||||
Gateway: gateway,
|
||||
Registry: registry,
|
||||
MarketData: loader,
|
||||
Features: pipeline,
|
||||
Signals: signalEngine,
|
||||
Sizer: sizer,
|
||||
FreeOrders: freeOrders,
|
||||
Risk: riskManager,
|
||||
Execution: &execEngine,
|
||||
Positions: position.NewManager(repo),
|
||||
Reconcile: recon,
|
||||
Notifier: notifier,
|
||||
AccountID: cfg.TInvest.AccountID,
|
||||
AccountIDHash: accountIDHash,
|
||||
Log: log,
|
||||
}
|
||||
return scheduler.New(clock, sm, scheduler.Config{
|
||||
Mode: cfg.App.Mode,
|
||||
Location: cfg.Location,
|
||||
RollingLong: cfg.Strategy.RollingLong,
|
||||
TickInterval: 30 * time.Second,
|
||||
EntrySignalTime: cfg.Execution.EntrySignalTime,
|
||||
EntryWindowStart: cfg.Execution.EntryWindowStart,
|
||||
EntryWindowEnd: cfg.Execution.EntryWindowEnd,
|
||||
NoNewEntryAfter: cfg.Execution.NoNewEntryAfter,
|
||||
ExitWatchStart: cfg.Execution.ExitWatchStart,
|
||||
ExitWindowStart: cfg.Execution.ExitWindowStart,
|
||||
ExitWindowEnd: cfg.Execution.ExitWindowEnd,
|
||||
HardExitDeadline: cfg.Execution.HardExitDeadline,
|
||||
QuoteDepth: cfg.Execution.QuoteDepth,
|
||||
MaxQuoteAge: time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second,
|
||||
OrderPollInterval: time.Duration(cfg.Execution.OrderPollIntervalMS) * time.Millisecond,
|
||||
PassiveImproveTicks: cfg.Execution.PassiveImproveTicks,
|
||||
MaxEntryOrderAttempts: cfg.Execution.MaxEntryOrderAttempts,
|
||||
MaxExitOrderAttempts: cfg.Execution.MaxExitOrderAttempts,
|
||||
MinTimeToClose: time.Duration(cfg.Execution.MinTimeToCloseSec) * time.Second,
|
||||
MaxClockDrift: time.Duration(cfg.Risk.MaxClockDriftSec) * time.Second,
|
||||
APIOutageHalt: time.Duration(cfg.Risk.APIOutageHaltSec) * time.Second,
|
||||
}, services)
|
||||
}
|
||||
|
||||
func openDB(ctx context.Context, cfg config.Config) (*sqlx.DB, error) {
|
||||
db, err := sqlx.Open("mysql", cfg.DB.DSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(cfg.DB.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.DB.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(time.Duration(cfg.DB.ConnMaxLifetimeMin) * time.Minute)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("db ping: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func buildGateway(ctx context.Context, cfg config.Config, log *slog.Logger) (tinvest.Gateway, func(), error) {
|
||||
switch cfg.App.Mode {
|
||||
case domain.ModePaper:
|
||||
return tinvest.NewFakeGateway(), nil, nil
|
||||
case domain.ModeSandbox:
|
||||
gw, err := tinvest.NewSandboxGateway(ctx, tinvest.Options{
|
||||
Token: cfg.TInvest.Token,
|
||||
AccountID: cfg.TInvest.AccountID,
|
||||
AppName: cfg.TInvest.AppName,
|
||||
RetryCount: cfg.TInvest.RetryCount,
|
||||
RetryBackoff: time.Duration(cfg.TInvest.RetryBackoffSec) * time.Second,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return gw, func() { _ = gw.Close() }, nil
|
||||
case domain.ModeLiveReadonly, domain.ModeLiveTrade:
|
||||
endpoint := cfg.TInvest.Endpoint
|
||||
if cfg.TInvest.UseSandbox {
|
||||
return nil, nil, errors.New("TINVEST_USE_SANDBOX is only allowed with APP_MODE=sandbox")
|
||||
}
|
||||
gw, err := tinvest.NewRealGateway(ctx, tinvest.Options{
|
||||
Token: cfg.TInvest.Token,
|
||||
AccountID: cfg.TInvest.AccountID,
|
||||
Endpoint: endpoint,
|
||||
AppName: cfg.TInvest.AppName,
|
||||
RetryCount: cfg.TInvest.RetryCount,
|
||||
RetryBackoff: time.Duration(cfg.TInvest.RetryBackoffSec) * time.Second,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return gw, func() { _ = gw.Close() }, nil
|
||||
default:
|
||||
return tinvest.NewFakeGateway(), nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func accountHash(accountID string) string {
|
||||
sum := sha256.Sum256([]byte(accountID))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func HealthURL(addr string) string {
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
return "http://127.0.0.1" + addr + "/health"
|
||||
}
|
||||
if _, err := url.ParseRequestURI(addr); err == nil && strings.HasPrefix(addr, "http") {
|
||||
return addr
|
||||
}
|
||||
return "http://" + addr + "/health"
|
||||
}
|
||||
|
||||
func PingDB(ctx context.Context, db *sql.DB) error {
|
||||
return db.PingContext(ctx)
|
||||
}
|
||||
|
||||
@@ -3,49 +3,29 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunRequiresConfigPath(t *testing.T) {
|
||||
err := Run(context.Background(), Options{})
|
||||
func TestRunRequiresAppMode(t *testing.T) {
|
||||
t.Setenv("APP_MODE", "")
|
||||
err := Run(context.Background(), Options{RunOnce: true})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "config path is required") {
|
||||
if !strings.Contains(err.Error(), "APP_MODE") && !strings.Contains(err.Error(), "MODE") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReportsMissingConfig(t *testing.T) {
|
||||
err := Run(context.Background(), Options{ConfigPath: "missing.yaml"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "does not exist") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsesExistingConfig(t *testing.T) {
|
||||
touch := t.TempDir() + "/config.yaml"
|
||||
if err := os.WriteFile(touch, []byte("instruments: []\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
func TestRunBacktestModeWithoutDB(t *testing.T) {
|
||||
t.Setenv("APP_MODE", "backtest")
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), Options{
|
||||
ConfigPath: touch,
|
||||
Stdout: &stdout,
|
||||
})
|
||||
err := Run(context.Background(), Options{Stdout: &stdout, RunOnce: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "initialized") {
|
||||
t.Fatalf("unexpected stdout: %q", stdout.String())
|
||||
if !strings.Contains(stdout.String(), "backtest") {
|
||||
t.Fatalf("unexpected stdout: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user