506 lines
17 KiB
Go
506 lines
17 KiB
Go
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"
|
|
"github.com/shopspring/decimal"
|
|
|
|
"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 {
|
|
Stdout io.Writer
|
|
Stderr io.Writer
|
|
ModeOverride string
|
|
Halt bool
|
|
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.Healthcheck {
|
|
target := opts.HealthcheckURL
|
|
if target == "" {
|
|
target = "http://127.0.0.1:3300/ready"
|
|
}
|
|
return healthcheck.CheckEndpoint(ctx, target)
|
|
}
|
|
if opts.Stdout == nil {
|
|
opts.Stdout = io.Discard
|
|
}
|
|
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)
|
|
|
|
if opts.Halt && opts.Unhalt {
|
|
return errors.New("-halt and -unhalt are mutually exclusive")
|
|
}
|
|
if cfg.App.Mode == domain.ModeBacktest && !opts.Unhalt && !opts.Halt {
|
|
_, _ = fmt.Fprintf(opts.Stdout, "overnight trading bot initialized in %s mode\n", cfg.App.Mode)
|
|
return nil
|
|
}
|
|
if opts.Halt && cfg.DB.DSN == "" {
|
|
return errors.New("-halt requires DB_DSN")
|
|
}
|
|
|
|
if cfg.DB.MigrationsAutoApply {
|
|
if err := applyMigrations(ctx, cfg); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
db, err := openDB(ctx, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = db.Close()
|
|
}()
|
|
repo := mysqlrepo.NewRepository(db)
|
|
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
|
|
}
|
|
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)
|
|
clock := timeutil.RealClock{Loc: cfg.Location}
|
|
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).
|
|
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB).
|
|
WithClock(clock)
|
|
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
|
|
}
|
|
if cfg.App.Mode == domain.ModeLiveTrade {
|
|
persistedMode, err := repo.GetSystemMode(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("read persisted system mode: %w", err)
|
|
}
|
|
if persistedMode == domain.ModeLiveReadonly {
|
|
cfg.App.Mode = domain.ModeLiveReadonly
|
|
log.Warn("runtime mode downgraded from live_trade to persisted live_readonly")
|
|
}
|
|
}
|
|
|
|
gateway, closer, err := buildGateway(ctx, cfg, log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if closer != nil {
|
|
defer closer()
|
|
}
|
|
if err := seedPaperGateway(ctx, repo, gateway); err != nil {
|
|
return err
|
|
}
|
|
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)
|
|
clock := timeutil.RealClock{Loc: cfg.Location}
|
|
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).
|
|
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB).
|
|
WithClock(clock)
|
|
sm := statemachine.New(repo, cfg.App.Mode)
|
|
if _, err := sm.Recover(ctx, recon); err != nil {
|
|
_ = notifier.Alert(ctx, fmt.Sprintf("state recovery failed: %s", err))
|
|
return fmt.Errorf("state recovery: %w", 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()
|
|
runtime := buildScheduler(clock, sm, cfg, repo, gateway, notifier, recon, accountIDHash, log)
|
|
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
|
|
}
|
|
|
|
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)
|
|
loader.SetClock(clock)
|
|
pipeline := features.NewPipeline(repo, features.PipelineConfig{
|
|
RollingShort: cfg.Strategy.RollingShort,
|
|
RollingLong: cfg.Strategy.RollingLong,
|
|
EWMALambda: cfg.Strategy.EWMALambda,
|
|
RiskBufferBps: cfg.Strategy.RiskBufferBps,
|
|
EntrySlippageBps: cfg.Strategy.ExpectedEntrySlippageBps,
|
|
ExitSlippageBps: cfg.Strategy.ExpectedExitSlippageBps,
|
|
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,
|
|
},
|
|
IntervalVolumeLookback: cfg.Strategy.IntervalVolumeLookbackDays,
|
|
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.SetClock(clock)
|
|
execEngine.SetMaxQuoteAge(time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second)
|
|
execEngine.SetFreeOrderCountPolicy(cfg.Commission.FreeOrderCountPolicy)
|
|
execEngine.SetMaxExitOrderAttempts(cfg.Execution.MaxExitOrderAttempts)
|
|
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,
|
|
IntervalVolumeLookbackDays: cfg.Strategy.IntervalVolumeLookbackDays,
|
|
TickInterval: 30 * time.Second,
|
|
EntrySignalTime: cfg.Execution.EntrySignalTime,
|
|
EntryWindowStart: cfg.Execution.EntryWindowStart,
|
|
EntryWindowEnd: cfg.Execution.EntryWindowEnd,
|
|
NoNewEntryAfter: cfg.Execution.NoNewEntryAfter,
|
|
ExitWatchStart: cfg.Execution.ExitWatchStart,
|
|
ExitNotBefore: cfg.Execution.ExitNotBefore,
|
|
ExitWindowStart: cfg.Execution.ExitWindowStart,
|
|
ExitWindowEnd: cfg.Execution.ExitWindowEnd,
|
|
HardExitDeadline: cfg.Execution.HardExitDeadline,
|
|
MarketClose: cfg.Execution.MarketClose,
|
|
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,
|
|
FreeOrderCountPolicy: cfg.Commission.FreeOrderCountPolicy,
|
|
ReconciliationInterval: 5 * time.Minute,
|
|
MaxOpenPositions: minPositive(cfg.Strategy.MaxPositions, cfg.Risk.MaxOpenPositions),
|
|
SizeReductionWindowTrades: cfg.Risk.SizeReductionWindowTrades,
|
|
SizeReductionFactor: cfg.Risk.SizeReductionFactor,
|
|
SizeReductionTriggerBps: cfg.Risk.SizeReductionTriggerBps,
|
|
TradingCalendarExchange: cfg.TInvest.TradingCalendarExchange,
|
|
}, services)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
func applyMigrations(ctx context.Context, cfg config.Config) error {
|
|
db, err := openDB(ctx, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = db.Close()
|
|
}()
|
|
return mysqlrepo.ApplyMigrations(ctx, db.DB)
|
|
}
|
|
|
|
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:
|
|
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
|
|
}
|
|
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,
|
|
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 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,
|
|
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 gw, func() { _ = gw.Close() }, nil
|
|
default:
|
|
return tinvest.NewFakeGateway(), nil, nil
|
|
}
|
|
}
|
|
|
|
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 {
|
|
paper, isPaper := gateway.(*tinvest.PaperGateway)
|
|
if !isPaper {
|
|
return nil
|
|
}
|
|
fake = paper.Fake()
|
|
}
|
|
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
|
|
}
|
|
|
|
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 + "/ready"
|
|
}
|
|
if _, err := url.ParseRequestURI(addr); err == nil && strings.HasPrefix(addr, "http") {
|
|
return addr
|
|
}
|
|
return "http://" + addr + "/ready"
|
|
}
|
|
|
|
func PingDB(ctx context.Context, db *sql.DB) error {
|
|
return db.PingContext(ctx)
|
|
}
|