Files
overnight-trading-bot/internal/config/config.go
T
2026-06-07 21:01:40 +00:00

235 lines
11 KiB
Go

package config
import (
"errors"
"fmt"
"time"
"github.com/caarlos0/env/v11"
"github.com/shopspring/decimal"
"overnight-trading-bot/internal/domain"
"overnight-trading-bot/internal/timeutil"
)
const liveTradeAck = "I_ACCEPT_RISK"
const maxQuoteDepth = 50
type Config struct {
App AppConfig `envPrefix:"APP_"`
TInvest TInvestConfig `envPrefix:"TINVEST_"`
DB DBConfig `envPrefix:"DB_"`
Telegram TelegramConfig `envPrefix:"TELEGRAM_"`
Strategy StrategyConfig `envPrefix:"STRATEGY_"`
Execution ExecutionConfig `envPrefix:"EXEC_"`
Risk RiskConfig `envPrefix:"RISK_"`
Liquidity LiquidityConfig `envPrefix:"LIQ_"`
Commission CommissionConfig `envPrefix:"COMM_"`
Backtest BacktestConfig `envPrefix:"BT_"`
Live LiveConfig `envPrefix:"LIVE_"`
Location *time.Location `env:"-"`
}
type AppConfig struct {
Mode domain.Mode `env:"MODE,required"`
Timezone string `env:"TIMEZONE" envDefault:"Europe/Moscow"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
HealthcheckAddr string `env:"HEALTHCHECK_ADDR" envDefault:":3300"`
ShutdownTimeoutSec int `env:"SHUTDOWN_TIMEOUT_SEC" envDefault:"30"`
}
type TInvestConfig struct {
Token string `env:"TOKEN"`
AccountID string `env:"ACCOUNT_ID"`
Endpoint string `env:"ENDPOINT" envDefault:"invest-public-api.tinkoff.ru:443"`
AppName string `env:"APP_NAME" envDefault:"overnight-trading-bot"`
RequestTimeoutSec int `env:"REQUEST_TIMEOUT_SEC" envDefault:"10"`
RetryCount int `env:"RETRY_COUNT" envDefault:"3"`
RetryBackoffSec int `env:"RETRY_BACKOFF_SEC" envDefault:"2"`
UseSandbox bool `env:"USE_SANDBOX" envDefault:"false"`
}
type DBConfig struct {
DSN string `env:"DSN"`
MaxOpenConns int `env:"MAX_OPEN_CONNS" envDefault:"20"`
MaxIdleConns int `env:"MAX_IDLE_CONNS" envDefault:"5"`
ConnMaxLifetimeMin int `env:"CONN_MAX_LIFETIME_MIN" envDefault:"30"`
MigrationsAutoApply bool `env:"MIGRATIONS_AUTO_APPLY" envDefault:"true"`
}
type TelegramConfig struct {
BotToken string `env:"BOT_TOKEN"`
ChatID int64 `env:"CHAT_ID"`
NotifyInfo bool `env:"NOTIFY_INFO" envDefault:"true"`
NotifyWarn bool `env:"NOTIFY_WARN" envDefault:"true"`
NotifyAlert bool `env:"NOTIFY_ALERT" envDefault:"true"`
NotifyReport bool `env:"NOTIFY_REPORT" envDefault:"true"`
}
type StrategyConfig struct {
RollingShort int `env:"ROLLING_SHORT" envDefault:"60"`
RollingLong int `env:"ROLLING_LONG" envDefault:"252"`
EWMALambda float64 `env:"EWMA_LAMBDA" envDefault:"0.08"`
MinTStat60 decimal.Decimal `env:"MIN_TSTAT_60" envDefault:"1.25"`
MinWinRate60 decimal.Decimal `env:"MIN_WIN_RATE_60" envDefault:"0.55"`
MinNetEdgeBps decimal.Decimal `env:"MIN_NET_EDGE_BPS" envDefault:"10"`
RiskBufferBps decimal.Decimal `env:"RISK_BUFFER_BPS" envDefault:"5"`
MaxPositions int `env:"MAX_POSITIONS" envDefault:"5"`
}
type ExecutionConfig struct {
EntrySignalTime timeutil.TimeOfDay `env:"ENTRY_SIGNAL_TIME" envDefault:"18:10:00"`
EntryWindowStart timeutil.TimeOfDay `env:"ENTRY_WINDOW_START" envDefault:"18:20:00"`
EntryWindowEnd timeutil.TimeOfDay `env:"ENTRY_WINDOW_END" envDefault:"18:38:30"`
NoNewEntryAfter timeutil.TimeOfDay `env:"NO_NEW_ENTRY_AFTER" envDefault:"18:38:30"`
ExitWatchStart timeutil.TimeOfDay `env:"EXIT_WATCH_START" envDefault:"09:50:00"`
ExitNotBefore timeutil.TimeOfDay `env:"EXIT_NOT_BEFORE" envDefault:"10:03:00"`
ExitWindowStart timeutil.TimeOfDay `env:"EXIT_WINDOW_START" envDefault:"10:05:00"`
ExitWindowEnd timeutil.TimeOfDay `env:"EXIT_WINDOW_END" envDefault:"10:25:00"`
HardExitDeadline timeutil.TimeOfDay `env:"HARD_EXIT_DEADLINE" envDefault:"10:45:00"`
MinTimeToCloseSec int `env:"MIN_TIME_TO_CLOSE_SEC" envDefault:"90"`
AllowMarketOrders bool `env:"ALLOW_MARKET_ORDERS" envDefault:"false"`
MaxEntryOrderAttempts int `env:"MAX_ENTRY_ORDER_ATTEMPTS" envDefault:"3"`
MaxExitOrderAttempts int `env:"MAX_EXIT_ORDER_ATTEMPTS" envDefault:"3"`
PassiveImproveTicks int `env:"PASSIVE_IMPROVE_TICKS" envDefault:"1"`
QuoteDepth int32 `env:"QUOTE_DEPTH" envDefault:"20"`
MaxQuoteAgeSec int `env:"MAX_QUOTE_AGE_SEC" envDefault:"3"`
OrderPollIntervalMS int `env:"ORDER_POLL_INTERVAL_MS" envDefault:"500"`
}
type RiskConfig struct {
UseMargin bool `env:"USE_MARGIN" envDefault:"false"`
AllowShort bool `env:"ALLOW_SHORT" envDefault:"false"`
MaxTotalExposurePct decimal.Decimal `env:"MAX_TOTAL_EXPOSURE_PCT" envDefault:"0.50"`
MaxPositionPct decimal.Decimal `env:"MAX_POSITION_PCT" envDefault:"0.10"`
MaxDailyLossPct decimal.Decimal `env:"MAX_DAILY_LOSS_PCT" envDefault:"0.01"`
MaxWeeklyLossPct decimal.Decimal `env:"MAX_WEEKLY_LOSS_PCT" envDefault:"0.03"`
MaxMonthlyDrawdownPct decimal.Decimal `env:"MAX_MONTHLY_DRAWDOWN_PCT" envDefault:"0.07"`
MaxOpenPositions int `env:"MAX_OPEN_POSITIONS" envDefault:"5"`
MaxAvgSlippageBps10Trades decimal.Decimal `env:"MAX_AVG_SLIPPAGE_BPS_10_TRADES" envDefault:"15"`
APIOutageHaltSec int `env:"API_OUTAGE_HALT_SEC" envDefault:"180"`
MaxClockDriftSec int `env:"MAX_CLOCK_DRIFT_SEC" envDefault:"2"`
ReconciliationWindowHours int `env:"RECONCILIATION_WINDOW_HOURS" envDefault:"72"`
ReconciliationSkewSec int `env:"RECONCILIATION_SKEW_SEC" envDefault:"10"`
CashUsageBuffer decimal.Decimal `env:"CASH_USAGE_BUFFER" envDefault:"0.95"`
RiskBudgetPerInstrumentPct decimal.Decimal `env:"RISK_BUDGET_PER_INSTRUMENT_PCT" envDefault:"0.005"`
MinOrderNotionalRUB decimal.Decimal `env:"MIN_ORDER_NOTIONAL_RUB" envDefault:"1000"`
}
type LiquidityConfig struct {
MinADVRUB decimal.Decimal `env:"MIN_ADV_RUB" envDefault:"5000000"`
MaxParticipationRate decimal.Decimal `env:"MAX_PARTICIPATION_RATE" envDefault:"0.01"`
MaxSpreadBpsDefault decimal.Decimal `env:"MAX_SPREAD_BPS_DEFAULT" envDefault:"20"`
MaxSpreadBpsMoneyMarket decimal.Decimal `env:"MAX_SPREAD_BPS_MONEY_MARKET" envDefault:"5"`
MaxSpreadBpsBondFunds decimal.Decimal `env:"MAX_SPREAD_BPS_BOND_FUNDS" envDefault:"10"`
MaxSpreadBpsEquityFunds decimal.Decimal `env:"MAX_SPREAD_BPS_EQUITY_FUNDS" envDefault:"25"`
MaxTickBps decimal.Decimal `env:"MAX_TICK_BPS" envDefault:"10"`
}
type CommissionConfig struct {
RequireZeroCommission bool `env:"REQUIRE_ZERO_COMMISSION" envDefault:"true"`
QuarantineOnNonZero bool `env:"QUARANTINE_ON_NONZERO" envDefault:"true"`
FreeOrderCountPolicy string `env:"FREE_ORDER_COUNT_POLICY" envDefault:"submitted"`
}
type BacktestConfig struct {
DateFrom string `env:"DATE_FROM"`
DateTo string `env:"DATE_TO"`
EntrySlippageBps decimal.Decimal `env:"ENTRY_SLIPPAGE_BPS" envDefault:"8"`
ExitSlippageBps decimal.Decimal `env:"EXIT_SLIPPAGE_BPS" envDefault:"8"`
CommissionRoundtripBps decimal.Decimal `env:"COMMISSION_ROUNDTRIP_BPS" envDefault:"0"`
UseMinuteModel bool `env:"USE_MINUTE_MODEL" envDefault:"false"`
OutputDir string `env:"OUTPUT_DIR" envDefault:"./backtest_out"`
}
type LiveConfig struct {
TradeAck string `env:"TRADE_ACK"`
}
func Load() (Config, error) {
var cfg Config
if err := env.Parse(&cfg); err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c *Config) Validate() error {
if c.App.Mode == "" {
return errors.New("APP_MODE is required")
}
loc, err := time.LoadLocation(c.App.Timezone)
if err != nil {
return fmt.Errorf("load timezone %q: %w", c.App.Timezone, err)
}
if c.App.Timezone != "Europe/Moscow" {
return fmt.Errorf("APP_TIMEZONE must be Europe/Moscow, got %q", c.App.Timezone)
}
c.Location = loc
if c.App.ShutdownTimeoutSec <= 0 {
return errors.New("APP_SHUTDOWN_TIMEOUT_SEC must be positive")
}
if c.Execution.AllowMarketOrders {
return errors.New("EXEC_ALLOW_MARKET_ORDERS must remain false: strategy is LIMIT-only")
}
if c.Execution.QuoteDepth <= 0 || c.Execution.QuoteDepth > maxQuoteDepth {
return fmt.Errorf("EXEC_QUOTE_DEPTH must be between 1 and %d", maxQuoteDepth)
}
if c.Execution.OrderPollIntervalMS <= 0 {
return errors.New("EXEC_ORDER_POLL_INTERVAL_MS must be positive")
}
if c.Risk.UseMargin {
return errors.New("RISK_USE_MARGIN must remain false")
}
if c.Risk.AllowShort {
return errors.New("RISK_ALLOW_SHORT must remain false")
}
if c.Risk.APIOutageHaltSec <= 0 {
return errors.New("RISK_API_OUTAGE_HALT_SEC must be positive")
}
if c.Risk.ReconciliationWindowHours <= 0 {
return errors.New("RISK_RECONCILIATION_WINDOW_HOURS must be positive")
}
if c.Risk.ReconciliationSkewSec < 0 {
return errors.New("RISK_RECONCILIATION_SKEW_SEC must be non-negative")
}
if c.Commission.FreeOrderCountPolicy != "submitted" {
return fmt.Errorf("COMM_FREE_ORDER_COUNT_POLICY must be submitted, got %q", c.Commission.FreeOrderCountPolicy)
}
if err := c.validateWindows(); err != nil {
return err
}
if c.App.Mode != domain.ModeBacktest && c.DB.DSN == "" {
return errors.New("DB_DSN is required outside backtest mode")
}
if (c.App.Mode == domain.ModeSandbox || c.App.Mode == domain.ModeLiveReadonly || c.App.Mode == domain.ModeLiveTrade) && c.TInvest.Token == "" {
return fmt.Errorf("TINVEST_TOKEN is required for APP_MODE=%s", c.App.Mode)
}
if c.TInvest.UseSandbox && c.App.Mode != domain.ModeSandbox {
return errors.New("TINVEST_USE_SANDBOX=true is only valid with APP_MODE=sandbox")
}
if c.App.Mode == domain.ModeLiveTrade && c.Live.TradeAck != liveTradeAck {
return fmt.Errorf("LIVE_TRADE_ACK=%s is required for APP_MODE=live_trade", liveTradeAck)
}
return nil
}
func (c Config) validateWindows() error {
if c.Execution.EntryWindowStart.Duration >= c.Execution.EntryWindowEnd.Duration ||
c.Execution.EntryWindowEnd.Duration > c.Execution.NoNewEntryAfter.Duration {
return errors.New("entry windows must satisfy EXEC_ENTRY_WINDOW_START < EXEC_ENTRY_WINDOW_END <= EXEC_NO_NEW_ENTRY_AFTER")
}
if c.Execution.ExitWatchStart.Duration > c.Execution.ExitNotBefore.Duration ||
c.Execution.ExitNotBefore.Duration > c.Execution.ExitWindowStart.Duration ||
c.Execution.ExitWindowStart.Duration >= c.Execution.ExitWindowEnd.Duration ||
c.Execution.ExitWindowEnd.Duration > c.Execution.HardExitDeadline.Duration {
return errors.New("exit windows must be monotonic from EXEC_EXIT_WATCH_START to EXEC_HARD_EXIT_DEADLINE")
}
return nil
}