Files
overnight-trading-bot/internal/app/app.go
T

479 lines
16 KiB
Go
Raw Normal View History

2026-06-06 22:19:21 +00:00
package app
import (
"context"
2026-06-07 21:01:40 +00:00
"crypto/sha256"
"database/sql"
"encoding/hex"
2026-06-06 22:19:21 +00:00
"errors"
"fmt"
"io"
2026-06-07 21:01:40 +00:00
"log/slog"
"net/url"
2026-06-06 22:19:21 +00:00
"os"
2026-06-07 21:01:40 +00:00
"os/signal"
"strings"
"syscall"
"time"
"github.com/jmoiron/sqlx"
2026-06-08 07:05:01 +00:00
"github.com/shopspring/decimal"
2026-06-07 21:01:40 +00:00
"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"
2026-06-06 22:19:21 +00:00
)
type Options struct {
2026-06-07 21:01:40 +00:00
Stdout io.Writer
Stderr io.Writer
ModeOverride string
2026-06-08 09:41:20 +00:00
Halt bool
2026-06-07 21:01:40 +00:00
Unhalt bool
Reason string
Healthcheck bool
HealthcheckURL string
RunOnce bool
2026-06-06 22:19:21 +00:00
}
func Run(ctx context.Context, opts Options) error {
if err := ctx.Err(); err != nil {
return err
}
2026-06-07 21:01:40 +00:00
if opts.Healthcheck {
target := opts.HealthcheckURL
if target == "" {
2026-06-08 14:25:44 +00:00
target = "http://127.0.0.1:3300/ready"
2026-06-07 21:01:40 +00:00
}
return healthcheck.CheckEndpoint(ctx, target)
2026-06-06 22:19:21 +00:00
}
if opts.Stdout == nil {
opts.Stdout = io.Discard
}
2026-06-07 21:01:40 +00:00
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)
2026-06-06 22:19:21 +00:00
2026-06-08 09:41:20 +00:00
if opts.Halt && opts.Unhalt {
return errors.New("-halt and -unhalt are mutually exclusive")
}
if cfg.App.Mode == domain.ModeBacktest && !opts.Unhalt && !opts.Halt {
2026-06-07 21:01:40 +00:00
_, _ = fmt.Fprintf(opts.Stdout, "overnight trading bot initialized in %s mode\n", cfg.App.Mode)
return nil
}
2026-06-08 09:41:20 +00:00
if opts.Halt && cfg.DB.DSN == "" {
return errors.New("-halt requires DB_DSN")
}
2026-06-07 21:01:40 +00:00
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)
2026-06-08 09:41:20 +00:00
if opts.Halt {
if strings.TrimSpace(opts.Reason) == "" {
return errors.New("-halt requires -reason")
}
if err := risk.NewManager(repo, risk.ManagerConfig{}).Halt(ctx, cfg.App.Mode, "manual_halt", opts.Reason, ""); err != nil {
return err
}
_, _ = fmt.Fprintf(opts.Stdout, "system halted: %s\n", opts.Reason)
return nil
}
2026-06-07 21:01:40 +00:00
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)
2026-06-08 09:03:37 +00:00
clock := timeutil.RealClock{Loc: cfg.Location}
2026-06-07 21:01:40 +00:00
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
2026-06-07 21:51:20 +00:00
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours)*time.Hour).
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec)*time.Second).
2026-06-08 09:03:37 +00:00
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB).
WithClock(clock)
2026-06-07 21:01:40 +00:00
diffs, err := recon.Run(ctx)
if err != nil {
return fmt.Errorf("pre-unhalt reconciliation: %w", err)
2026-06-06 22:19:21 +00:00
}
2026-06-07 21:01:40 +00:00
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
}
2026-06-06 22:19:21 +00:00
2026-06-07 21:01:40 +00:00
gateway, closer, err := buildGateway(ctx, cfg, log)
if err != nil {
return err
}
if closer != nil {
defer closer()
}
2026-06-08 07:05:01 +00:00
if err := seedPaperGateway(ctx, repo, gateway); err != nil {
return err
}
2026-06-07 21:01:40 +00:00
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)
2026-06-06 22:19:21 +00:00
}
2026-06-07 21:01:40 +00:00
defer func() {
_ = notifier.Close()
}()
accountIDHash := accountHash(cfg.TInvest.AccountID)
2026-06-08 09:03:37 +00:00
clock := timeutil.RealClock{Loc: cfg.Location}
2026-06-07 21:01:40 +00:00
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
2026-06-07 21:51:20 +00:00
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours)*time.Hour).
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec)*time.Second).
2026-06-08 09:03:37 +00:00
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB).
WithClock(clock)
2026-06-07 21:01:40 +00:00
sm := statemachine.New(repo, cfg.App.Mode)
if _, err := sm.Recover(ctx, recon); err != nil {
2026-06-08 07:05:01 +00:00
_ = notifier.Alert(ctx, fmt.Sprintf("state recovery failed: %s", err))
return fmt.Errorf("state recovery: %w", err)
2026-06-07 21:01:40 +00:00
}
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()
runtime := buildScheduler(clock, sm, cfg, repo, gateway, notifier, recon, accountIDHash, log)
2026-06-08 09:03:37 +00:00
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
2026-06-07 21:01:40 +00:00
}
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)
2026-06-08 09:03:37 +00:00
loader.SetClock(clock)
2026-06-07 21:01:40 +00:00
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,
},
2026-06-07 21:51:20 +00:00
IntervalVolumeLookback: 20,
Location: cfg.Location,
2026-06-07 21:01:40 +00:00
})
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)
2026-06-08 09:03:37 +00:00
execEngine.SetClock(clock)
2026-06-07 21:01:40 +00:00
execEngine.SetMaxQuoteAge(time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second)
2026-06-08 07:36:52 +00:00
execEngine.SetFreeOrderCountPolicy(cfg.Commission.FreeOrderCountPolicy)
2026-06-07 21:01:40 +00:00
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{
2026-06-07 21:51:20 +00:00
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,
2026-06-08 07:36:52 +00:00
ExitNotBefore: cfg.Execution.ExitNotBefore,
2026-06-07 21:51:20 +00:00
ExitWindowStart: cfg.Execution.ExitWindowStart,
ExitWindowEnd: cfg.Execution.ExitWindowEnd,
HardExitDeadline: cfg.Execution.HardExitDeadline,
2026-06-08 07:05:01 +00:00
MarketClose: cfg.Execution.MarketClose,
2026-06-07 21:51:20 +00:00
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,
RequireZeroCommission: cfg.Commission.RequireZeroCommission,
QuarantineOnNonZero: cfg.Commission.QuarantineOnNonZero,
2026-06-08 07:36:52 +00:00
FreeOrderCountPolicy: cfg.Commission.FreeOrderCountPolicy,
2026-06-07 21:51:20 +00:00
ReconciliationInterval: 5 * time.Minute,
2026-06-08 07:05:01 +00:00
MaxOpenPositions: minPositive(cfg.Strategy.MaxPositions, cfg.Risk.MaxOpenPositions),
2026-06-07 21:01:40 +00:00
}, services)
}
2026-06-08 07:05:01 +00:00
func minPositive(a, b int) int {
switch {
case a <= 0:
return b
case b <= 0:
return a
case a < b:
return a
default:
return b
}
}
2026-06-07 21:01:40 +00:00
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:
2026-06-08 09:41:20 +00:00
if cfg.TInvest.Token != "" {
accountID := cfg.TInvest.AccountID
if accountID == "" {
accountID = "paper-readonly"
}
gw, err := tinvest.NewRealGateway(ctx, tinvest.Options{
Token: cfg.TInvest.Token,
AccountID: accountID,
Endpoint: cfg.TInvest.Endpoint,
AppName: cfg.TInvest.AppName,
RequestTimeout: time.Duration(cfg.TInvest.RequestTimeoutSec) * time.Second,
RetryCount: cfg.TInvest.RetryCount,
RetryBackoff: time.Duration(cfg.TInvest.RetryBackoffSec) * time.Second,
Logger: log,
})
if err != nil {
return nil, nil, err
}
return tinvest.NewPaperGateway(gw), func() { _ = gw.Close() }, nil
}
2026-06-07 21:01:40 +00:00
return tinvest.NewFakeGateway(), nil, nil
case domain.ModeSandbox:
gw, err := tinvest.NewSandboxGateway(ctx, tinvest.Options{
2026-06-08 09:41:20 +00:00
Token: cfg.TInvest.Token,
AccountID: cfg.TInvest.AccountID,
AppName: cfg.TInvest.AppName,
RequestTimeout: time.Duration(cfg.TInvest.RequestTimeoutSec) * time.Second,
RetryCount: cfg.TInvest.RetryCount,
RetryBackoff: time.Duration(cfg.TInvest.RetryBackoffSec) * time.Second,
Logger: log,
2026-06-07 21:01:40 +00:00
})
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{
2026-06-08 09:41:20 +00:00
Token: cfg.TInvest.Token,
AccountID: cfg.TInvest.AccountID,
Endpoint: endpoint,
AppName: cfg.TInvest.AppName,
RequestTimeout: time.Duration(cfg.TInvest.RequestTimeoutSec) * time.Second,
RetryCount: cfg.TInvest.RetryCount,
RetryBackoff: time.Duration(cfg.TInvest.RetryBackoffSec) * time.Second,
Logger: log,
2026-06-07 21:01:40 +00:00
})
if err != nil {
return nil, nil, err
}
return gw, func() { _ = gw.Close() }, nil
default:
return tinvest.NewFakeGateway(), nil, nil
}
}
2026-06-08 07:05:01 +00:00
func seedPaperGateway(ctx context.Context, repo interface {
ListInstruments(context.Context, bool) ([]domain.Instrument, error)
}, gateway tinvest.Gateway) error {
fake, ok := gateway.(*tinvest.FakeGateway)
if !ok {
2026-06-08 09:41:20 +00:00
paper, isPaper := gateway.(*tinvest.PaperGateway)
if !isPaper {
return nil
}
fake = paper.Fake()
2026-06-08 07:05:01 +00:00
}
instrumentsList, err := repo.ListInstruments(ctx, true)
if err != nil {
return err
}
for _, instrument := range instrumentsList {
remote := instrument
if remote.InstrumentUID == "" || strings.HasPrefix(remote.InstrumentUID, "PENDING:") {
remote.InstrumentUID = "paper-" + strings.ToUpper(remote.Ticker)
}
if remote.Figi == "" {
remote.Figi = remote.InstrumentUID
}
if remote.Lot <= 0 {
remote.Lot = 1
}
if !remote.MinPriceIncrement.IsPositive() {
remote.MinPriceIncrement = decimal.RequireFromString("0.01")
}
if remote.Currency == "" {
remote.Currency = "RUB"
}
remote.Enabled = true
remote.UpdatedAt = time.Now().UTC()
fake.Instruments[remote.InstrumentUID] = remote
fake.Statuses[remote.InstrumentUID] = domain.TradingStatusNormal
}
return nil
}
2026-06-07 21:01:40 +00:00
func accountHash(accountID string) string {
sum := sha256.Sum256([]byte(accountID))
return hex.EncodeToString(sum[:])
}
func HealthURL(addr string) string {
if strings.HasPrefix(addr, ":") {
2026-06-08 14:25:44 +00:00
return "http://127.0.0.1" + addr + "/ready"
2026-06-07 21:01:40 +00:00
}
if _, err := url.ParseRequestURI(addr); err == nil && strings.HasPrefix(addr, "http") {
return addr
}
2026-06-08 14:25:44 +00:00
return "http://" + addr + "/ready"
2026-06-07 21:01:40 +00:00
}
2026-06-06 22:19:21 +00:00
2026-06-07 21:01:40 +00:00
func PingDB(ctx context.Context, db *sql.DB) error {
return db.PingContext(ctx)
2026-06-06 22:19:21 +00:00
}