235 lines
11 KiB
Go
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
|
|
}
|