third version

This commit is contained in:
2026-06-08 07:05:01 +00:00
parent 282c841e11
commit 52a935b8b4
20 changed files with 1371 additions and 151 deletions
+57 -1
View File
@@ -17,6 +17,7 @@ import (
"time"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
"overnight-trading-bot/internal/config"
"overnight-trading-bot/internal/domain"
@@ -138,6 +139,9 @@ func Run(ctx context.Context, opts Options) error {
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,
@@ -161,7 +165,8 @@ func Run(ctx context.Context, opts Options) error {
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB)
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)
_ = 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)
@@ -270,6 +275,7 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con
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,
@@ -282,9 +288,23 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con
RequireZeroCommission: cfg.Commission.RequireZeroCommission,
QuarantineOnNonZero: cfg.Commission.QuarantineOnNonZero,
ReconciliationInterval: 5 * time.Minute,
MaxOpenPositions: minPositive(cfg.Strategy.MaxPositions, cfg.Risk.MaxOpenPositions),
}, 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 openDB(ctx context.Context, cfg config.Config) (*sqlx.DB, error) {
db, err := sqlx.Open("mysql", cfg.DB.DSN)
if err != nil {
@@ -340,6 +360,42 @@ func buildGateway(ctx context.Context, cfg config.Config, log *slog.Logger) (tin
}
}
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 {
return nil
}
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[:])
+34
View File
@@ -5,6 +5,12 @@ import (
"context"
"strings"
"testing"
"github.com/shopspring/decimal"
"overnight-trading-bot/internal/domain"
"overnight-trading-bot/internal/testutil"
"overnight-trading-bot/internal/tinvest"
)
func TestRunRequiresAppMode(t *testing.T) {
@@ -29,3 +35,31 @@ func TestRunBacktestModeWithoutDB(t *testing.T) {
t.Fatalf("unexpected stdout: %s", stdout.String())
}
}
func TestSeedPaperGatewayMakesSeedInstrumentsDiscoverable(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
if err := repo.UpsertInstrument(ctx, domain.Instrument{
InstrumentUID: "PENDING:TRUR",
Ticker: "TRUR",
ClassCode: "TQTF",
Name: "TRUR",
Lot: 1,
MinPriceIncrement: decimal.RequireFromString("0.0001"),
Currency: "RUB",
Enabled: true,
}); err != nil {
t.Fatal(err)
}
gateway := tinvest.NewFakeGateway()
if err := seedPaperGateway(ctx, repo, gateway); err != nil {
t.Fatal(err)
}
instrument, err := gateway.GetInstrument(ctx, "TRUR", "TQTF")
if err != nil {
t.Fatal(err)
}
if !instrument.MetadataValid() || strings.HasPrefix(instrument.InstrumentUID, "PENDING:") {
t.Fatalf("instrument was not made runnable for paper: %+v", instrument)
}
}