From f19bab11001b50238bc479da4df52f20f7de8d0d Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 7 Jun 2026 21:01:40 +0000 Subject: [PATCH] first version --- .env.example | 93 ++ .gitea/workflows/deploy.yml | 189 ++++ .gitignore | 21 +- .golangci.yml | 15 + Makefile | 55 +- README.md | 254 ++++- cmd/backtest/main.go | 133 +++ cmd/bot/main.go | 15 +- cmd/migrate/main.go | 57 ++ config.example.yaml | 70 -- deploy/systemd/overnight-trading-bot.service | 33 + go.mod | 75 ++ go.sum | 191 ++++ internal/app/app.go | 341 ++++++- internal/app/app_test.go | 38 +- internal/backtest/engine.go | 563 +++++++++++ internal/backtest/lookahead_test.go | 70 ++ internal/backtest/metrics.go | 213 +++++ internal/backtest/metrics_test.go | 14 + internal/config/config.go | 234 +++++ internal/domain/types.go | 311 ++++++ internal/execution/engine.go | 405 ++++++++ internal/execution/pricing.go | 58 ++ internal/execution/pricing_test.go | 49 + internal/execution/state_test.go | 137 +++ internal/features/pipeline.go | 148 +++ internal/features/pipeline_test.go | 57 ++ internal/features/returns.go | 207 ++++ internal/features/returns_test.go | 38 + internal/features/rolling_test.go | 30 + internal/features/spread_test.go | 13 + internal/healthcheck/healthcheck.go | 109 +++ internal/instruments/registry.go | 61 ++ internal/logging/logging.go | 48 + internal/marketdata/loader.go | 67 ++ internal/money/money.go | 117 +++ internal/money/rounding_test.go | 39 + internal/notify/notify.go | 219 +++++ internal/notify/telegram_test.go | 49 + internal/position/manager.go | 93 ++ internal/position/manager_test.go | 141 +++ internal/reconciliation/engine.go | 230 +++++ internal/reconciliation/engine_test.go | 131 +++ internal/report/daily.go | 46 + .../repository/migrations/0001_init.down.sql | 13 + .../repository/migrations/0001_init.up.sql | 181 ++++ .../migrations/0002_seed_instruments.down.sql | 7 + .../migrations/0002_seed_instruments.up.sql | 24 + .../0003_instrument_uid_cascade.down.sql | 29 + .../0003_instrument_uid_cascade.up.sql | 29 + .../0004_positions_reports.down.sql | 5 + .../migrations/0004_positions_reports.up.sql | 11 + .../migrations/0005_policy_cleanup.down.sql | 7 + .../migrations/0005_policy_cleanup.up.sql | 20 + .../0006_position_lot_size.down.sql | 3 + .../migrations/0006_position_lot_size.up.sql | 8 + internal/repository/migrations/migrations.go | 8 + internal/repository/mysql/migrate.go | 61 ++ internal/repository/mysql/repository.go | 790 +++++++++++++++ .../mysql/repository_integration_test.go | 114 +++ internal/repository/mysql/rows.go | 338 +++++++ internal/repository/repository.go | 49 + internal/risk/freeorders_test.go | 27 + internal/risk/manager.go | 127 +++ internal/risk/sizing.go | 166 ++++ internal/risk/sizing_test.go | 172 ++++ internal/scheduler/scheduler.go | 905 ++++++++++++++++++ internal/scheduler/scheduler_test.go | 287 ++++++ internal/signal/engine.go | 152 +++ internal/signal/engine_test.go | 87 ++ internal/statemachine/system.go | 120 +++ internal/statemachine/system_test.go | 93 ++ internal/testutil/memory_repository.go | 344 +++++++ internal/timeutil/clock.go | 93 ++ internal/tinvest/gateway.go | 182 ++++ internal/tinvest/real.go | 456 +++++++++ internal/tinvest/retry.go | 64 ++ internal/tinvest/retry_test.go | 41 + internal/tinvest/sandbox.go | 10 + 79 files changed, 10355 insertions(+), 145 deletions(-) create mode 100644 .env.example create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .golangci.yml create mode 100644 cmd/backtest/main.go create mode 100644 cmd/migrate/main.go delete mode 100644 config.example.yaml create mode 100644 deploy/systemd/overnight-trading-bot.service create mode 100644 go.sum create mode 100644 internal/backtest/engine.go create mode 100644 internal/backtest/lookahead_test.go create mode 100644 internal/backtest/metrics.go create mode 100644 internal/backtest/metrics_test.go create mode 100644 internal/config/config.go create mode 100644 internal/domain/types.go create mode 100644 internal/execution/engine.go create mode 100644 internal/execution/pricing.go create mode 100644 internal/execution/pricing_test.go create mode 100644 internal/execution/state_test.go create mode 100644 internal/features/pipeline.go create mode 100644 internal/features/pipeline_test.go create mode 100644 internal/features/returns.go create mode 100644 internal/features/returns_test.go create mode 100644 internal/features/rolling_test.go create mode 100644 internal/features/spread_test.go create mode 100644 internal/healthcheck/healthcheck.go create mode 100644 internal/instruments/registry.go create mode 100644 internal/logging/logging.go create mode 100644 internal/marketdata/loader.go create mode 100644 internal/money/money.go create mode 100644 internal/money/rounding_test.go create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/telegram_test.go create mode 100644 internal/position/manager.go create mode 100644 internal/position/manager_test.go create mode 100644 internal/reconciliation/engine.go create mode 100644 internal/reconciliation/engine_test.go create mode 100644 internal/report/daily.go create mode 100644 internal/repository/migrations/0001_init.down.sql create mode 100644 internal/repository/migrations/0001_init.up.sql create mode 100644 internal/repository/migrations/0002_seed_instruments.down.sql create mode 100644 internal/repository/migrations/0002_seed_instruments.up.sql create mode 100644 internal/repository/migrations/0003_instrument_uid_cascade.down.sql create mode 100644 internal/repository/migrations/0003_instrument_uid_cascade.up.sql create mode 100644 internal/repository/migrations/0004_positions_reports.down.sql create mode 100644 internal/repository/migrations/0004_positions_reports.up.sql create mode 100644 internal/repository/migrations/0005_policy_cleanup.down.sql create mode 100644 internal/repository/migrations/0005_policy_cleanup.up.sql create mode 100644 internal/repository/migrations/0006_position_lot_size.down.sql create mode 100644 internal/repository/migrations/0006_position_lot_size.up.sql create mode 100644 internal/repository/migrations/migrations.go create mode 100644 internal/repository/mysql/migrate.go create mode 100644 internal/repository/mysql/repository.go create mode 100644 internal/repository/mysql/repository_integration_test.go create mode 100644 internal/repository/mysql/rows.go create mode 100644 internal/repository/repository.go create mode 100644 internal/risk/freeorders_test.go create mode 100644 internal/risk/manager.go create mode 100644 internal/risk/sizing.go create mode 100644 internal/risk/sizing_test.go create mode 100644 internal/scheduler/scheduler.go create mode 100644 internal/scheduler/scheduler_test.go create mode 100644 internal/signal/engine.go create mode 100644 internal/signal/engine_test.go create mode 100644 internal/statemachine/system.go create mode 100644 internal/statemachine/system_test.go create mode 100644 internal/testutil/memory_repository.go create mode 100644 internal/timeutil/clock.go create mode 100644 internal/tinvest/gateway.go create mode 100644 internal/tinvest/real.go create mode 100644 internal/tinvest/retry.go create mode 100644 internal/tinvest/retry_test.go create mode 100644 internal/tinvest/sandbox.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a6f0de0 --- /dev/null +++ b/.env.example @@ -0,0 +1,93 @@ +APP_MODE=paper +APP_TIMEZONE=Europe/Moscow +APP_LOG_LEVEL=info +APP_HEALTHCHECK_ADDR=:3300 +APP_SHUTDOWN_TIMEOUT_SEC=30 + +TINVEST_TOKEN= +TINVEST_ACCOUNT_ID= +TINVEST_ENDPOINT=invest-public-api.tinkoff.ru:443 +TINVEST_APP_NAME=overnight-trading-bot +TINVEST_REQUEST_TIMEOUT_SEC=10 +TINVEST_RETRY_COUNT=3 +TINVEST_RETRY_BACKOFF_SEC=2 +TINVEST_USE_SANDBOX=false + +DB_DSN=bot:change-me@tcp(db.example.internal:3306)/overnight_bot?parseTime=true&loc=UTC&multiStatements=true +DB_MAX_OPEN_CONNS=20 +DB_MAX_IDLE_CONNS=5 +DB_CONN_MAX_LIFETIME_MIN=30 +DB_MIGRATIONS_AUTO_APPLY=true + +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +TELEGRAM_NOTIFY_INFO=true +TELEGRAM_NOTIFY_WARN=true +TELEGRAM_NOTIFY_ALERT=true +TELEGRAM_NOTIFY_REPORT=true + +STRATEGY_ROLLING_SHORT=60 +STRATEGY_ROLLING_LONG=252 +STRATEGY_EWMA_LAMBDA=0.08 +STRATEGY_MIN_TSTAT_60=1.25 +STRATEGY_MIN_WIN_RATE_60=0.55 +STRATEGY_MIN_NET_EDGE_BPS=10 +STRATEGY_RISK_BUFFER_BPS=5 +STRATEGY_MAX_POSITIONS=5 + +EXEC_ENTRY_SIGNAL_TIME=18:10:00 +EXEC_ENTRY_WINDOW_START=18:20:00 +EXEC_ENTRY_WINDOW_END=18:38:30 +EXEC_NO_NEW_ENTRY_AFTER=18:38:30 +EXEC_EXIT_WATCH_START=09:50:00 +EXEC_EXIT_NOT_BEFORE=10:03:00 +EXEC_EXIT_WINDOW_START=10:05:00 +EXEC_EXIT_WINDOW_END=10:25:00 +EXEC_HARD_EXIT_DEADLINE=10:45:00 +EXEC_MIN_TIME_TO_CLOSE_SEC=90 +EXEC_ALLOW_MARKET_ORDERS=false +EXEC_MAX_ENTRY_ORDER_ATTEMPTS=3 +EXEC_MAX_EXIT_ORDER_ATTEMPTS=3 +EXEC_PASSIVE_IMPROVE_TICKS=1 +EXEC_QUOTE_DEPTH=20 +EXEC_MAX_QUOTE_AGE_SEC=3 +EXEC_ORDER_POLL_INTERVAL_MS=500 + +RISK_USE_MARGIN=false +RISK_ALLOW_SHORT=false +RISK_MAX_TOTAL_EXPOSURE_PCT=0.50 +RISK_MAX_POSITION_PCT=0.10 +RISK_MAX_DAILY_LOSS_PCT=0.01 +RISK_MAX_WEEKLY_LOSS_PCT=0.03 +RISK_MAX_MONTHLY_DRAWDOWN_PCT=0.07 +RISK_MAX_OPEN_POSITIONS=5 +RISK_MAX_AVG_SLIPPAGE_BPS_10_TRADES=15 +RISK_API_OUTAGE_HALT_SEC=180 +RISK_MAX_CLOCK_DRIFT_SEC=2 +RISK_RECONCILIATION_WINDOW_HOURS=72 +RISK_RECONCILIATION_SKEW_SEC=10 +RISK_CASH_USAGE_BUFFER=0.95 +RISK_RISK_BUDGET_PER_INSTRUMENT_PCT=0.005 +RISK_MIN_ORDER_NOTIONAL_RUB=1000 + +LIQ_MIN_ADV_RUB=5000000 +LIQ_MAX_PARTICIPATION_RATE=0.01 +LIQ_MAX_SPREAD_BPS_DEFAULT=20 +LIQ_MAX_SPREAD_BPS_MONEY_MARKET=5 +LIQ_MAX_SPREAD_BPS_BOND_FUNDS=10 +LIQ_MAX_SPREAD_BPS_EQUITY_FUNDS=25 +LIQ_MAX_TICK_BPS=10 + +COMM_REQUIRE_ZERO_COMMISSION=true +COMM_QUARANTINE_ON_NONZERO=true +COMM_FREE_ORDER_COUNT_POLICY=submitted + +BT_DATE_FROM= +BT_DATE_TO= +BT_ENTRY_SLIPPAGE_BPS=8 +BT_EXIT_SLIPPAGE_BPS=8 +BT_COMMISSION_ROUNDTRIP_BPS=0 +BT_USE_MINUTE_MODEL=false +BT_OUTPUT_DIR=./backtest_out + +LIVE_TRADE_ACK= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..a653411 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,189 @@ +name: Deploy + +on: + push: + branches: + - master + +jobs: + deploy: + name: Test, build and deploy + runs-on: ubuntu-latest + timeout-minutes: 30 + + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + RELEASE_SHA: ${{ github.sha }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Print Go version + run: go version + + - name: Download modules + run: go mod download + + - name: Vet + run: go vet ./... + + - name: Install golangci-lint + run: | + set -Eeuo pipefail + bin_dir="$(go env GOPATH)/bin" + install -d "$bin_dir" + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh \ + | sh -s -- -b "$bin_dir" latest + echo "$bin_dir" >> "$GITHUB_PATH" + + - name: Lint + run: golangci-lint run ./... + + - name: Test + run: go test ./... + + - name: Build linux/amd64 binaries + env: + CGO_ENABLED: "0" + GOOS: linux + GOARCH: amd64 + run: | + set -Eeuo pipefail + mkdir -p out/release/bin out/release/systemd + go build -trimpath -ldflags="-s -w" -o out/release/bin/overnight-trading-bot ./cmd/bot + go build -trimpath -ldflags="-s -w" -o out/release/bin/overnight-trading-bot-migrate ./cmd/migrate + go build -trimpath -ldflags="-s -w" -o out/release/bin/overnight-trading-bot-backtest ./cmd/backtest + cp deploy/systemd/overnight-trading-bot.service out/release/systemd/ + file out/release/bin/overnight-trading-bot + + - name: Pack release archive + run: | + set -Eeuo pipefail + tar -C out/release -czf out/release.tar.gz . + + - name: Validate inputs and configure SSH + env: + DEPLOY_SSH_PRIVATE_KEY_BASE64: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY_BASE64 }} + run: | + set -Eeuo pipefail + + if [ -z "${DEPLOY_HOST:-}" ]; then + echo "secrets.DEPLOY_HOST is required." >&2 + exit 1 + fi + if [ -z "${DEPLOY_SSH_PRIVATE_KEY_BASE64:-}" ]; then + echo "secrets.DEPLOY_SSH_PRIVATE_KEY_BASE64 is required." >&2 + exit 1 + fi + + install -m 700 -d ~/.ssh + printf '%s' "$DEPLOY_SSH_PRIVATE_KEY_BASE64" | base64 -d > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -p 22 -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + + - name: Upload archive + run: | + set -Eeuo pipefail + ssh \ + -i ~/.ssh/deploy_key \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -p 22 \ + root@"$DEPLOY_HOST" \ + "install -d -m 0755 /var/tmp/overnight-trading-bot-deploy" + scp \ + -i ~/.ssh/deploy_key \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -P 22 \ + out/release.tar.gz \ + root@"$DEPLOY_HOST":/var/tmp/overnight-trading-bot-deploy/release.tar.gz + + - name: Install release and restart service + run: | + set -Eeuo pipefail + ssh \ + -i ~/.ssh/deploy_key \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -p 22 \ + root@"$DEPLOY_HOST" \ + "RELEASE_SHA='$RELEASE_SHA' bash -se" <<'REMOTE' + set -Eeuo pipefail + + for cmd in systemctl tar install useradd journalctl; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd is not installed on the server." >&2 + exit 1 + fi + done + + env_file=/etc/overnight-trading-bot/overnight-trading-bot.env + if [ ! -f "$env_file" ]; then + echo "Missing $env_file. Create it before running deploy (see README)." >&2 + exit 1 + fi + + if ! id -u overnight-bot >/dev/null 2>&1; then + useradd --system --no-create-home --home-dir /nonexistent \ + --shell /usr/sbin/nologin overnight-bot + fi + + chgrp overnight-bot "$env_file" + chmod 0640 "$env_file" + + stage_dir=/var/tmp/overnight-trading-bot-deploy/stage + rm -rf "$stage_dir" + install -d -m 0755 "$stage_dir" + tar -xzf /var/tmp/overnight-trading-bot-deploy/release.tar.gz -C "$stage_dir" + rm -f /var/tmp/overnight-trading-bot-deploy/release.tar.gz + + for bin in overnight-trading-bot overnight-trading-bot-migrate overnight-trading-bot-backtest; do + install -m 0755 "$stage_dir/bin/$bin" "/usr/local/bin/$bin.new" + mv -f "/usr/local/bin/$bin.new" "/usr/local/bin/$bin" + done + + install -m 0644 "$stage_dir/systemd/overnight-trading-bot.service" \ + /etc/systemd/system/overnight-trading-bot.service + + rm -rf "$stage_dir" + + systemctl daemon-reload + systemctl enable overnight-trading-bot.service + systemctl restart overnight-trading-bot.service + + for _ in $(seq 1 30); do + if systemctl is-active --quiet overnight-trading-bot.service; then + break + fi + sleep 2 + done + + if ! systemctl is-active --quiet overnight-trading-bot.service; then + echo "Service failed to become active after restart." >&2 + journalctl -u overnight-trading-bot.service -n 100 --no-pager >&2 || true + exit 1 + fi + + for _ in $(seq 1 15); do + if /usr/local/bin/overnight-trading-bot -healthcheck >/dev/null 2>&1; then + echo "Health endpoint responding." + exit 0 + fi + sleep 2 + done + + echo "Health endpoint did not respond after restart." >&2 + journalctl -u overnight-trading-bot.service -n 100 --no-pager >&2 || true + exit 1 + REMOTE diff --git a/.gitignore b/.gitignore index 237a330..298b6ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,8 @@ -.DS_Store - -# Go build artifacts -/bin/ -/dist/ -*.test -*.out - -# Local configuration and secrets .env -config.yaml - -# Editor folders -.idea/ -.vscode/ +bin/ +backtest_out/ +.cache/ +.tmp/ +.DS_Store +coverage.out +*.test diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..6ca47e0 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,15 @@ +version: "2" +linters: + enable: + - bodyclose + - errcheck + - gocritic + - gosec + - govet + - ineffassign + - staticcheck + - unused + - misspell +formatters: + enable: + - gofmt diff --git a/Makefile b/Makefile index 0b0550c..06fda42 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,50 @@ -.PHONY: fmt test run tidy +export GOCACHE := $(CURDIR)/.cache/go-build +export GOMODCACHE := $(CURDIR)/.cache/go-mod +export GOLANGCI_LINT_CACHE := $(CURDIR)/.cache/golangci-lint +export GOTELEMETRY := off +export TMPDIR := $(CURDIR)/.tmp -fmt: - go fmt ./... +GO := go -test: - go test ./... +.PHONY: cache fmt vet lint test race integration sandbox tidy run migrate build backtest -run: - go run ./cmd/bot +cache: + mkdir -p $(GOCACHE) $(GOMODCACHE) $(GOLANGCI_LINT_CACHE) $(TMPDIR) bin -tidy: - go mod tidy +fmt: cache + $(GO) fmt ./... + +vet: cache + $(GO) vet ./... + +lint: cache + golangci-lint run ./... + +test: cache + $(GO) test ./... + +race: cache + $(GO) test -race ./... + +integration: cache + GOMODCACHE=$(CURDIR)/.cache/go-mod-integration-v42 GOCACHE=$(CURDIR)/.cache/go-build-integration-v42 $(GO) test -tags=integration ./... + +sandbox: cache + $(GO) test -tags=sandbox ./... + +tidy: cache + $(GO) mod tidy + +run: cache + $(GO) run ./cmd/bot + +migrate: cache + $(GO) run ./cmd/migrate -direction=up + +build: cache + $(GO) build -trimpath -o bin/bot ./cmd/bot + $(GO) build -trimpath -o bin/migrate ./cmd/migrate + $(GO) build -trimpath -o bin/backtest ./cmd/backtest + +backtest: cache + $(GO) run ./cmd/backtest -candles "$${BT_CANDLES:?set BT_CANDLES}" diff --git a/README.md b/README.md index 5f7c2c4..089b5cf 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,261 @@ # Overnight Trading Bot -Go-проект для overnight-бота по фондам T-Капитала через T-Invest API. +Go-бот для overnight-стратегии `close -> next open` на фондах T-Капитала через T-Invest API. ## Quick Start ```sh -cp config.example.yaml config.yaml -go test ./... -go run ./cmd/bot -config config.yaml +cp .env.example .env +make test +APP_MODE=backtest go run ./cmd/bot ``` -## Development +Для daemon-режимов (`paper`, `sandbox`, `live_readonly`, `live_trade`) нужен `DB_DSN` MariaDB/MySQL. `live_trade` дополнительно требует `LIVE_TRADE_ACK=I_ACCEPT_RISK`. + +## Environment Variables + +Конфигурация читается из ENV через `.env`. Если значение не парсится в нужный тип, бот падает на старте с ошибкой `load ENV config`. + +Общие форматы: + +- Время указывается в формате `HH:MM:SS` и трактуется в `Europe/Moscow`. +- Доли указываются десятичной дробью: `0.10` означает 10%, `0.005` означает 0.5%. +- `bps` - базисные пункты: `10` означает 0.10%. +- Boolean-значения: `true` или `false`. +- В колонке "Дефолт" указан дефолт из кода. Если дефолта в коде нет, но в `.env.example` есть пример, это отмечено отдельно. +- Границы делятся на жёсткую валидацию старта и практические ограничения. Там, где валидации пока нет, указано рекомендуемое значение. + +### APP + +| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | +| --- | --- | --- | --- | --- | +| `APP_MODE` | `backtest`, `paper`, `sandbox`, `live_readonly`, `live_trade` | нет, в `.env.example`: `paper` | обязательна; только перечисленные значения | Режим работы. `backtest` не требует БД и API в `cmd/bot`; `paper` использует fake gateway; `sandbox`, `live_readonly`, `live_trade` подключаются к T-Invest API; `live_trade` может отправлять брокерские заявки. | +| `APP_TIMEZONE` | `Europe/Moscow` | `Europe/Moscow` | жёстко только `Europe/Moscow` | Таймзона расписания торговых окон. Изменить нельзя без изменения валидации. | +| `APP_LOG_LEVEL` | `debug`, `info`, `warn`, `warning`, `error` | `info` | неизвестное значение трактуется как `info` | Уровень JSON-логов. Ниже уровень - больше диагностических записей. | +| `APP_HEALTHCHECK_ADDR` | HTTP listen address, например `:3300` или `127.0.0.1:3300` | `:3300` | без отдельной валидации | Адрес `/health` и `/ready`. При изменении меняется порт или интерфейс healthcheck-сервера. | +| `APP_SHUTDOWN_TIMEOUT_SEC` | целое число секунд | `30` | должно быть `> 0` | Таймаут graceful shutdown для HTTP healthcheck при остановке. | + +### TINVEST + +| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | +| --- | --- | --- | --- | --- | +| `TINVEST_TOKEN` | токен T-Invest API | пусто | обязателен для `sandbox`, `live_readonly`, `live_trade` | Доступ к реальному или sandbox API. В `paper` и `backtest` не нужен. | +| `TINVEST_ACCOUNT_ID` | идентификатор брокерского счёта | пусто | строка; в коде непустота не проверяется | Счёт для портфеля, заявок и сверки. Для API-режимов нужно указать реальный account id, иначе операции у брокера могут падать. | +| `TINVEST_ENDPOINT` | gRPC endpoint T-Invest, обычно `host:port` | `invest-public-api.tinkoff.ru:443` | строка; валидации формата нет | Endpoint для API. В `sandbox` код принудительно использует sandbox endpoint. | +| `TINVEST_APP_NAME` | имя приложения | `overnight-trading-bot` | строка | Передаётся в SDK как имя клиента. Меняет идентификацию приложения на стороне API/логов. | +| `TINVEST_REQUEST_TIMEOUT_SEC` | целое число секунд | `10` | рекомендуется `> 0`; сейчас не применяется | Зарезервировано под таймаут API-запросов. На текущий код не влияет. | +| `TINVEST_RETRY_COUNT` | целое число попыток | `3` | `<= 0` трактуется как одна попытка | Общее число попыток для SDK-вызовов T-Invest через exponential backoff. Больше значение повышает устойчивость к кратким сбоям, но может дольше задерживать окончательную ошибку. | +| `TINVEST_RETRY_BACKOFF_SEC` | целое число секунд | `2` | рекомендуется `>= 0` | Начальный интервал exponential backoff для SDK-вызовов T-Invest. Больше значение снижает частоту повторов при сбоях, но дольше задерживает окончательную ошибку. | +| `TINVEST_USE_SANDBOX` | `true` или `false` | `false` | boolean; разрешено только при `APP_MODE=sandbox` | Защитный флаг совместимости. В `live_readonly` и `live_trade` запрещён валидацией, чтобы случайно не подменить фактическую среду исполнения. | + +### DB + +| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | +| --- | --- | --- | --- | --- | +| `DB_DSN` | MySQL/MariaDB DSN, например `bot:change-me@tcp(db.example.internal:3306)/overnight_bot?parseTime=true&loc=UTC&multiStatements=true` | нет, пример есть в `.env.example` | обязателен во всех режимах, кроме `backtest`; должен открываться драйвером MySQL | Подключение к БД. В БД хранятся инструменты, свечи, сигналы, заявки, позиции, состояния, события риска и отчёты. | +| `DB_MAX_OPEN_CONNS` | целое число | `20` | валидации нет; `<= 0` для `database/sql` означает без лимита | Максимум открытых соединений с БД. Больше - выше параллелизм, но больше нагрузка на MariaDB. | +| `DB_MAX_IDLE_CONNS` | целое число | `5` | валидации нет; `<= 0` отключает idle pool | Размер пула простаивающих соединений. Больше - меньше переподключений, но больше удерживаемых соединений. | +| `DB_CONN_MAX_LIFETIME_MIN` | целое число минут | `30` | валидации нет; `<= 0` отключает лимит lifetime | Сколько живёт соединение до пересоздания. Меньше - чаще переподключения, больше - дольше используются старые соединения. | +| `DB_MIGRATIONS_AUTO_APPLY` | `true` или `false` | `true` | boolean | Автоматически применяет миграции при старте daemon-режима. `false` требует запускать миграции вручную через `cmd/migrate`. | + +### TELEGRAM + +| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | +| --- | --- | --- | --- | --- | +| `TELEGRAM_BOT_TOKEN` | токен Telegram-бота | пусто | строка | Если токен или `TELEGRAM_CHAT_ID` пустые, уведомления отключены и используется noop notifier. | +| `TELEGRAM_CHAT_ID` | числовой chat id | `0` | `int64`; `0` отключает Telegram | Чат, куда отправляются уведомления. | +| `TELEGRAM_NOTIFY_INFO` | `true` или `false` | `true` | boolean | Включает информационные сообщения, например старт бота и события заявок. При переполнении очереди такие сообщения могут быть отброшены. | +| `TELEGRAM_NOTIFY_WARN` | `true` или `false` | `true` | boolean | Включает предупреждения. | +| `TELEGRAM_NOTIFY_ALERT` | `true` или `false` | `true` | boolean | Включает alert-сообщения. Они считаются критичными для доставки и ждут место в очереди. | +| `TELEGRAM_NOTIFY_REPORT` | `true` или `false` | `true` | boolean | Включает дневные отчёты. | + +### STRATEGY + +| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | +| --- | --- | --- | --- | --- | +| `STRATEGY_ROLLING_SHORT` | количество торговых дней | `60` | рекомендуется `> 0` | Короткое окно статистики overnight-доходности. Больше - стабильнее оценка, но медленнее реакция; меньше - быстрее реакция, но больше шум. | +| `STRATEGY_ROLLING_LONG` | количество торговых дней | `252` | рекомендуется `>= STRATEGY_ROLLING_SHORT` и `> 0` | Длинное окно для проверки положительного долгосрочного edge и глубины backfill. Больше требует больше истории. | +| `STRATEGY_EWMA_LAMBDA` | дробь для EWMA | `0.08` | рабочий диапазон `(0, 1]`; вне диапазона EWMA-функция использует `0.08` | Вес новых наблюдений в EWMA. Больше - свежее движение влияет сильнее. | +| `STRATEGY_MIN_TSTAT_60` | decimal t-stat | `1.25` | валидации нет; обычно `>= 0` | Минимальная статистическая значимость короткого edge. Выше - меньше входов, ниже - больше входов. | +| `STRATEGY_MIN_WIN_RATE_60` | доля прибыльных overnight-дней | `0.55` | рекомендуется `0..1` | Минимальная доля положительных overnight-наблюдений. Выше - строже фильтр сигналов. | +| `STRATEGY_MIN_NET_EDGE_BPS` | bps | `10` | валидации нет; обычно `>= 0` | Минимальный ожидаемый edge после издержек. Выше - меньше, но потенциально качественнее сигналы. | +| `STRATEGY_RISK_BUFFER_BPS` | bps | `5` | валидации нет; обычно `>= 0` | Дополнительная надбавка к ожидаемым издержкам в расчёте `NetEdgeBps`. Больше - консервативнее отбор. | +| `STRATEGY_MAX_POSITIONS` | целое число позиций | `5` | `> 0` включает лимит; `<= 0` фактически отключает signal-level лимит | Максимум одновременно открытых позиций на уровне генерации сигналов. Больше - больше диверсификация и нагрузка на капитал. | + +### EXEC + +| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | +| --- | --- | --- | --- | --- | +| `EXEC_ENTRY_SIGNAL_TIME` | `HH:MM:SS` | `18:10:00` | должно парситься как время | Время старта подготовки данных и генерации сигналов. | +| `EXEC_ENTRY_WINDOW_START` | `HH:MM:SS` | `18:20:00` | `ENTRY_WINDOW_START < ENTRY_WINDOW_END <= NO_NEW_ENTRY_AFTER` | Начало окна постановки заявок на вход. Позже - меньше времени на исполнение входа. | +| `EXEC_ENTRY_WINDOW_END` | `HH:MM:SS` | `18:38:30` | см. правило окна входа | Конец активной постановки заявок на вход и market close для pre-trade проверки входа. | +| `EXEC_NO_NEW_ENTRY_AFTER` | `HH:MM:SS` | `18:38:30` | не раньше `EXEC_ENTRY_WINDOW_END` | После этого времени новые входы не ставятся, бот переходит в overnight hold. | +| `EXEC_EXIT_WATCH_START` | `HH:MM:SS` | `09:50:00` | `EXIT_WATCH_START <= EXIT_NOT_BEFORE <= EXIT_WINDOW_START < EXIT_WINDOW_END <= HARD_EXIT_DEADLINE` | Начало утреннего наблюдения перед выходом. До `EXEC_EXIT_WINDOW_START` заявки на выход ещё не ставятся. | +| `EXEC_EXIT_NOT_BEFORE` | `HH:MM:SS` | `10:03:00` | см. правило окна выхода; сейчас используется только валидацией | Нижняя граница "не выходить раньше". На текущий scheduler напрямую не влияет, потому что заявки начинаются с `EXEC_EXIT_WINDOW_START`. | +| `EXEC_EXIT_WINDOW_START` | `HH:MM:SS` | `10:05:00` | см. правило окна выхода | Начало постановки заявок на выход. Раньше - больше шанс выйти быстрее, но ближе к открытию рынка. | +| `EXEC_EXIT_WINDOW_END` | `HH:MM:SS` | `10:25:00` | см. правило окна выхода | Конец постановки новых exit-заявок, после него идёт мониторинг до hard deadline. | +| `EXEC_HARD_EXIT_DEADLINE` | `HH:MM:SS` | `10:45:00` | не раньше `EXEC_EXIT_WINDOW_END` | Крайний срок выхода. После него запускаются reconciliation и report; незакрытая позиция ведёт к ручной обработке/HALT-сценарию. | +| `EXEC_MIN_TIME_TO_CLOSE_SEC` | целое число секунд | `90` | `> 0` включает проверку; `<= 0` отключает | Минимальный запас до конца торгового окна для pre-trade. Больше - меньше риск ставить заявку слишком поздно. | +| `EXEC_ALLOW_MARKET_ORDERS` | только `false` | `false` | жёстко должно быть `false` | Защитный флаг стратегии LIMIT-only. `true` запрещён валидацией. | +| `EXEC_MAX_ENTRY_ORDER_ATTEMPTS` | целое число | `3` | рекомендуется `>= 1` | Максимальное число постановок входной заявки в `MonitorUntil`: после polling/repost остаток отменяется к дедлайну окна входа. | +| `EXEC_MAX_EXIT_ORDER_ATTEMPTS` | целое число | `3` | рекомендуется `>= 1` | Максимальное число постановок выходной заявки в `MonitorUntil`: после polling/repost остаток отменяется к hard deadline. | +| `EXEC_PASSIVE_IMPROVE_TICKS` | целое число тиков | `1` | отрицательное значение в pricing приравнивается к `0` | Насколько улучшать passive limit price от лучшего bid/ask. Больше - цена агрессивнее, но код не пересекает spread. | +| `EXEC_QUOTE_DEPTH` | целое число уровней стакана | `20` | `1..50` | Глубина стакана для оценки bid/ask и spread. Больше - больше данных из API, но для цены используется лучший уровень. | +| `EXEC_MAX_QUOTE_AGE_SEC` | целое число секунд | `3` | `> 0` включает проверку; `<= 0` отключает | Максимальный возраст котировки. Меньше - строже к свежести данных, но больше отказов `quote age exceeds`. | +| `EXEC_ORDER_POLL_INTERVAL_MS` | целое число миллисекунд | `500` | рекомендуется `> 0` | Частота polling статусов заявок в `MonitorUntil`; также задаёт нижнюю границу интервала между repost-попытками. | + +### RISK + +| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | +| --- | --- | --- | --- | --- | +| `RISK_USE_MARGIN` | только `false` | `false` | жёстко должно быть `false` | Защитный запрет маржинальной торговли. | +| `RISK_ALLOW_SHORT` | только `false` | `false` | жёстко должно быть `false` | Защитный запрет коротких позиций. | +| `RISK_MAX_TOTAL_EXPOSURE_PCT` | доля equity | `0.50` | рекомендуется `0..1`; `0` фактически запрещает новые позиции | Общий лимит экспозиции, делится на выбранные инструменты при sizing. Больше - больше капитал в рынке. | +| `RISK_MAX_POSITION_PCT` | доля equity | `0.10` | рекомендуется `0..1`; `0` запрещает размер позиции | Максимальный размер одной позиции от equity. | +| `RISK_MAX_DAILY_LOSS_PCT` | доля equity | `0.01` | `> 0` включает лимит; `<= 0` отключает | Дневной стоп по убытку. При достижении pre-trade отклоняет новые заявки. | +| `RISK_MAX_WEEKLY_LOSS_PCT` | доля equity | `0.03` | `> 0` включает лимит; `<= 0` отключает | Недельный стоп по убытку. | +| `RISK_MAX_MONTHLY_DRAWDOWN_PCT` | доля equity | `0.07` | `> 0` включает лимит; `<= 0` отключает | Месячный лимит просадки. | +| `RISK_MAX_OPEN_POSITIONS` | целое число | `5` | `> 0` включает лимит; `<= 0` отключает | Risk-level максимум открытых позиций перед постановкой заявки. | +| `RISK_MAX_AVG_SLIPPAGE_BPS_10_TRADES` | bps | `15` | `> 0` включает лимит; `<= 0` отключает | Блокирует новые заявки при слишком большом среднем slippage за 10 сделок. | +| `RISK_API_OUTAGE_HALT_SEC` | целое число секунд | `180` | должно быть `> 0` | Если инфраструктурный/API сбой длится дольше, бот переводится в HALT. Больше - терпимее к сбоям, меньше - быстрее останавливается. | +| `RISK_MAX_CLOCK_DRIFT_SEC` | целое число секунд | `2` | `> 0` включает проверку drift; `<= 0` отключает | Максимальный рассинхрон локального времени и серверного времени API в `/ready`. | +| `RISK_RECONCILIATION_WINDOW_HOURS` | целое число часов | `72` | должно быть `> 0` | Глубина сверки последних заявок и операций брокера. Больше - больше история сверки, но тяжелее запросы. | +| `RISK_RECONCILIATION_SKEW_SEC` | целое число секунд | `10` | `>= 0` | Grace-window для только что отправленных локальных заявок: свежие in-flight orders не считаются diff, пока брокерский active-list догоняет запись. | +| `RISK_CASH_USAGE_BUFFER` | доля cash | `0.95` | рекомендуется `0..1`; `0` запрещает использование cash | Какая часть свободных денег может идти в sizing. Меньше - больше денежный буфер. | +| `RISK_RISK_BUDGET_PER_INSTRUMENT_PCT` | доля equity | `0.005` | рекомендуется `> 0` | Риск-бюджет на инструмент, используется вместе с оценкой неблагоприятного overnight-движения. Больше - крупнее позиции при прочих равных. | +| `RISK_MIN_ORDER_NOTIONAL_RUB` | сумма в рублях | `1000` | `> 0` включает минимум; `<= 0` фактически отключает | Минимальный notional заявки. Если рассчитанная позиция меньше, сигнал отклоняется по sizing. | + +Если средний `realized_edge_bps - expected_net_edge_bps` по последним 20 закрытым сделкам ниже `-10 bps`, scheduler пишет `risk_event(WARN, size_reduction_rule_triggered)` и до восстановления качества режет sizing до `0.5x`. + +### LIQ + +| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | +| --- | --- | --- | --- | --- | +| `LIQ_MIN_ADV_RUB` | сумма в рублях | `5000000` | рекомендуется `>= 0` | Минимальный средний дневной оборот за 20 дней. Выше - отсекает менее ликвидные фонды. | +| `LIQ_MAX_PARTICIPATION_RATE` | доля объёма | `0.01` | рекомендуется `0..1` | Максимальная доля объёма входного/выходного окна, которую может занять бот при sizing. Больше - крупнее позиции, но выше рыночное воздействие. | +| `LIQ_MAX_SPREAD_BPS_DEFAULT` | bps | `20` | рекомендуется `>= 0` | Максимальный spread для фондов без специальной категории. Ниже - строже фильтр ликвидности. | +| `LIQ_MAX_SPREAD_BPS_MONEY_MARKET` | bps | `5` | рекомендуется `>= 0` | Максимальный spread для money market фондов. | +| `LIQ_MAX_SPREAD_BPS_BOND_FUNDS` | bps | `10` | рекомендуется `>= 0` | Максимальный spread для bond/corporate bond фондов. | +| `LIQ_MAX_SPREAD_BPS_EQUITY_FUNDS` | bps | `25` | рекомендуется `>= 0` | Максимальный spread для equity фондов. | +| `LIQ_MAX_TICK_BPS` | bps | `10` | рекомендуется `>= 0` | Максимальный размер минимального шага цены относительно цены. Ниже - отсекает инструменты с грубым тиком. | + +### COMM + +| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | +| --- | --- | --- | --- | --- | +| `COMM_REQUIRE_ZERO_COMMISSION` | `true` или `false` | `true` | boolean | При `true` сигналы по инструментам с ожидаемой комиссией `> 0` отклоняются. | +| `COMM_QUARANTINE_ON_NONZERO` | `true` или `false` | `true` | boolean; сейчас не применяется | Зарезервировано под автоматический quarantine при ненулевой комиссии. На текущий код не влияет. | +| `COMM_FREE_ORDER_COUNT_POLICY` | `submitted` | `submitted` | жёстко только `submitted` | Политика учёта бесплатных заявок: счётчик увеличивается при отправке заявки. Другие значения запрещены валидацией. | + +### BT + +| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | +| --- | --- | --- | --- | --- | +| `BT_DATE_FROM` | дата `YYYY-MM-DD` | пусто | сейчас не применяется | Зарезервировано под фильтр периода backtest. На текущий `cmd/bot` и `cmd/backtest` не влияет. | +| `BT_DATE_TO` | дата `YYYY-MM-DD` | пусто | сейчас не применяется | Зарезервировано под фильтр периода backtest. На текущий `cmd/bot` и `cmd/backtest` не влияет. | +| `BT_ENTRY_SLIPPAGE_BPS` | bps | `8` | рекомендуется `>= 0` | Модельная издержка входа. Используется в расчёте `ExpectedCostBps`/`NetEdgeBps`; больше - строже отбор сигналов. | +| `BT_EXIT_SLIPPAGE_BPS` | bps | `8` | рекомендуется `>= 0` | Модельная издержка выхода. Больше - снижает ожидаемый net edge. | +| `BT_COMMISSION_ROUNDTRIP_BPS` | bps | `0` | рекомендуется `>= 0` | Модельная комиссия за полный круг. Увеличение снижает `NetEdgeBps`; при zero-commission политике ненулевые комиссии могут отсеивать сделки в backtest engine. | +| `BT_USE_MINUTE_MODEL` | `true` или `false` | `false` | boolean | Включает консервативную minute-модель backtest: лимитная цена должна быть достижима внутри минутной свечи, размер ограничивается participation cap по минутному объёму. В CLI соответствует `-use-minute-model` и требует `-minute-candles`. | +| `BT_OUTPUT_DIR` | путь к директории | `./backtest_out` | сейчас не применяется в `cmd/backtest`, где используется флаг `-out` | Зарезервированный ENV-путь для результатов backtest. | + +### LIVE + +| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | +| --- | --- | --- | --- | --- | +| `LIVE_TRADE_ACK` | ровно `I_ACCEPT_RISK` | пусто | обязателен только для `APP_MODE=live_trade` | Ручное подтверждение риска для режима реальной торговли. Без него `live_trade` не стартует. | + +## Commands ```sh make fmt +make vet +make lint make test -make run +make race +make build +go run ./cmd/migrate -direction=up +go run ./cmd/migrate up +go run ./cmd/backtest -candles candles.csv -out ./backtest_out +go run ./cmd/backtest -candles candles.csv -minute-candles minute.csv -use-minute-model -out ./backtest_out +go run ./cmd/bot -mode=paper +go run ./cmd/bot -unhalt -reason="manual reconciliation complete" +go run ./cmd/bot -healthcheck ``` -`config.yaml` не коммитится: в нем будут локальные настройки, account id и ссылки на переменные окружения с токенами. +Backtest CSV columns: + +```csv +instrument_uid,trade_date,open,high,low,close,volume_lots +TRUR,2024-01-09,100,101,99,100.5,10000 +``` + +Для minute-модели используется тот же формат, но `trade_date` может быть timestamp (`2024-01-09T18:25:00Z` или `2024-01-09 18:25:00`). + +`ClientOrderID` детерминирован по `(date, instrument_uid, side, attempt)` и содержит 8 hex символов SHA-256. Для дневного числа retry этого достаточно; при ручных массовых перезапусках с теми же параметрами id остаётся тем же, что намеренно подавляет дубли. + +## Deploy + +`.gitea/workflows/deploy.yml` собирает статический бинарь и кладёт его на сервер. Никакого Docker/Podman ни в CI, ни на сервере не используется — служба запускается напрямую через `systemd` внутри LXC-контейнера. БД (MariaDB/MySQL) живёт на отдельном хосте и подключается через `DB_DSN`. + +Pipeline на push в `master` (один job `deploy`): + +1. `actions/setup-go@v5` (версия из `go.mod`), `go mod download`, `go vet ./...`, `go test ./...`. +2. Кросс-компиляция `linux/amd64` с `CGO_ENABLED=0` и `-trimpath -ldflags="-s -w"` для `cmd/bot`, `cmd/migrate`, `cmd/backtest`. Бинари именуются `overnight-trading-bot`, `overnight-trading-bot-migrate`, `overnight-trading-bot-backtest` — чтобы безопасно жить в `/usr/local/bin`. +3. Сборка `out/release.tar.gz` с бинарями и `deploy/systemd/overnight-trading-bot.service`. +4. SSH-ключ из base64 → `~/.ssh/deploy_key`, `ssh-keyscan` целевого хоста. +5. `scp` архива в `/var/tmp/overnight-trading-bot-deploy/release.tar.gz` на сервере. +6. На сервере: проверка наличия env-файла, идемпотентное создание системного пользователя `overnight-bot`, распаковка во временный staging-каталог `/var/tmp/overnight-trading-bot-deploy/stage`, атомарная замена `/usr/local/bin/overnight-trading-bot{,-migrate,-backtest}` через `*.new` → `mv -f`, копирование systemd unit в `/etc/systemd/system/`, `systemctl daemon-reload && enable && restart`. После рестарта workflow ждёт `is-active` (до 60 с), затем проверяет `overnight-trading-bot -healthcheck` (до 30 с); при провале печатает последние 100 строк `journalctl` и завершается с ошибкой. + +Переменные Gitea: + +- `secrets.DEPLOY_HOST` - IP сервера. +- `secrets.DEPLOY_SSH_PRIVATE_KEY_BASE64` - приватный SSH-ключ root в base64 (`base64 -w0 < id_ed25519`). + +На сервере (debian 13 LXC) заранее должны быть установлены `systemd` (и стандартные утилиты `tar`, `useradd`, `journalctl` — все из coreutils/util-linux/systemd, в базовом образе debian 13 уже есть). Перед первым деплоем нужно создать production env-файл: + +```sh +install -d -m 0750 /etc/overnight-trading-bot +install -m 0640 .env.example /etc/overnight-trading-bot/overnight-trading-bot.env +``` + +В `/etc/overnight-trading-bot/overnight-trading-bot.env` нужно заменить `DB_DSN` на адрес внешней БД и заполнить секреты T-Invest/Telegram. Workflow при каждом деплое перевыставляет владельцем группу `overnight-bot` и режим `0640`, чтобы env читался службой, но не был world-readable. Если файла нет, workflow падает до перезапуска службы. + +Systemd unit (`deploy/systemd/overnight-trading-bot.service`) запускает `/usr/local/bin/overnight-trading-bot` под непривилегированным пользователем `overnight-bot` с базовым systemd hardening (`NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, пустой `CapabilityBoundingSet` и т.д.). Логи смотрятся через `journalctl -u overnight-trading-bot.service`. + +## Runbook + +API недоступен утром: + +1. Бот повторяет запросы с retry/backoff. +2. Если сбой дольше `RISK_API_OUTAGE_HALT_SEC`, состояние переводится в `HALTED`. +3. После восстановления сначала запускается reconciliation. +4. Ручной вывод из HALT: `go run ./cmd/bot -unhalt -reason="..."`. + +Позиция не закрыта до hard deadline: + +1. Scheduler отменяет активные sell-заявки, помечает незакрытые `HOLDING_OVERNIGHT`/`EXIT_ORDER_SENT`/`EXIT_PARTIALLY_FILLED` как `EXIT_FAILED` и отправляет critical alert. +2. Новые входы блокируются через HALT (`hard_exit_deadline_missed`). +3. Требуется ручная сверка брокерского портфеля, активных заявок и локальной БД; `-unhalt` выполнит reconciliation перед снятием HALT и откажется продолжать при critical diff. + +Ненулевая комиссия: + +1. Reconciliation фиксирует критическое расхождение по комиссии. +2. Бот уходит в `HALTED` через событие риска `reconciliation_critical`. +3. Инструмент нужно вручную перевести в quarantine или выключить до выяснения причины. Автоматический quarantine по `COMM_QUARANTINE_ON_NONZERO` сейчас не подключён. + +Превышен лимит риска при открытой позиции: + +1. `HALTED` блокирует любые новые заявки, включая автоматический exit. +2. Оператор делает ручную сверку брокерского портфеля, активных заявок и локальных `positions`/`orders`. +3. Если позицию нужно закрывать ботом, сначала выполняется `go run ./cmd/bot -unhalt -reason="manual reconciliation before exit"` после успешной reconciliation; если расхождения остаются, закрытие выполняется вручную у брокера и затем синхронизируется в БД. + +Полевой sandbox-чек времени сервера: + +1. Перед `live_readonly` выполнить sandbox-запуск, в котором `GetServerTime` получает `Date` из gRPC metadata. +2. Если SDK/API не возвращает `Date`, `GetServerTime` отдаёт явную ошибку; в `paper` она глушится, в API-режимах учитывается через `RISK_API_OUTAGE_HALT_SEC`. +3. До исправления источника server-time не переводить этот аккаунт в `live_trade`. + +## Live Preconditions + +Перед `live_trade` должны быть выполнены условия из ТЗ: минимум 20 торговых дней `live_readonly`, 20 дней `paper`, 10 дней `sandbox`, ручная проверка комиссий и whitelist, Telegram-тест, kill-switch-тест, успешный server-time check в sandbox и малый стартовый капитал. diff --git a/cmd/backtest/main.go b/cmd/backtest/main.go new file mode 100644 index 0000000..ac7b1ad --- /dev/null +++ b/cmd/backtest/main.go @@ -0,0 +1,133 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/backtest" + "overnight-trading-bot/internal/domain" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} + +func run() error { + candlesPath := flag.String("candles", "", "CSV with columns instrument_uid,trade_date,open,high,low,close,volume_lots") + minuteCandlesPath := flag.String("minute-candles", "", "optional minute CSV with the same columns; trade_date may be RFC3339") + outputDir := flag.String("out", "./backtest_out", "output directory") + useMinuteModel := flag.Bool("use-minute-model", false, "require minute candles for conservative limit-fill simulation") + entrySlip := flag.String("entry-slippage-bps", "8", "entry slippage in bps") + exitSlip := flag.String("exit-slippage-bps", "8", "exit slippage in bps") + commission := flag.String("commission-roundtrip-bps", "0", "roundtrip commission in bps") + rollingShort := flag.Int("rolling-short", 60, "short rolling window") + rollingLong := flag.Int("rolling-long", 252, "long rolling window") + ewmaLambda := flag.Float64("ewma-lambda", 0.08, "EWMA lambda") + minTStat := flag.String("min-tstat-60", "1.25", "minimum short-window t-stat") + minWinRate := flag.String("min-win-rate-60", "0.55", "minimum short-window win rate") + minNetEdge := flag.String("min-net-edge-bps", "10", "minimum net edge in bps") + minADV := flag.String("min-adv-rub", "5000000", "minimum ADV in RUB") + maxSpread := flag.String("max-spread-bps", "20", "maximum spread in bps") + maxTick := flag.String("max-tick-bps", "10", "maximum tick size in bps") + requireZeroCommission := flag.Bool("require-zero-commission", true, "reject trades when roundtrip commission is non-zero") + flag.Parse() + if *candlesPath == "" { + return fmt.Errorf("-candles is required") + } + file, err := os.Open(*candlesPath) + if err != nil { + return fmt.Errorf("open candles: %w", err) + } + defer func() { + _ = file.Close() + }() + candles, err := backtest.LoadCandlesCSV(file) + if err != nil { + return fmt.Errorf("load candles: %w", err) + } + var minuteCandles map[string][]domain.Candle + if *minuteCandlesPath != "" { + minuteFile, err := os.Open(*minuteCandlesPath) + if err != nil { + return fmt.Errorf("open minute candles: %w", err) + } + defer func() { + _ = minuteFile.Close() + }() + minuteCandles, err = backtest.LoadCandlesCSV(minuteFile) + if err != nil { + return fmt.Errorf("load minute candles: %w", err) + } + } + if *useMinuteModel && len(minuteCandles) == 0 { + return fmt.Errorf("-minute-candles is required when -use-minute-model=true") + } + entry, err := decimal.NewFromString(*entrySlip) + if err != nil { + return fmt.Errorf("entry slippage: %w", err) + } + exit, err := decimal.NewFromString(*exitSlip) + if err != nil { + return fmt.Errorf("exit slippage: %w", err) + } + comm, err := decimal.NewFromString(*commission) + if err != nil { + return fmt.Errorf("commission: %w", err) + } + tstat, err := decimal.NewFromString(*minTStat) + if err != nil { + return fmt.Errorf("min tstat: %w", err) + } + winRate, err := decimal.NewFromString(*minWinRate) + if err != nil { + return fmt.Errorf("min win rate: %w", err) + } + netEdge, err := decimal.NewFromString(*minNetEdge) + if err != nil { + return fmt.Errorf("min net edge: %w", err) + } + adv, err := decimal.NewFromString(*minADV) + if err != nil { + return fmt.Errorf("min adv: %w", err) + } + spread, err := decimal.NewFromString(*maxSpread) + if err != nil { + return fmt.Errorf("max spread: %w", err) + } + tick, err := decimal.NewFromString(*maxTick) + if err != nil { + return fmt.Errorf("max tick: %w", err) + } + engine := backtest.New(backtest.Config{ + EntrySlippageBps: entry, + ExitSlippageBps: exit, + CommissionRoundtripBps: comm, + OutputDir: *outputDir, + RollingShort: *rollingShort, + RollingLong: *rollingLong, + EWMALambda: *ewmaLambda, + MinTStat60: tstat, + MinWinRate60: winRate, + MinNetEdgeBps: netEdge, + MinADVRUB: adv, + MaxSpreadBps: spread, + MaxTickBps: tick, + RequireZeroCommission: *requireZeroCommission, + UseMinuteModel: *useMinuteModel, + }) + result, err := engine.RunWithMinuteCandles(candles, minuteCandles) + if err != nil { + return fmt.Errorf("run backtest: %w", err) + } + if err := result.Write(*outputDir); err != nil { + return fmt.Errorf("write result: %w", err) + } + fmt.Printf("backtest complete: trades=%d total_return=%.6f\n", result.Metrics.NumberOfTrades, result.Metrics.TotalReturn) + return nil +} diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 90ffb8b..2c51d16 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -10,12 +10,21 @@ import ( ) func main() { - configPath := flag.String("config", "config.yaml", "path to YAML configuration file") + mode := flag.String("mode", "", "override APP_MODE: backtest|paper|sandbox|live_readonly|live_trade") + unhalt := flag.Bool("unhalt", false, "manually clear HALT after reconciliation") + reason := flag.String("reason", "", "audit reason for -unhalt") + health := flag.Bool("healthcheck", false, "check local /health endpoint") + healthURL := flag.String("healthcheck-url", "", "healthcheck URL; default http://127.0.0.1:3300/health") flag.Parse() if err := app.Run(context.Background(), app.Options{ - ConfigPath: *configPath, - Stdout: os.Stdout, + Stdout: os.Stdout, + Stderr: os.Stderr, + ModeOverride: *mode, + Unhalt: *unhalt, + Reason: *reason, + Healthcheck: *health, + HealthcheckURL: *healthURL, }); err != nil { fmt.Fprintf(os.Stderr, "bot failed: %v\n", err) os.Exit(1) diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..454c6f3 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "github.com/jmoiron/sqlx" + + "overnight-trading-bot/internal/repository/mysql" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} + +func run() error { + dsn := flag.String("dsn", os.Getenv("DB_DSN"), "MySQL/MariaDB DSN") + direction := flag.String("direction", "up", "up or down") + flag.Parse() + if flag.NArg() > 0 { + *direction = flag.Arg(0) + } + if *dsn == "" { + return fmt.Errorf("DB_DSN is required") + } + db, err := sqlx.Open("mysql", *dsn) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer func() { + _ = db.Close() + }() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + return fmt.Errorf("ping db: %w", err) + } + switch *direction { + case "up": + err = mysql.ApplyMigrations(ctx, db.DB) + case "down": + err = mysql.RollbackAll(db.DB) + default: + err = fmt.Errorf("unknown direction %q", *direction) + } + if err != nil { + return fmt.Errorf("migrate: %w", err) + } + fmt.Println("migrations applied") + return nil +} diff --git a/config.example.yaml b/config.example.yaml deleted file mode 100644 index 6ddde04..0000000 --- a/config.example.yaml +++ /dev/null @@ -1,70 +0,0 @@ -app: - mode: paper - timezone: Europe/Moscow - -tinkoff: - token_env: T_INVEST_TOKEN - account_id_env: T_INVEST_ACCOUNT_ID - -risk: - max_position_rub: 10000 - min_net_edge_bps: 10 - risk_buffer_bps: 5 - max_spread_bps: 20 - max_tick_bps: 5 - min_adv_rub: 10000000 - -signal: - overnight_window_short: 60 - overnight_window_long: 252 - min_tstat_short: 1.25 - min_win_rate_short: 0.55 - ewma_lambda: 0.08 - -execution: - entry_window: "18:30-18:45" - exit_window: "10:15-10:45" - limit_order_only: true - -instruments: - - ticker: TRUR - enabled: true - expected_commission_bps_per_side: 0 - free_order_limit_per_day: 15 - fund_type: mixed - - ticker: TGLD - enabled: true - expected_commission_bps_per_side: 0 - free_order_limit_per_day: 15 - fund_type: commodity - - ticker: TBRU - enabled: true - expected_commission_bps_per_side: 0 - fund_type: bonds - - ticker: TDIV - enabled: true - expected_commission_bps_per_side: 0 - fund_type: equity_income - - ticker: TMON - enabled: true - expected_commission_bps_per_side: 0 - fund_type: money_market - - ticker: TOFZ - enabled: true - expected_commission_bps_per_side: 0 - fund_type: bonds - - ticker: TLCB - enabled: true - expected_commission_bps_per_side: 0 - fund_type: corporate_bonds - - ticker: TITR - enabled: true - expected_commission_bps_per_side: 0 - fund_type: equity - - ticker: TRND - enabled: true - expected_commission_bps_per_side: 0 - fund_type: equity - - ticker: TMOS - enabled: false - exclude_reason: "Excluded by default due to possible non-zero sell-side fee" diff --git a/deploy/systemd/overnight-trading-bot.service b/deploy/systemd/overnight-trading-bot.service new file mode 100644 index 0000000..e1d4ffa --- /dev/null +++ b/deploy/systemd/overnight-trading-bot.service @@ -0,0 +1,33 @@ +[Unit] +Description=Overnight Trading Bot +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=overnight-bot +Group=overnight-bot +EnvironmentFile=/etc/overnight-trading-bot/overnight-trading-bot.env +ExecStart=/usr/local/bin/overnight-trading-bot +Restart=always +RestartSec=10s +TimeoutStartSec=120s + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +CapabilityBoundingSet= +AmbientCapabilities= + +[Install] +WantedBy=multi-user.target diff --git a/go.mod b/go.mod index 26595bb..da12443 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,78 @@ module overnight-trading-bot go 1.26.2 + +require ( + github.com/caarlos0/env/v11 v11.4.1 + github.com/cenkalti/backoff/v4 v4.3.0 + github.com/go-sql-driver/mysql v1.10.0 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/jmoiron/sqlx v1.4.0 + github.com/russianinvestments/invest-api-go-sdk v1.40.1 + github.com/shopspring/decimal v1.4.0 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/mariadb v0.42.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + dario.cat/mergo v1.0.2 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0-rc.5 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.80.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6400a42 --- /dev/null +++ b/go.sum @@ -0,0 +1,191 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0-rc.5 h1:3IZOAnD058zZllQTZNBioTlrzrBG/IjpiZ133IEtusM= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0-rc.5/go.mod h1:xbKERva94Pw2cPen0s79J3uXmGzbbpDYFBFDlZ4mV/w= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russianinvestments/invest-api-go-sdk v1.40.1 h1:EZ9mA5fTlyspH8urdAMFXTzfUnMcVbCI3O0C88CrkUo= +github.com/russianinvestments/invest-api-go-sdk v1.40.1/go.mod h1:rOu2P3GMTQEkQxRpQfp+wK5k71c3SUDHIke3Ijr8cOU= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/mariadb v0.42.0 h1:ZfWUJSIDnbNgoLAXMV1fc7lqcxBIX3zdnhwjaVUo7N0= +github.com/testcontainers/testcontainers-go/modules/mariadb v0.42.0/go.mod h1:0kV+yHee7zAgp0yccydxjNnHvlC1EOavTLCeg/lnRBY= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/app/app.go b/internal/app/app.go index 2810d53..826f55b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,38 +2,353 @@ 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" + + "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 { - ConfigPath string - Stdout io.Writer + Stdout io.Writer + Stderr io.Writer + ModeOverride string + 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.ConfigPath == "" { - return errors.New("config path is required") + if opts.Healthcheck { + target := opts.HealthcheckURL + if target == "" { + target = "http://127.0.0.1:3300/health" + } + return healthcheck.CheckEndpoint(ctx, target) } - if opts.Stdout == nil { opts.Stdout = io.Discard } - - if _, err := os.Stat(opts.ConfigPath); err != nil { - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("config file %q does not exist; copy config.example.yaml to config.yaml and fill credentials", opts.ConfigPath) + 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) - return fmt.Errorf("check config file %q: %w", opts.ConfigPath, err) + if cfg.App.Mode == domain.ModeBacktest && !opts.Unhalt { + _, _ = fmt.Fprintf(opts.Stdout, "overnight trading bot initialized in %s mode\n", cfg.App.Mode) + return nil } - fmt.Fprintf(opts.Stdout, "overnight trading bot initialized with config %q\n", opts.ConfigPath) - return nil + 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) + 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) + 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) + 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 + } + + gateway, closer, err := buildGateway(ctx, cfg, log) + if err != nil { + return err + } + if closer != nil { + defer closer() + } + 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) + 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) + 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) + } + 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() + clock := timeutil.RealClock{Loc: cfg.Location} + runtime := buildScheduler(clock, sm, cfg, repo, gateway, notifier, recon, accountIDHash, log) + return runtime.Run(runCtx) +} + +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) + 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, + }, + 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.SetMaxQuoteAge(time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second) + 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, + TickInterval: 30 * time.Second, + EntrySignalTime: cfg.Execution.EntrySignalTime, + EntryWindowStart: cfg.Execution.EntryWindowStart, + EntryWindowEnd: cfg.Execution.EntryWindowEnd, + NoNewEntryAfter: cfg.Execution.NoNewEntryAfter, + ExitWatchStart: cfg.Execution.ExitWatchStart, + ExitWindowStart: cfg.Execution.ExitWindowStart, + ExitWindowEnd: cfg.Execution.ExitWindowEnd, + HardExitDeadline: cfg.Execution.HardExitDeadline, + 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, + }, services) +} + +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: + 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, + 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, + 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 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 + "/health" + } + if _, err := url.ParseRequestURI(addr); err == nil && strings.HasPrefix(addr, "http") { + return addr + } + return "http://" + addr + "/health" +} + +func PingDB(ctx context.Context, db *sql.DB) error { + return db.PingContext(ctx) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 8ec9a2f..7003bac 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -3,49 +3,29 @@ package app import ( "bytes" "context" - "os" "strings" "testing" ) -func TestRunRequiresConfigPath(t *testing.T) { - err := Run(context.Background(), Options{}) +func TestRunRequiresAppMode(t *testing.T) { + t.Setenv("APP_MODE", "") + err := Run(context.Background(), Options{RunOnce: true}) if err == nil { t.Fatal("expected error") } - - if !strings.Contains(err.Error(), "config path is required") { + if !strings.Contains(err.Error(), "APP_MODE") && !strings.Contains(err.Error(), "MODE") { t.Fatalf("unexpected error: %v", err) } } -func TestRunReportsMissingConfig(t *testing.T) { - err := Run(context.Background(), Options{ConfigPath: "missing.yaml"}) - if err == nil { - t.Fatal("expected error") - } - - if !strings.Contains(err.Error(), "does not exist") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestRunUsesExistingConfig(t *testing.T) { - touch := t.TempDir() + "/config.yaml" - if err := os.WriteFile(touch, []byte("instruments: []\n"), 0o600); err != nil { - t.Fatal(err) - } - +func TestRunBacktestModeWithoutDB(t *testing.T) { + t.Setenv("APP_MODE", "backtest") var stdout bytes.Buffer - err := Run(context.Background(), Options{ - ConfigPath: touch, - Stdout: &stdout, - }) + err := Run(context.Background(), Options{Stdout: &stdout, RunOnce: true}) if err != nil { t.Fatal(err) } - - if !strings.Contains(stdout.String(), "initialized") { - t.Fatalf("unexpected stdout: %q", stdout.String()) + if !strings.Contains(stdout.String(), "backtest") { + t.Fatalf("unexpected stdout: %s", stdout.String()) } } diff --git a/internal/backtest/engine.go b/internal/backtest/engine.go new file mode 100644 index 0000000..58f6574 --- /dev/null +++ b/internal/backtest/engine.go @@ -0,0 +1,563 @@ +package backtest + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/features" + "overnight-trading-bot/internal/money" + "overnight-trading-bot/internal/risk" +) + +type Config struct { + EntrySlippageBps decimal.Decimal + ExitSlippageBps decimal.Decimal + CommissionRoundtripBps decimal.Decimal + InitialEquity decimal.Decimal + OutputDir string + RollingShort int + RollingLong int + EWMALambda float64 + MinTStat60 decimal.Decimal + MinWinRate60 decimal.Decimal + MinNetEdgeBps decimal.Decimal + MinADVRUB decimal.Decimal + MaxSpreadBps decimal.Decimal + MaxTickBps decimal.Decimal + RequireZeroCommission bool + MaxPositions int + MaxPositionPct decimal.Decimal + MaxTotalExposurePct decimal.Decimal + MaxParticipationRate decimal.Decimal + CashUsageBuffer decimal.Decimal + RiskBudgetPct decimal.Decimal + MinOrderNotionalRUB decimal.Decimal + AssumedSpreadBps decimal.Decimal + AssumedTickBps decimal.Decimal + Lot int64 + UseMinuteModel bool +} + +type Trade struct { + InstrumentUID string `json:"instrument_uid"` + EntryDate string `json:"entry_date"` + ExitDate string `json:"exit_date"` + BuyPrice decimal.Decimal `json:"buy_price"` + SellPrice decimal.Decimal `json:"sell_price"` + Return decimal.Decimal `json:"return"` + Lots int64 `json:"lots"` + Notional decimal.Decimal `json:"notional"` + NetPnL decimal.Decimal `json:"net_pnl"` + SpreadBps decimal.Decimal `json:"spread_bps"` + SlippageBps decimal.Decimal `json:"slippage_bps"` + OvernightGap decimal.Decimal `json:"overnight_gap"` + CapacityRUB decimal.Decimal `json:"capacity_rub"` +} + +type Result struct { + Metrics Metrics `json:"metrics"` + Trades []Trade `json:"trades"` + Equity []Point `json:"equity"` +} + +type Point struct { + Date string `json:"date"` + Equity decimal.Decimal `json:"equity"` + Return decimal.Decimal `json:"return"` +} + +type Engine struct { + cfg Config +} + +func New(cfg Config) Engine { + cfg = cfg.withDefaults() + return Engine{cfg: cfg} +} + +func (cfg Config) withDefaults() Config { + if cfg.InitialEquity.IsZero() { + cfg.InitialEquity = decimal.NewFromInt(100_000) + } + if cfg.RollingShort == 0 { + cfg.RollingShort = 60 + } + if cfg.RollingLong == 0 { + cfg.RollingLong = 252 + } + if cfg.EWMALambda == 0 { + cfg.EWMALambda = 0.08 + } + if cfg.MinTStat60.IsZero() { + cfg.MinTStat60 = decimal.NewFromFloat(1.25) + } + if cfg.MinWinRate60.IsZero() { + cfg.MinWinRate60 = decimal.NewFromFloat(0.55) + } + if cfg.MinNetEdgeBps.IsZero() { + cfg.MinNetEdgeBps = decimal.NewFromInt(10) + } + if cfg.MinADVRUB.IsZero() { + cfg.MinADVRUB = decimal.NewFromInt(5_000_000) + } + if cfg.MaxSpreadBps.IsZero() { + cfg.MaxSpreadBps = decimal.NewFromInt(20) + } + if cfg.MaxTickBps.IsZero() { + cfg.MaxTickBps = decimal.NewFromInt(10) + } + if !cfg.RequireZeroCommission && cfg.CommissionRoundtripBps.IsZero() { + cfg.RequireZeroCommission = true + } + if cfg.MaxPositions == 0 { + cfg.MaxPositions = 5 + } + if cfg.MaxPositionPct.IsZero() { + cfg.MaxPositionPct = decimal.NewFromFloat(0.10) + } + if cfg.MaxTotalExposurePct.IsZero() { + cfg.MaxTotalExposurePct = decimal.NewFromFloat(0.50) + } + if cfg.MaxParticipationRate.IsZero() { + cfg.MaxParticipationRate = decimal.NewFromFloat(0.01) + } + if cfg.CashUsageBuffer.IsZero() { + cfg.CashUsageBuffer = decimal.NewFromFloat(0.95) + } + if cfg.RiskBudgetPct.IsZero() { + cfg.RiskBudgetPct = decimal.NewFromFloat(0.005) + } + if cfg.MinOrderNotionalRUB.IsZero() { + cfg.MinOrderNotionalRUB = decimal.NewFromInt(1000) + } + if cfg.Lot == 0 { + cfg.Lot = 1 + } + return cfg +} + +func (e Engine) Run(candlesByInstrument map[string][]domain.Candle) (Result, error) { + return e.RunWithMinuteCandles(candlesByInstrument, nil) +} + +func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Candle, minuteCandlesByInstrument map[string][]domain.Candle) (Result, error) { + prepared := prepareCandles(candlesByInstrument) + preparedMinutes := prepareCandles(minuteCandlesByInstrument) + candidatesByExitDate := make(map[string][]candidate) + for instrumentUID, candles := range prepared { + for i := 1; i < len(candles); i++ { + candidate, ok, err := e.evaluateCandidate(instrumentUID, candles, i) + if err != nil { + return Result{}, err + } + if ok { + candidatesByExitDate[candidate.exit.TradeDate.Format("2006-01-02")] = append(candidatesByExitDate[candidate.exit.TradeDate.Format("2006-01-02")], candidate) + } + } + } + dates := make([]string, 0, len(candidatesByExitDate)) + for date := range candidatesByExitDate { + dates = append(dates, date) + } + sort.Strings(dates) + + equity := e.cfg.InitialEquity + cash := e.cfg.InitialEquity + var trades []Trade + points := []Point{{Date: "START", Equity: equity}} + sizer := risk.NewSizer(risk.SizingConfig{ + MaxPositionPct: e.cfg.MaxPositionPct, + MaxTotalExposurePct: e.cfg.MaxTotalExposurePct, + MaxParticipationRate: e.cfg.MaxParticipationRate, + CashUsageBuffer: e.cfg.CashUsageBuffer, + RiskBudgetPerInstrumentPct: e.cfg.RiskBudgetPct, + MinOrderNotionalRUB: e.cfg.MinOrderNotionalRUB, + }) + for _, date := range dates { + dayCandidates := candidatesByExitDate[date] + sort.Slice(dayCandidates, func(i, j int) bool { + if dayCandidates[i].netEdge.Equal(dayCandidates[j].netEdge) { + return dayCandidates[i].instrumentUID < dayCandidates[j].instrumentUID + } + return dayCandidates[i].netEdge.GreaterThan(dayCandidates[j].netEdge) + }) + if len(dayCandidates) > e.cfg.MaxPositions { + dayCandidates = dayCandidates[:e.cfg.MaxPositions] + } + dayStartEquity := equity + dayPnL := decimal.Zero + for _, c := range dayCandidates { + sized := sizer.Size(risk.SizingInput{ + Portfolio: domain.Portfolio{Equity: equity, Cash: cash}, + SelectedInstruments: len(dayCandidates), + LimitPrice: c.buy, + Lot: e.cfg.Lot, + EntryIntervalVolume: c.adv, + ExitIntervalVolume: c.adv, + Q05OvernightAbs: c.q05Abs, + }) + if sized.Lots <= 0 { + continue + } + lots := sized.Lots + capacity := c.capacity + if e.cfg.UseMinuteModel { + executedLots, minuteCapacity, ok := e.minuteExecution(c, preparedMinutes[c.instrumentUID], sized.Lots) + if !ok { + continue + } + lots = executedLots + capacity = minuteCapacity + } + notional := c.buy.Mul(decimal.NewFromInt(lots)).Mul(decimal.NewFromInt(e.cfg.Lot)) + ret := c.sell.Div(c.buy).Sub(decimal.NewFromInt(1)).Sub(money.FromBps(e.cfg.CommissionRoundtripBps)) + pnl := notional.Mul(ret) + dayPnL = dayPnL.Add(pnl) + cash = cash.Sub(notional) + trades = append(trades, Trade{ + InstrumentUID: c.instrumentUID, + EntryDate: c.entry.TradeDate.Format("2006-01-02"), + ExitDate: c.exit.TradeDate.Format("2006-01-02"), + BuyPrice: c.buy, + SellPrice: c.sell, + Return: ret, + Lots: lots, + Notional: notional, + NetPnL: pnl, + SpreadBps: e.cfg.AssumedSpreadBps, + SlippageBps: e.cfg.EntrySlippageBps.Add(e.cfg.ExitSlippageBps), + OvernightGap: c.overnightGap, + CapacityRUB: capacity, + }) + } + if !dayPnL.IsZero() { + equity = equity.Add(dayPnL) + cash = equity + points = append(points, Point{ + Date: date, + Equity: equity, + Return: dayPnL.Div(dayStartEquity), + }) + } + } + sort.Slice(trades, func(i, j int) bool { + if trades[i].ExitDate == trades[j].ExitDate { + return trades[i].InstrumentUID < trades[j].InstrumentUID + } + return trades[i].ExitDate < trades[j].ExitDate + }) + return Result{ + Metrics: ComputeMetrics(points, trades), + Trades: trades, + Equity: points, + }, nil +} + +func (e Engine) minuteExecution(c candidate, minutes []domain.Candle, requestedLots int64) (int64, decimal.Decimal, bool) { + if requestedLots <= 0 || len(minutes) == 0 { + return 0, decimal.Zero, false + } + entryLots, entryCapacity := e.fillableMinuteLots(minutes, c.entry.TradeDate, c.buy, domain.SideBuy) + exitLots, exitCapacity := e.fillableMinuteLots(minutes, c.exit.TradeDate, c.sell, domain.SideSell) + lots := min(requestedLots, entryLots) + lots = min(lots, exitLots) + if lots <= 0 { + return 0, decimal.Zero, false + } + return lots, money.Min(entryCapacity, exitCapacity), true +} + +func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limitPrice decimal.Decimal, side domain.Side) (int64, decimal.Decimal) { + if !limitPrice.IsPositive() || e.cfg.Lot <= 0 { + return 0, decimal.Zero + } + lotNotional := limitPrice.Mul(decimal.NewFromInt(e.cfg.Lot)) + if !lotNotional.IsPositive() { + return 0, decimal.Zero + } + capacity := decimal.Zero + for _, candle := range minutes { + if !sameDate(candle.TradeDate, date) { + continue + } + reachable := side == domain.SideBuy && candle.Low.LessThanOrEqual(limitPrice) + reachable = reachable || side == domain.SideSell && candle.High.GreaterThanOrEqual(limitPrice) + if !reachable { + continue + } + minuteCapacity := candle.VolumeLots.Mul(lotNotional).Mul(e.cfg.MaxParticipationRate) + capacity = capacity.Add(minuteCapacity) + } + return capacity.Div(lotNotional).Floor().IntPart(), capacity +} + +type candidate struct { + instrumentUID string + entry domain.Candle + exit domain.Candle + buy decimal.Decimal + sell decimal.Decimal + netEdge decimal.Decimal + adv decimal.Decimal + q05Abs decimal.Decimal + overnightGap decimal.Decimal + capacity decimal.Decimal +} + +func (e Engine) evaluateCandidate(instrumentUID string, candles []domain.Candle, exitIndex int) (candidate, bool, error) { + if exitIndex < e.cfg.RollingShort || exitIndex <= 0 { + return candidate{}, false, nil + } + history := candles[:exitIndex] + returns := make([]float64, 0, len(history)-1) + for j := 1; j < len(history); j++ { + r, err := features.OvernightReturn(history[j].Open, history[j-1].Close) + if err != nil { + return candidate{}, false, err + } + rf, _ := r.Float64() + returns = append(returns, rf) + } + short := features.Rolling(returns, e.cfg.RollingShort, e.cfg.EWMALambda) + long := features.Rolling(returns, min(e.cfg.RollingLong, len(returns)), e.cfg.EWMALambda) + if !short.Available || !long.Available || short.StdDev == 0 { + return candidate{}, false, nil + } + rawEdge := decimal.NewFromFloat(short.Mean).Mul(decimal.NewFromInt(10_000)) + cost := e.cfg.AssumedSpreadBps. + Add(e.cfg.EntrySlippageBps). + Add(e.cfg.ExitSlippageBps). + Add(e.cfg.CommissionRoundtripBps) + netEdge := rawEdge.Sub(cost) + adv := features.ADV(history, e.cfg.Lot, 20) + switch { + case e.cfg.RequireZeroCommission && e.cfg.CommissionRoundtripBps.IsPositive(): + return candidate{}, false, nil + case !decimal.NewFromFloat(short.Mean).IsPositive() || !decimal.NewFromFloat(long.Mean).IsPositive(): + return candidate{}, false, nil + case decimal.NewFromFloat(short.TStat).LessThan(e.cfg.MinTStat60): + return candidate{}, false, nil + case decimal.NewFromFloat(short.WinRate).LessThan(e.cfg.MinWinRate60): + return candidate{}, false, nil + case netEdge.LessThan(e.cfg.MinNetEdgeBps): + return candidate{}, false, nil + case e.cfg.AssumedSpreadBps.GreaterThan(e.cfg.MaxSpreadBps): + return candidate{}, false, nil + case e.cfg.AssumedTickBps.GreaterThan(e.cfg.MaxTickBps): + return candidate{}, false, nil + case adv.LessThan(e.cfg.MinADVRUB): + return candidate{}, false, nil + } + entry := candles[exitIndex-1] + exit := candles[exitIndex] + buy := entry.Close.Mul(decimal.NewFromInt(1).Add(money.FromBps(e.cfg.EntrySlippageBps))) + sell := exit.Open.Mul(decimal.NewFromInt(1).Sub(money.FromBps(e.cfg.ExitSlippageBps))) + gap, err := features.OvernightReturn(exit.Open, entry.Close) + if err != nil { + return candidate{}, false, err + } + q05Abs := decimal.NewFromFloat(features.Quantile(returns, 0.05)) + if q05Abs.IsNegative() { + q05Abs = q05Abs.Neg() + } + return candidate{ + instrumentUID: instrumentUID, + entry: entry, + exit: exit, + buy: buy, + sell: sell, + netEdge: netEdge, + adv: adv, + q05Abs: q05Abs, + overnightGap: gap, + capacity: adv.Mul(e.cfg.MaxParticipationRate), + }, true, nil +} + +func prepareCandles(candlesByInstrument map[string][]domain.Candle) map[string][]domain.Candle { + prepared := make(map[string][]domain.Candle, len(candlesByInstrument)) + for instrumentUID, candles := range candlesByInstrument { + cp := append([]domain.Candle(nil), candles...) + sort.Slice(cp, func(i, j int) bool { + return cp[i].TradeDate.Before(cp[j].TradeDate) + }) + prepared[instrumentUID] = cp + } + return prepared +} + +func (r Result) Write(outputDir string) error { + if outputDir == "" { + outputDir = "./backtest_out" + } + if err := os.MkdirAll(outputDir, 0o750); err != nil { + return err + } + summary, err := json.MarshalIndent(r.Metrics, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outputDir, "summary.json"), summary, 0o600); err != nil { + return err + } + if err := writeTrades(filepath.Join(outputDir, "trades.csv"), r.Trades); err != nil { + return err + } + return writeEquity(filepath.Join(outputDir, "equity.csv"), r.Equity) +} + +func LoadCandlesCSV(reader io.Reader) (map[string][]domain.Candle, error) { + r := csv.NewReader(reader) + r.FieldsPerRecord = -1 + records, err := r.ReadAll() + if err != nil { + return nil, err + } + out := make(map[string][]domain.Candle) + for i, record := range records { + if i == 0 && len(record) > 0 && record[0] == "instrument_uid" { + continue + } + if len(record) < 7 { + return nil, fmt.Errorf("line %d: expected 7 fields", i+1) + } + date, err := parseCandleTime(record[1]) + if err != nil { + return nil, err + } + open, err := decimal.NewFromString(record[2]) + if err != nil { + return nil, err + } + high, err := decimal.NewFromString(record[3]) + if err != nil { + return nil, err + } + low, err := decimal.NewFromString(record[4]) + if err != nil { + return nil, err + } + closePrice, err := decimal.NewFromString(record[5]) + if err != nil { + return nil, err + } + volume, err := decimal.NewFromString(record[6]) + if err != nil { + return nil, err + } + candle := domain.Candle{ + InstrumentUID: record[0], + TradeDate: date, + Open: open, + High: high, + Low: low, + Close: closePrice, + VolumeLots: volume, + Source: "csv", + LoadedAt: time.Now().UTC(), + } + out[candle.InstrumentUID] = append(out[candle.InstrumentUID], candle) + } + return out, nil +} + +func parseCandleTime(raw string) (time.Time, error) { + layouts := []string{ + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + "2006-01-02", + } + var lastErr error + for _, layout := range layouts { + parsed, err := time.Parse(layout, raw) + if err == nil { + return parsed.UTC(), nil + } + lastErr = err + } + return time.Time{}, lastErr +} + +func sameDate(a, b time.Time) bool { + return dateOnly(a).Equal(dateOnly(b)) +} + +func dateOnly(t time.Time) time.Time { + y, m, d := t.UTC().Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +func writeTrades(path string, trades []Trade) error { + // #nosec G304 -- path is the user-selected backtest output destination. + f, err := os.Create(path) + if err != nil { + return err + } + defer func() { + _ = f.Close() + }() + w := csv.NewWriter(f) + defer w.Flush() + if err := w.Write([]string{"instrument_uid", "entry_date", "exit_date", "buy_price", "sell_price", "return", "lots", "notional", "net_pnl", "spread_bps", "slippage_bps", "overnight_gap", "capacity_rub"}); err != nil { + return err + } + for _, trade := range trades { + if err := w.Write([]string{ + trade.InstrumentUID, + trade.EntryDate, + trade.ExitDate, + trade.BuyPrice.String(), + trade.SellPrice.String(), + trade.Return.String(), + fmt.Sprintf("%d", trade.Lots), + trade.Notional.String(), + trade.NetPnL.String(), + trade.SpreadBps.String(), + trade.SlippageBps.String(), + trade.OvernightGap.String(), + trade.CapacityRUB.String(), + }); err != nil { + return err + } + } + return w.Error() +} + +func writeEquity(path string, points []Point) error { + // #nosec G304 -- path is the user-selected backtest output destination. + f, err := os.Create(path) + if err != nil { + return err + } + defer func() { + _ = f.Close() + }() + w := csv.NewWriter(f) + defer w.Flush() + if err := w.Write([]string{"date", "equity", "return"}); err != nil { + return err + } + for _, point := range points { + if err := w.Write([]string{point.Date, point.Equity.String(), point.Return.String()}); err != nil { + return err + } + } + return w.Error() +} + +func ParseDecimalFlag(raw string) (decimal.Decimal, error) { + if raw == "" { + return decimal.Zero, nil + } + return decimal.NewFromString(raw) +} diff --git a/internal/backtest/lookahead_test.go b/internal/backtest/lookahead_test.go new file mode 100644 index 0000000..806f143 --- /dev/null +++ b/internal/backtest/lookahead_test.go @@ -0,0 +1,70 @@ +package backtest + +import ( + "testing" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" +) + +func TestBacktestNoLookAheadWithFutureOnlyEdge(t *testing.T) { + var candles []domain.Candle + start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + for i := 0; i < 80; i++ { + open := decimal.NewFromInt(100) + if i == 79 { + open = decimal.NewFromInt(110) + } + candles = append(candles, domain.Candle{ + InstrumentUID: "uid", + TradeDate: start.AddDate(0, 0, i), + Open: open, + High: open, + Low: open, + Close: decimal.NewFromInt(100), + }) + } + result, err := New(Config{}).Run(map[string][]domain.Candle{"uid": candles}) + if err != nil { + t.Fatal(err) + } + if len(result.Trades) != 0 { + t.Fatalf("future-only edge leaked into signals: %d trades", len(result.Trades)) + } +} + +func TestMinuteExecutionRequiresReachableLimitAndParticipation(t *testing.T) { + engine := New(Config{ + Lot: 10, + MaxParticipationRate: decimal.NewFromFloat(0.10), + }) + entryDate := time.Date(2024, 1, 2, 18, 25, 0, 0, time.UTC) + exitDate := time.Date(2024, 1, 3, 10, 5, 0, 0, time.UTC) + c := candidate{ + instrumentUID: "uid", + entry: domain.Candle{TradeDate: entryDate}, + exit: domain.Candle{TradeDate: exitDate}, + buy: decimal.NewFromInt(100), + sell: decimal.NewFromInt(105), + } + minutes := []domain.Candle{ + {TradeDate: entryDate, Low: decimal.NewFromInt(99), High: decimal.NewFromInt(101), VolumeLots: decimal.NewFromInt(20)}, + {TradeDate: exitDate, Low: decimal.NewFromInt(104), High: decimal.NewFromInt(106), VolumeLots: decimal.NewFromInt(20)}, + } + lots, capacity, ok := engine.minuteExecution(c, minutes, 5) + if !ok { + t.Fatal("expected minute execution") + } + if lots != 2 { + t.Fatalf("lots=%d, want 2", lots) + } + if !capacity.Equal(decimal.NewFromInt(2000)) { + t.Fatalf("capacity=%s, want 2000", capacity) + } + c.sell = decimal.NewFromInt(110) + if _, _, ok := engine.minuteExecution(c, minutes, 5); ok { + t.Fatal("sell limit should be unreachable") + } +} diff --git a/internal/backtest/metrics.go b/internal/backtest/metrics.go new file mode 100644 index 0000000..462e153 --- /dev/null +++ b/internal/backtest/metrics.go @@ -0,0 +1,213 @@ +package backtest + +import ( + "math" + "sort" + + "github.com/shopspring/decimal" +) + +type Metrics struct { + TotalReturn float64 `json:"total_return"` + CAGR float64 `json:"cagr"` + AnnualizedVolatility float64 `json:"annualized_volatility"` + SharpeRatio float64 `json:"sharpe_ratio"` + SortinoRatio float64 `json:"sortino_ratio"` + MaxDrawdown float64 `json:"max_drawdown"` + CalmarRatio float64 `json:"calmar_ratio"` + WinRate float64 `json:"win_rate"` + AverageTradeReturn float64 `json:"average_trade_return"` + MedianTradeReturn float64 `json:"median_trade_return"` + ProfitFactor float64 `json:"profit_factor"` + AverageSpreadBps float64 `json:"average_spread_bps"` + AverageSlippageBps float64 `json:"average_slippage_bps"` + NumberOfTrades int `json:"number_of_trades"` + WorstOvernightGap float64 `json:"worst_overnight_gap"` + VaR95 float64 `json:"var_95"` + CVaR95 float64 `json:"cvar_95"` + CapacityEstimate float64 `json:"capacity_estimate"` +} + +func ComputeMetrics(points []Point, trades []Trade) Metrics { + if len(points) == 0 { + return Metrics{} + } + start, _ := points[0].Equity.Float64() + end, _ := points[len(points)-1].Equity.Float64() + returns := make([]float64, 0, len(points)-1) + for _, point := range points[1:] { + r, _ := point.Return.Float64() + returns = append(returns, r) + } + tradeReturns := make([]float64, 0, len(trades)) + spreads := make([]float64, 0, len(trades)) + slippages := make([]float64, 0, len(trades)) + profits := 0.0 + losses := 0.0 + wins := 0 + worstGap := 0.0 + capacity := 0.0 + for _, trade := range trades { + r, _ := trade.Return.Float64() + tradeReturns = append(tradeReturns, r) + spread, _ := trade.SpreadBps.Float64() + spreads = append(spreads, spread) + slippage, _ := trade.SlippageBps.Float64() + slippages = append(slippages, slippage) + if r > 0 { + wins++ + profits += r + } else { + losses += r + } + gap, _ := trade.OvernightGap.Float64() + if gap < worstGap { + worstGap = gap + } + tradeCapacity, _ := trade.CapacityRUB.Float64() + if tradeCapacity > 0 && (capacity == 0 || tradeCapacity < capacity) { + capacity = tradeCapacity + } + } + totalReturn := 0.0 + if start > 0 { + totalReturn = end/start - 1 + } + vol := stddev(returns) * math.Sqrt(252) + meanReturn := mean(returns) + sharpe := 0.0 + if std := stddev(returns); std > 0 { + sharpe = meanReturn / std * math.Sqrt(252) + } + sortino := 0.0 + if down := downsideStddev(returns); down > 0 { + sortino = meanReturn / down * math.Sqrt(252) + } + tradingDays := math.Max(float64(len(returns)), 1) + cagr := 0.0 + if start > 0 && end > 0 { + cagr = math.Pow(end/start, 252/tradingDays) - 1 + } + maxDD := maxDrawdown(points) + calmar := 0.0 + if maxDD != 0 { + calmar = cagr / math.Abs(maxDD) + } + pf := 0.0 + if losses != 0 { + pf = profits / math.Abs(losses) + } + var95 := percentile(returns, 0.05) + cvar95 := conditionalMean(returns, var95) + return Metrics{ + TotalReturn: totalReturn, + CAGR: cagr, + AnnualizedVolatility: vol, + SharpeRatio: sharpe, + SortinoRatio: sortino, + MaxDrawdown: maxDD, + CalmarRatio: calmar, + WinRate: ratio(wins, len(tradeReturns)), + AverageTradeReturn: mean(tradeReturns), + MedianTradeReturn: percentile(tradeReturns, 0.50), + ProfitFactor: pf, + AverageSpreadBps: mean(spreads), + AverageSlippageBps: mean(slippages), + NumberOfTrades: len(trades), + WorstOvernightGap: worstGap, + VaR95: var95, + CVaR95: cvar95, + CapacityEstimate: capacity, + } +} + +func maxDrawdown(points []Point) float64 { + peak := 0.0 + maxDD := 0.0 + for _, point := range points { + e, _ := point.Equity.Float64() + if e > peak { + peak = e + } + if peak > 0 { + dd := e/peak - 1 + if dd < maxDD { + maxDD = dd + } + } + } + return maxDD +} + +func mean(values []float64) float64 { + if len(values) == 0 { + return 0 + } + sum := 0.0 + for _, value := range values { + sum += value + } + return sum / float64(len(values)) +} + +func stddev(values []float64) float64 { + if len(values) < 2 { + return 0 + } + m := mean(values) + sum := 0.0 + for _, value := range values { + diff := value - m + sum += diff * diff + } + return math.Sqrt(sum / float64(len(values)-1)) +} + +func downsideStddev(values []float64) float64 { + var downs []float64 + for _, value := range values { + if value < 0 { + downs = append(downs, value) + } + } + return stddev(downs) +} + +func percentile(values []float64, q float64) float64 { + if len(values) == 0 { + return 0 + } + cp := append([]float64(nil), values...) + sort.Float64s(cp) + pos := q * float64(len(cp)-1) + lower := int(math.Floor(pos)) + upper := int(math.Ceil(pos)) + if lower == upper { + return cp[lower] + } + weight := pos - float64(lower) + return cp[lower]*(1-weight) + cp[upper]*weight +} + +func conditionalMean(values []float64, threshold float64) float64 { + var selected []float64 + for _, value := range values { + if value <= threshold { + selected = append(selected, value) + } + } + return mean(selected) +} + +func ratio(n, d int) float64 { + if d == 0 { + return 0 + } + return float64(n) / float64(d) +} + +func point(date string, equity, ret string) Point { + e, _ := decimal.NewFromString(equity) + r, _ := decimal.NewFromString(ret) + return Point{Date: date, Equity: e, Return: r} +} diff --git a/internal/backtest/metrics_test.go b/internal/backtest/metrics_test.go new file mode 100644 index 0000000..ca529f5 --- /dev/null +++ b/internal/backtest/metrics_test.go @@ -0,0 +1,14 @@ +package backtest + +import "testing" + +func TestMetrics(t *testing.T) { + got := ComputeMetrics([]Point{ + point("START", "100", "0"), + point("2024-01-02", "110", "0.10"), + point("2024-01-03", "99", "-0.10"), + }, []Trade{{Return: point("", "0", "0.10").Return}, {Return: point("", "0", "-0.10").Return}}) + if got.NumberOfTrades != 2 || got.WinRate != 0.5 || got.MaxDrawdown >= 0 || got.VaR95 >= 0 { + t.Fatalf("unexpected metrics: %+v", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3e0748c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,234 @@ +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 +} diff --git a/internal/domain/types.go b/internal/domain/types.go new file mode 100644 index 0000000..68ed72b --- /dev/null +++ b/internal/domain/types.go @@ -0,0 +1,311 @@ +package domain + +import ( + "fmt" + "strings" + "time" + + "github.com/shopspring/decimal" +) + +type Mode string + +const ( + ModeBacktest Mode = "backtest" + ModePaper Mode = "paper" + ModeSandbox Mode = "sandbox" + ModeLiveReadonly Mode = "live_readonly" + ModeLiveTrade Mode = "live_trade" +) + +func ParseMode(raw string) (Mode, error) { + mode := Mode(strings.TrimSpace(raw)) + switch mode { + case ModeBacktest, ModePaper, ModeSandbox, ModeLiveReadonly, ModeLiveTrade: + return mode, nil + default: + return "", fmt.Errorf("unsupported app mode %q", raw) + } +} + +func (m Mode) AllowsBrokerOrders() bool { + return m == ModeSandbox || m == ModeLiveTrade +} + +func (m *Mode) UnmarshalText(text []byte) error { + mode, err := ParseMode(string(text)) + if err != nil { + return err + } + *m = mode + return nil +} + +type Side string + +const ( + SideBuy Side = "BUY" + SideSell Side = "SELL" +) + +type OrderType string + +const ( + OrderTypeLimit OrderType = "LIMIT" +) + +type OrderStatus string + +const ( + OrderStatusNew OrderStatus = "NEW" + OrderStatusSent OrderStatus = "SENT" + OrderStatusPartiallyFilled OrderStatus = "PARTIALLY_FILLED" + OrderStatusFilled OrderStatus = "FILLED" + OrderStatusCancelled OrderStatus = "CANCELLED" + OrderStatusRejected OrderStatus = "REJECTED" + OrderStatusExpired OrderStatus = "EXPIRED" + OrderStatusFailed OrderStatus = "FAILED" +) + +type SignalDecision string + +const ( + DecisionEnter SignalDecision = "ENTER" + DecisionSkip SignalDecision = "SKIP" + DecisionReject SignalDecision = "REJECT" +) + +type PositionStatus string + +const ( + PositionNoPosition PositionStatus = "NO_POSITION" + PositionEntrySignalled PositionStatus = "ENTRY_SIGNALLED" + PositionEntryOrderSent PositionStatus = "ENTRY_ORDER_SENT" + PositionEntryPartiallyFilled PositionStatus = "ENTRY_PARTIALLY_FILLED" + PositionEntryFilled PositionStatus = "ENTRY_FILLED" + PositionHoldingOvernight PositionStatus = "HOLDING_OVERNIGHT" + PositionExitOrderSent PositionStatus = "EXIT_ORDER_SENT" + PositionExitPartiallyFilled PositionStatus = "EXIT_PARTIALLY_FILLED" + PositionExitFilled PositionStatus = "EXIT_FILLED" + PositionExitFailed PositionStatus = "EXIT_FAILED" + PositionQuarantine PositionStatus = "QUARANTINE" +) + +type SystemState string + +const ( + StateInit SystemState = "INIT" + StateSyncInstruments SystemState = "SYNC_INSTRUMENTS" + StateSyncMarketData SystemState = "SYNC_MARKET_DATA" + StateGenerateSignals SystemState = "GENERATE_SIGNALS" + StateWaitEntryWindow SystemState = "WAIT_ENTRY_WINDOW" + StatePlaceEntryOrders SystemState = "PLACE_ENTRY_ORDERS" + StateMonitorEntryOrders SystemState = "MONITOR_ENTRY_ORDERS" + StateHoldOvernight SystemState = "HOLD_OVERNIGHT" + StateWaitExitWindow SystemState = "WAIT_EXIT_WINDOW" + StatePlaceExitOrders SystemState = "PLACE_EXIT_ORDERS" + StateMonitorExitOrders SystemState = "MONITOR_EXIT_ORDERS" + StateReconcile SystemState = "RECONCILE" + StateReport SystemState = "REPORT" + StateSleep SystemState = "SLEEP" + StateHalted SystemState = "HALTED" +) + +type Severity string + +const ( + SeverityInfo Severity = "INFO" + SeverityWarn Severity = "WARN" + SeverityAlert Severity = "ALERT" + SeverityCritical Severity = "CRITICAL" +) + +type TradingStatus string + +const ( + TradingStatusNormal TradingStatus = "NORMAL_TRADING" + TradingStatusClosed TradingStatus = "CLOSED" + TradingStatusUnknown TradingStatus = "UNKNOWN" +) + +type Instrument struct { + InstrumentUID string + Figi string + Ticker string + ClassCode string + Name string + Lot int64 + MinPriceIncrement decimal.Decimal + Currency string + Enabled bool + FundType string + ExpectedCommissionBpsPerSide decimal.Decimal + FreeOrderLimitPerDay int + Quarantine bool + QuarantineReason string + ExcludeReason string + UpdatedAt time.Time +} + +func (i Instrument) MetadataValid() bool { + return i.InstrumentUID != "" && + !strings.HasPrefix(i.InstrumentUID, "PENDING:") && + i.Lot > 0 && + i.MinPriceIncrement.IsPositive() && + strings.EqualFold(i.Currency, "RUB") +} + +type Candle struct { + InstrumentUID string + TradeDate time.Time + Open decimal.Decimal + High decimal.Decimal + Low decimal.Decimal + Close decimal.Decimal + VolumeLots decimal.Decimal + Source string + LoadedAt time.Time +} + +type FeatureSet struct { + InstrumentUID string + TradeDate time.Time + ROn decimal.Decimal + RDay decimal.Decimal + MuOn60 decimal.Decimal + MuOn252 decimal.Decimal + SigmaOn60 decimal.Decimal + TStatOn60 decimal.Decimal + WinOn60 decimal.Decimal + EWMAOn decimal.Decimal + SpreadBps decimal.Decimal + HalfSpreadBps decimal.Decimal + TickBps decimal.Decimal + ADV20 decimal.Decimal + ExpectedCostBps decimal.Decimal + NetEdgeBps decimal.Decimal + EntryIntervalVolume decimal.Decimal + ExitIntervalVolume decimal.Decimal + CalculatedAt time.Time +} + +type Signal struct { + ID int64 + TradeDate time.Time + InstrumentUID string + Decision SignalDecision + Score decimal.Decimal + NetEdgeBps decimal.Decimal + TargetNotional decimal.Decimal + TargetLots int64 + RejectReason string + ContextJSON string + CreatedAt time.Time +} + +type Order struct { + ClientOrderID string + BrokerOrderID string + AccountIDHash string + InstrumentUID string + TradeDate time.Time + Side Side + OrderType OrderType + LimitPrice decimal.Decimal + QuantityLots int64 + FilledLots int64 + AvgFillPrice decimal.Decimal + Status OrderStatus + Commission decimal.Decimal + AttemptNo int + RawStateJSON string + CreatedAt time.Time + UpdatedAt time.Time +} + +type Position struct { + ID int64 + AccountIDHash string + InstrumentUID string + OpenTradeDate time.Time + Lots int64 + Lot int64 + ExitFilledLots int64 + AvgBuyPrice decimal.Decimal + AvgSellPrice decimal.Decimal + Status PositionStatus + GrossPnL decimal.Decimal + NetPnL decimal.Decimal + CommissionTotal decimal.Decimal + RealizedEdgeBps decimal.Decimal + OpenedAt *time.Time + ClosedAt *time.Time + UpdatedAt time.Time +} + +type RiskEvent struct { + ID int64 + TS time.Time + Severity Severity + EventType string + InstrumentUID string + Message string + ContextJSON string +} + +type Holding struct { + InstrumentUID string + QuantityLots int64 + AveragePrice decimal.Decimal + MarketValue decimal.Decimal +} + +type Portfolio struct { + Equity decimal.Decimal + Cash decimal.Decimal + Holdings []Holding + CheckedAt time.Time +} + +type OrderBookLevel struct { + Price decimal.Decimal + QuantityLots int64 +} + +type OrderBook struct { + InstrumentUID string + Bids []OrderBookLevel + Asks []OrderBookLevel + Time time.Time + ReceivedAt time.Time +} + +func (o OrderBook) BestBid() (decimal.Decimal, bool) { + if len(o.Bids) == 0 { + return decimal.Zero, false + } + return o.Bids[0].Price, true +} + +func (o OrderBook) BestAsk() (decimal.Decimal, bool) { + if len(o.Asks) == 0 { + return decimal.Zero, false + } + return o.Asks[0].Price, true +} + +type Operation struct { + ID string + InstrumentUID string + Type string + Payment decimal.Decimal + Commission decimal.Decimal + ExecutedAt time.Time +} + +type ReconciliationDiff struct { + Kind string + InstrumentUID string + Message string + Critical bool +} diff --git a/internal/execution/engine.go b/internal/execution/engine.go new file mode 100644 index 0000000..f84ea47 --- /dev/null +++ b/internal/execution/engine.go @@ -0,0 +1,405 @@ +package execution + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/repository" +) + +var ErrBrokerOrdersDisabled = errors.New("broker orders are disabled for current mode") +var ErrEmptyOrderBook = errors.New("order book has no usable bid/ask") + +type Gateway interface { + PostLimitOrder(ctx context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error) + CancelOrder(ctx context.Context, accountID, orderID string) error + GetOrderState(ctx context.Context, accountID, orderID string) (domain.Order, error) +} + +type Engine struct { + mode domain.Mode + accountID string + gateway Gateway + store repository.Repository + maxQuoteAge time.Duration + mu sync.Map +} + +type MonitorConfig struct { + Deadline time.Time + PollInterval time.Duration + MaxAttempts int + RepostAfter time.Duration + Instrument domain.Instrument + ImproveTicks int + Quote func(ctx context.Context, instrumentUID string) (domain.OrderBook, error) +} + +func NewEngine(mode domain.Mode, accountID string, gateway Gateway, store repository.Repository) Engine { + return Engine{mode: mode, accountID: accountID, gateway: gateway, store: store} +} + +func (e *Engine) SetMaxQuoteAge(maxQuoteAge time.Duration) { + e.maxQuoteAge = maxQuoteAge +} + +func (e *Engine) PlaceEntry(ctx context.Context, accountIDHash string, instrument domain.Instrument, tradeDate time.Time, lots int64, book domain.OrderBook, improveTicks int, attempt int) (domain.Order, error) { + if err := e.checkQuoteFresh(book); err != nil { + return domain.Order{}, err + } + bid, ask, err := bestBidAsk(book) + if err != nil { + return domain.Order{}, err + } + price, err := LimitBuyPrice(bid, ask, instrument.MinPriceIncrement, improveTicks) + if err != nil { + return domain.Order{}, err + } + return e.PlaceLimit(ctx, domain.Order{ + ClientOrderID: ClientOrderID(tradeDate, instrument.InstrumentUID, domain.SideBuy, attempt), + AccountIDHash: accountIDHash, + InstrumentUID: instrument.InstrumentUID, + TradeDate: tradeDate, + Side: domain.SideBuy, + OrderType: domain.OrderTypeLimit, + LimitPrice: price, + QuantityLots: lots, + Status: domain.OrderStatusNew, + AttemptNo: attempt, + RawStateJSON: "{}", + }) +} + +func (e *Engine) PlaceExit(ctx context.Context, accountIDHash string, instrument domain.Instrument, tradeDate time.Time, lots int64, book domain.OrderBook, improveTicks int, attempt int) (domain.Order, error) { + if err := e.checkQuoteFresh(book); err != nil { + return domain.Order{}, err + } + bid, ask, err := bestBidAsk(book) + if err != nil { + return domain.Order{}, err + } + price, err := LimitSellPrice(bid, ask, instrument.MinPriceIncrement, improveTicks) + if err != nil { + return domain.Order{}, err + } + return e.PlaceLimit(ctx, domain.Order{ + ClientOrderID: ClientOrderID(tradeDate, instrument.InstrumentUID, domain.SideSell, attempt), + AccountIDHash: accountIDHash, + InstrumentUID: instrument.InstrumentUID, + TradeDate: tradeDate, + Side: domain.SideSell, + OrderType: domain.OrderTypeLimit, + LimitPrice: price, + QuantityLots: lots, + Status: domain.OrderStatusNew, + AttemptNo: attempt, + RawStateJSON: "{}", + }) +} + +func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Order, error) { + if e.store != nil { + existing, err := e.findExisting(ctx, order) + if err != nil { + return domain.Order{}, err + } + if existing.ClientOrderID != "" { + return existing, nil + } + } + if !e.mode.AllowsBrokerOrders() { + order.Status = domain.OrderStatusNew + if e.store != nil { + return order, e.store.UpsertOrder(ctx, order) + } + return order, ErrBrokerOrdersDisabled + } + if e.gateway == nil { + return domain.Order{}, errors.New("gateway is nil") + } + lock := e.lockFor(order.InstrumentUID) + lock.Lock() + defer lock.Unlock() + + posted, err := e.gateway.PostLimitOrder(ctx, e.accountID, order.InstrumentUID, order.Side, order.QuantityLots, order.LimitPrice, order.ClientOrderID) + if err != nil { + order.Status = domain.OrderStatusFailed + if e.store != nil { + _ = e.store.UpsertOrder(ctx, order) + } + return domain.Order{}, err + } + posted.ClientOrderID = order.ClientOrderID + posted.AccountIDHash = order.AccountIDHash + posted.InstrumentUID = order.InstrumentUID + posted.Side = order.Side + posted.OrderType = order.OrderType + posted.LimitPrice = order.LimitPrice + posted.QuantityLots = order.QuantityLots + posted.AttemptNo = order.AttemptNo + posted.TradeDate = order.TradeDate + posted.CreatedAt = time.Now().UTC() + posted.UpdatedAt = posted.CreatedAt + if e.store != nil { + if err := e.store.RunInTx(ctx, func(ctx context.Context, repo repository.Repository) error { + if err := repo.UpsertOrder(ctx, posted); err != nil { + return fmt.Errorf("persist posted order: %w", err) + } + return repo.IncrementFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1) + }); err != nil { + return domain.Order{}, err + } + } + return posted, nil +} + +func (e *Engine) findExisting(ctx context.Context, order domain.Order) (domain.Order, error) { + orders, err := e.store.ListOrders(ctx, order.AccountIDHash, order.TradeDate, order.TradeDate) + if err != nil { + return domain.Order{}, err + } + for _, existing := range orders { + if existing.ClientOrderID == order.ClientOrderID && + existing.Status != domain.OrderStatusFailed && + existing.Status != domain.OrderStatusRejected { + return existing, nil + } + } + return domain.Order{}, nil +} + +func (e *Engine) Refresh(ctx context.Context, order domain.Order) (domain.Order, error) { + if e.gateway == nil { + return domain.Order{}, errors.New("gateway is nil") + } + lock := e.lockFor(order.InstrumentUID) + lock.Lock() + defer lock.Unlock() + state, err := e.gateway.GetOrderState(ctx, e.accountID, order.BrokerOrderID) + if err != nil { + return domain.Order{}, err + } + state.ClientOrderID = order.ClientOrderID + state.AccountIDHash = order.AccountIDHash + state.InstrumentUID = order.InstrumentUID + state.TradeDate = order.TradeDate + state.Side = order.Side + state.OrderType = order.OrderType + state.LimitPrice = order.LimitPrice + state.QuantityLots = order.QuantityLots + state.AttemptNo = order.AttemptNo + if e.store != nil { + if err := e.store.UpsertOrder(ctx, state); err != nil { + return domain.Order{}, err + } + } + return state, nil +} + +func (e *Engine) Cancel(ctx context.Context, order domain.Order) error { + if e.gateway == nil { + return errors.New("gateway is nil") + } + lock := e.lockFor(order.InstrumentUID) + lock.Lock() + defer lock.Unlock() + if err := e.gateway.CancelOrder(ctx, e.accountID, order.BrokerOrderID); err != nil { + return err + } + if e.store != nil { + return e.store.UpdateOrderStatus(ctx, order.ClientOrderID, domain.OrderStatusCancelled, order.FilledLots, order.RawStateJSON) + } + return nil +} + +func (e *Engine) MonitorUntil(ctx context.Context, order domain.Order, cfg MonitorConfig) (domain.Order, error) { + if cfg.PollInterval <= 0 { + cfg.PollInterval = 500 * time.Millisecond + } + if cfg.MaxAttempts <= 0 { + cfg.MaxAttempts = 1 + } + lastPost := time.Now() + current := order + aggregate := order + seen := map[string]domain.Order{order.ClientOrderID: order} + ticker := time.NewTicker(cfg.PollInterval) + defer ticker.Stop() + for { + previous := seen[current.ClientOrderID] + refreshed, err := e.Refresh(ctx, current) + if err != nil { + return aggregate, err + } + aggregate = mergeAggregateFill(aggregate, previous, refreshed) + seen[current.ClientOrderID] = refreshed + current = mergeOrderState(current, refreshed) + aggregate.Status = current.Status + aggregate.UpdatedAt = current.UpdatedAt + aggregate.RawStateJSON = current.RawStateJSON + if aggregate.FilledLots >= aggregate.QuantityLots { + aggregate.Status = domain.OrderStatusFilled + return aggregate, nil + } + if isTerminal(current.Status) { + return aggregate, nil + } + if !cfg.Deadline.IsZero() && !time.Now().Before(cfg.Deadline) { + if err := e.Cancel(ctx, current); err != nil { + return aggregate, err + } + aggregate.Status = domain.OrderStatusExpired + if e.store != nil { + if err := e.store.UpdateOrderStatus(ctx, current.ClientOrderID, aggregate.Status, current.FilledLots, current.RawStateJSON); err != nil { + return aggregate, err + } + } + return aggregate, nil + } + shouldRepost := cfg.RepostAfter > 0 && + time.Since(lastPost) >= cfg.RepostAfter && + current.AttemptNo < cfg.MaxAttempts && + aggregate.FilledLots < aggregate.QuantityLots && + cfg.Quote != nil + if shouldRepost { + next, err := e.repost(ctx, current, cfg, aggregate.QuantityLots-aggregate.FilledLots) + if err != nil { + return aggregate, err + } + current = next + seen[current.ClientOrderID] = current + lastPost = time.Now() + continue + } + select { + case <-ctx.Done(): + return aggregate, ctx.Err() + case <-ticker.C: + } + } +} + +func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConfig, remaining int64) (domain.Order, error) { + if err := e.Cancel(ctx, order); err != nil { + return domain.Order{}, err + } + if remaining <= 0 { + order.Status = domain.OrderStatusFilled + return order, nil + } + book, err := cfg.Quote(ctx, order.InstrumentUID) + if err != nil { + return domain.Order{}, err + } + attempt := order.AttemptNo + 1 + switch order.Side { + case domain.SideBuy: + return e.PlaceEntry(ctx, order.AccountIDHash, cfg.Instrument, order.TradeDate, remaining, book, cfg.ImproveTicks, attempt) + case domain.SideSell: + return e.PlaceExit(ctx, order.AccountIDHash, cfg.Instrument, order.TradeDate, remaining, book, cfg.ImproveTicks, attempt) + default: + return domain.Order{}, fmt.Errorf("unsupported side %s", order.Side) + } +} + +func (e *Engine) checkQuoteFresh(book domain.OrderBook) error { + if e.maxQuoteAge <= 0 { + return nil + } + receivedAt := book.ReceivedAt + if receivedAt.IsZero() { + receivedAt = book.Time + } + if receivedAt.IsZero() { + return fmt.Errorf("quote timestamp is missing") + } + age := time.Since(receivedAt) + if age > e.maxQuoteAge { + return fmt.Errorf("quote age %s exceeds %s", age, e.maxQuoteAge) + } + return nil +} + +func (e *Engine) lockFor(instrumentUID string) *sync.Mutex { + value, _ := e.mu.LoadOrStore(instrumentUID, &sync.Mutex{}) + lock, ok := value.(*sync.Mutex) + if !ok { + panic("execution lock has unexpected type") + } + return lock +} + +func bestBidAsk(book domain.OrderBook) (decimal.Decimal, decimal.Decimal, error) { + bid, ok := book.BestBid() + if !ok { + return decimal.Zero, decimal.Zero, ErrEmptyOrderBook + } + ask, ok := book.BestAsk() + if !ok { + return decimal.Zero, decimal.Zero, ErrEmptyOrderBook + } + return bid, ask, nil +} + +func isTerminal(status domain.OrderStatus) bool { + switch status { + case domain.OrderStatusFilled, domain.OrderStatusCancelled, domain.OrderStatusRejected, domain.OrderStatusExpired, domain.OrderStatusFailed: + return true + default: + return false + } +} + +func mergeOrderState(base, state domain.Order) domain.Order { + base.BrokerOrderID = state.BrokerOrderID + base.FilledLots = state.FilledLots + base.AvgFillPrice = state.AvgFillPrice + base.Status = state.Status + base.Commission = state.Commission + base.RawStateJSON = state.RawStateJSON + base.UpdatedAt = state.UpdatedAt + return base +} + +func mergeAggregateFill(aggregate, previous, current domain.Order) domain.Order { + deltaLots := current.FilledLots - previous.FilledLots + if deltaLots > 0 { + deltaAvg := fillDeltaAvg(previous, current, deltaLots) + previousValue := aggregate.AvgFillPrice.Mul(decimal.NewFromInt(aggregate.FilledLots)) + deltaValue := deltaAvg.Mul(decimal.NewFromInt(deltaLots)) + aggregate.FilledLots += deltaLots + aggregate.AvgFillPrice = previousValue.Add(deltaValue).Div(decimal.NewFromInt(aggregate.FilledLots)) + } + deltaCommission := current.Commission.Sub(previous.Commission) + if deltaCommission.IsPositive() { + aggregate.Commission = aggregate.Commission.Add(deltaCommission) + } + return aggregate +} + +func fillDeltaAvg(previous, current domain.Order, deltaLots int64) decimal.Decimal { + if deltaLots <= 0 { + return decimal.Zero + } + if previous.FilledLots <= 0 { + if current.AvgFillPrice.IsPositive() { + return current.AvgFillPrice + } + return current.LimitPrice + } + currentValue := current.AvgFillPrice.Mul(decimal.NewFromInt(current.FilledLots)) + previousValue := previous.AvgFillPrice.Mul(decimal.NewFromInt(previous.FilledLots)) + if currentValue.GreaterThan(previousValue) { + return currentValue.Sub(previousValue).Div(decimal.NewFromInt(deltaLots)) + } + if current.AvgFillPrice.IsPositive() { + return current.AvgFillPrice + } + return current.LimitPrice +} diff --git a/internal/execution/pricing.go b/internal/execution/pricing.go new file mode 100644 index 0000000..0d3b66f --- /dev/null +++ b/internal/execution/pricing.go @@ -0,0 +1,58 @@ +package execution + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strings" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/money" +) + +var nonIDChar = regexp.MustCompile(`[^A-Za-z0-9_-]+`) + +func LimitBuyPrice(bestBid, bestAsk, tick decimal.Decimal, improveTicks int) (decimal.Decimal, error) { + if improveTicks < 0 { + improveTicks = 0 + } + if !tick.IsPositive() { + return decimal.Zero, money.ErrInvalidTick + } + candidate := bestBid.Add(tick.Mul(decimal.NewFromInt(int64(improveTicks)))) + upper := bestAsk.Sub(tick) + if candidate.LessThanOrEqual(upper) { + return money.RoundToTick(candidate, tick, money.RoundFloor) + } + return money.RoundToTick(bestBid, tick, money.RoundFloor) +} + +func LimitSellPrice(bestBid, bestAsk, tick decimal.Decimal, improveTicks int) (decimal.Decimal, error) { + if improveTicks < 0 { + improveTicks = 0 + } + if !tick.IsPositive() { + return decimal.Zero, money.ErrInvalidTick + } + candidate := bestAsk.Sub(tick.Mul(decimal.NewFromInt(int64(improveTicks)))) + lower := bestBid.Add(tick) + if candidate.GreaterThanOrEqual(lower) { + return money.RoundToTick(candidate, tick, money.RoundCeil) + } + return money.RoundToTick(bestAsk, tick, money.RoundCeil) +} + +func ClientOrderID(tradeDate time.Time, instrumentUID string, side domain.Side, attempt int) string { + base := fmt.Sprintf("%s|%s|%s|%d", tradeDate.Format("20060102"), instrumentUID, side, attempt) + sum := sha256.Sum256([]byte(base)) + suffix := hex.EncodeToString(sum[:])[:8] + cleanUID := nonIDChar.ReplaceAllString(instrumentUID, "_") + if len(cleanUID) > 24 { + cleanUID = cleanUID[:24] + } + return strings.ToLower(fmt.Sprintf("otb-%s-%s-%s-%02d-%s", tradeDate.Format("20060102"), cleanUID, side, attempt, suffix)) +} diff --git a/internal/execution/pricing_test.go b/internal/execution/pricing_test.go new file mode 100644 index 0000000..fd3f916 --- /dev/null +++ b/internal/execution/pricing_test.go @@ -0,0 +1,49 @@ +package execution + +import ( + "testing" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" +) + +func ed(raw string) decimal.Decimal { + v, err := decimal.NewFromString(raw) + if err != nil { + panic(err) + } + return v +} + +func TestLimitPricesDoNotCross(t *testing.T) { + buy, err := LimitBuyPrice(ed("100"), ed("100.03"), ed("0.01"), 1) + if err != nil { + t.Fatal(err) + } + if !buy.Equal(ed("100.01")) { + t.Fatalf("buy=%s", buy) + } + sell, err := LimitSellPrice(ed("100"), ed("100.03"), ed("0.01"), 1) + if err != nil { + t.Fatal(err) + } + if !sell.Equal(ed("100.02")) { + t.Fatalf("sell=%s", sell) + } + tightBuy, _ := LimitBuyPrice(ed("100"), ed("100.01"), ed("0.01"), 1) + if !tightBuy.Equal(ed("100")) { + t.Fatalf("tight buy=%s", tightBuy) + } +} + +func TestClientOrderIDDeterministic(t *testing.T) { + date := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) + a := ClientOrderID(date, "uid", domain.SideBuy, 1) + b := ClientOrderID(date, "uid", domain.SideBuy, 1) + c := ClientOrderID(date, "uid", domain.SideBuy, 2) + if a != b || a == c { + t.Fatalf("unexpected ids: %s %s %s", a, b, c) + } +} diff --git a/internal/execution/state_test.go b/internal/execution/state_test.go new file mode 100644 index 0000000..9c55a58 --- /dev/null +++ b/internal/execution/state_test.go @@ -0,0 +1,137 @@ +package execution + +import ( + "context" + "testing" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/testutil" + "overnight-trading-bot/internal/tinvest" +) + +func TestClientOrderIDIncludesAttempt(t *testing.T) { + date := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) + first := ClientOrderID(date, "uid:TRUR", domain.SideBuy, 1) + second := ClientOrderID(date, "uid:TRUR", domain.SideBuy, 1) + third := ClientOrderID(date, "uid:TRUR", domain.SideBuy, 2) + if first != second { + t.Fatalf("client order id is not deterministic: %s != %s", first, second) + } + if first == third { + t.Fatalf("attempt is not part of client order id: %s", first) + } +} + +func TestPlaceLimitSuppressesDuplicateSubmit(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + gateway := tinvest.NewFakeGateway() + engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) + tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) + order := domain.Order{ + ClientOrderID: "order-1", + AccountIDHash: "hash", + InstrumentUID: "uid", + TradeDate: tradeDate, + Side: domain.SideBuy, + OrderType: domain.OrderTypeLimit, + LimitPrice: decimal.NewFromInt(100), + QuantityLots: 1, + Status: domain.OrderStatusNew, + AttemptNo: 1, + } + first, err := engine.PlaceLimit(ctx, order) + if err != nil { + t.Fatal(err) + } + second, err := engine.PlaceLimit(ctx, order) + if err != nil { + t.Fatal(err) + } + if first.BrokerOrderID != second.BrokerOrderID { + t.Fatalf("duplicate submit posted a new broker order: %s != %s", first.BrokerOrderID, second.BrokerOrderID) + } + if got := len(gateway.Orders); got != 1 { + t.Fatalf("broker posts=%d, want 1", got) + } + sent, err := repo.GetFreeOrdersSent(ctx, tradeDate, "uid") + if err != nil { + t.Fatal(err) + } + if sent != 1 { + t.Fatalf("free order counter=%d, want 1", sent) + } +} + +func TestPlaceEntryRejectsStaleQuote(t *testing.T) { + ctx := context.Background() + engine := NewEngine(domain.ModeSandbox, "account", tinvest.NewFakeGateway(), testutil.NewMemoryRepository()) + engine.SetMaxQuoteAge(time.Second) + _, err := engine.PlaceEntry(ctx, "hash", domain.Instrument{ + InstrumentUID: "uid", + Lot: 1, + MinPriceIncrement: decimal.NewFromInt(1), + }, time.Now().UTC(), 1, domain.OrderBook{ + InstrumentUID: "uid", + Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, + Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, + ReceivedAt: time.Now().UTC().Add(-2 * time.Second), + }, 1, 1) + if err == nil { + t.Fatal("expected stale quote error") + } +} + +func TestMonitorUntilRepostsAndExpiresAtDeadline(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + gateway := tinvest.NewFakeGateway() + engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) + instrument := domain.Instrument{ + InstrumentUID: "uid", + Lot: 1, + MinPriceIncrement: decimal.NewFromInt(1), + } + book := domain.OrderBook{ + InstrumentUID: "uid", + Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, + Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, + ReceivedAt: time.Now().UTC(), + } + tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) + order, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 3, book, 1, 1) + if err != nil { + t.Fatal(err) + } + monitored, err := engine.MonitorUntil(ctx, order, MonitorConfig{ + Deadline: time.Now().Add(20 * time.Millisecond), + PollInterval: time.Millisecond, + MaxAttempts: 2, + RepostAfter: time.Nanosecond, + Instrument: instrument, + ImproveTicks: 1, + Quote: func(context.Context, string) (domain.OrderBook, error) { + book.ReceivedAt = time.Now().UTC() + return book, nil + }, + }) + if err != nil { + t.Fatal(err) + } + if monitored.Status != domain.OrderStatusExpired { + t.Fatalf("status=%s, want EXPIRED", monitored.Status) + } + if got := len(gateway.Orders); got < 2 { + t.Fatalf("broker orders=%d, want repost attempt", got) + } + sent, err := repo.GetFreeOrdersSent(ctx, tradeDate, "uid") + if err != nil { + t.Fatal(err) + } + if sent != 2 { + t.Fatalf("free order counter=%d, want 2", sent) + } +} diff --git a/internal/features/pipeline.go b/internal/features/pipeline.go new file mode 100644 index 0000000..70944f4 --- /dev/null +++ b/internal/features/pipeline.go @@ -0,0 +1,148 @@ +package features + +import ( + "context" + "fmt" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/repository" + "overnight-trading-bot/internal/timeutil" +) + +type PipelineConfig struct { + RollingShort int + RollingLong int + EWMALambda float64 + RiskBufferBps decimal.Decimal + EntrySlippageBps decimal.Decimal + ExitSlippageBps decimal.Decimal + CommissionRoundtripBps decimal.Decimal + EntryWindow timeutil.Window + ExitWindow timeutil.Window + Location *time.Location +} + +type Pipeline struct { + repo repository.Repository + cfg PipelineConfig +} + +func NewPipeline(repo repository.Repository, cfg PipelineConfig) Pipeline { + return Pipeline{repo: repo, cfg: cfg} +} + +func (p Pipeline) Recompute(ctx context.Context, instrument domain.Instrument, tradeDate time.Time, spread SpreadResult) (domain.FeatureSet, error) { + from := tradeDate.AddDate(0, 0, -p.cfg.RollingLong-5) + candles, err := p.repo.ListDailyCandles(ctx, instrument.InstrumentUID, from, tradeDate) + if err != nil { + return domain.FeatureSet{}, err + } + entryVolume, err := p.intervalVolume(ctx, instrument, tradeDate, p.cfg.EntryWindow) + if err != nil { + return domain.FeatureSet{}, err + } + exitVolume, err := p.intervalVolume(ctx, instrument, tradeDate.AddDate(0, 0, 1), p.cfg.ExitWindow) + if err != nil { + return domain.FeatureSet{}, err + } + feature, err := Compute(instrument, candles, tradeDate, spread, p.cfg, entryVolume, exitVolume) + if err != nil { + return domain.FeatureSet{}, err + } + if err := p.repo.UpsertFeature(ctx, feature); err != nil { + return domain.FeatureSet{}, err + } + return feature, nil +} + +func (p Pipeline) intervalVolume(ctx context.Context, instrument domain.Instrument, date time.Time, window timeutil.Window) (decimal.Decimal, error) { + if window.Start.Duration == 0 && window.End.Duration == 0 { + return decimal.Zero, nil + } + loc := p.cfg.Location + if loc == nil { + loc = time.UTC + } + from := window.Start.On(date, loc).UTC() + to := window.End.On(date, loc).UTC() + candles, err := p.repo.ListMinuteCandles(ctx, instrument.InstrumentUID, from, to) + if err != nil { + return decimal.Zero, err + } + return IntervalVolume(candles, instrument.Lot), nil +} + +func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate time.Time, spread SpreadResult, cfg PipelineConfig, entryVolume, exitVolume decimal.Decimal) (domain.FeatureSet, error) { + if len(candles) < 2 { + return domain.FeatureSet{}, fmt.Errorf("need at least 2 candles, got %d", len(candles)) + } + var overnight []float64 + var lastROn decimal.Decimal + var lastRDay decimal.Decimal + for i := 1; i < len(candles); i++ { + rOn, err := OvernightReturn(candles[i].Open, candles[i-1].Close) + if err != nil { + return domain.FeatureSet{}, err + } + rDay, err := IntradayReturn(candles[i].Close, candles[i].Open) + if err != nil { + return domain.FeatureSet{}, err + } + onFloat, _ := rOn.Float64() + overnight = append(overnight, onFloat) + lastROn = rOn + lastRDay = rDay + } + short := Rolling(overnight, cfg.RollingShort, cfg.EWMALambda) + long := Rolling(overnight, cfg.RollingLong, cfg.EWMALambda) + adv := ADV(candles, instrument.Lot, 20) + rawEdgeBps := decimal.NewFromFloat(short.Mean).Mul(decimal.NewFromInt(10_000)) + if !entryVolume.IsPositive() { + entryVolume = adv + } + if !exitVolume.IsPositive() { + exitVolume = adv + } + instrumentCommission := instrument.ExpectedCommissionBpsPerSide.Mul(decimal.NewFromInt(2)) + expectedCost := spread.SpreadBps. + Add(cfg.EntrySlippageBps). + Add(cfg.ExitSlippageBps). + Add(cfg.CommissionRoundtripBps). + Add(instrumentCommission). + Add(cfg.RiskBufferBps) + return domain.FeatureSet{ + InstrumentUID: instrument.InstrumentUID, + TradeDate: tradeDate, + ROn: lastROn, + RDay: lastRDay, + MuOn60: decimal.NewFromFloat(short.Mean), + MuOn252: decimal.NewFromFloat(long.Mean), + SigmaOn60: decimal.NewFromFloat(short.StdDev), + TStatOn60: decimal.NewFromFloat(short.TStat), + WinOn60: decimal.NewFromFloat(short.WinRate), + EWMAOn: decimal.NewFromFloat(short.EWMA), + SpreadBps: spread.SpreadBps, + HalfSpreadBps: spread.HalfSpreadBps, + TickBps: spread.TickBps, + ADV20: adv, + ExpectedCostBps: expectedCost, + NetEdgeBps: rawEdgeBps.Sub(expectedCost), + EntryIntervalVolume: entryVolume, + ExitIntervalVolume: exitVolume, + CalculatedAt: time.Now().UTC(), + }, nil +} + +func IntervalVolume(candles []domain.Candle, lot int64) decimal.Decimal { + if lot <= 0 { + return decimal.Zero + } + total := decimal.Zero + for _, candle := range candles { + total = total.Add(candle.VolumeLots.Mul(decimal.NewFromInt(lot)).Mul(candle.Close)) + } + return total +} diff --git a/internal/features/pipeline_test.go b/internal/features/pipeline_test.go new file mode 100644 index 0000000..472efb5 --- /dev/null +++ b/internal/features/pipeline_test.go @@ -0,0 +1,57 @@ +package features + +import ( + "testing" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" +) + +func TestComputeExpectedCostIncludesCommissionAndSlippage(t *testing.T) { + var candles []domain.Candle + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for i := 0; i < 6; i++ { + price := decimal.NewFromInt(int64(100 + i)) + candles = append(candles, domain.Candle{ + InstrumentUID: "uid", + TradeDate: start.AddDate(0, 0, i), + Open: price, + Close: price, + VolumeLots: decimal.NewFromInt(1000), + }) + } + got, err := Compute(domain.Instrument{ + InstrumentUID: "uid", + Lot: 1, + ExpectedCommissionBpsPerSide: decimal.NewFromInt(1), + }, candles, start.AddDate(0, 0, 5), SpreadResult{SpreadBps: decimal.NewFromInt(10)}, PipelineConfig{ + RollingShort: 2, + RollingLong: 2, + EWMALambda: 0.08, + RiskBufferBps: decimal.NewFromInt(5), + EntrySlippageBps: decimal.NewFromInt(2), + ExitSlippageBps: decimal.NewFromInt(3), + CommissionRoundtripBps: decimal.NewFromInt(4), + }, decimal.NewFromInt(10000), decimal.NewFromInt(9000)) + if err != nil { + t.Fatal(err) + } + if !got.ExpectedCostBps.Equal(decimal.NewFromInt(26)) { + t.Fatalf("expected cost=%s, want 26", got.ExpectedCostBps) + } + if !got.EntryIntervalVolume.Equal(decimal.NewFromInt(10000)) || !got.ExitIntervalVolume.Equal(decimal.NewFromInt(9000)) { + t.Fatalf("interval volumes were not preserved: %+v", got) + } +} + +func TestIntervalVolume(t *testing.T) { + got := IntervalVolume([]domain.Candle{ + {Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(10)}, + {Close: decimal.NewFromInt(101), VolumeLots: decimal.NewFromInt(20)}, + }, 2) + if !got.Equal(decimal.NewFromInt(6040)) { + t.Fatalf("interval volume=%s, want 6040", got) + } +} diff --git a/internal/features/returns.go b/internal/features/returns.go new file mode 100644 index 0000000..3f4cd71 --- /dev/null +++ b/internal/features/returns.go @@ -0,0 +1,207 @@ +package features + +import ( + "errors" + "math" + "sort" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/money" +) + +var ErrInvalidPrice = errors.New("price must be positive") + +func OvernightReturn(open, previousClose decimal.Decimal) (decimal.Decimal, error) { + if !open.IsPositive() || !previousClose.IsPositive() { + return decimal.Zero, ErrInvalidPrice + } + return open.Div(previousClose).Sub(decimal.NewFromInt(1)), nil +} + +func IntradayReturn(close, open decimal.Decimal) (decimal.Decimal, error) { + if !close.IsPositive() || !open.IsPositive() { + return decimal.Zero, ErrInvalidPrice + } + return close.Div(open).Sub(decimal.NewFromInt(1)), nil +} + +func LogReturn(to, from decimal.Decimal) (float64, error) { + if !to.IsPositive() || !from.IsPositive() { + return 0, ErrInvalidPrice + } + ratio, _ := to.Div(from).Float64() + return math.Log(ratio), nil +} + +func CumulativeLinear(returns []decimal.Decimal) decimal.Decimal { + total := decimal.NewFromInt(1) + for _, r := range returns { + total = total.Mul(decimal.NewFromInt(1).Add(r)) + } + return total.Sub(decimal.NewFromInt(1)) +} + +func CumulativeLog(logReturns []float64) float64 { + sum := 0.0 + for _, r := range logReturns { + sum += r + } + return math.Exp(sum) - 1 +} + +type RollingResult struct { + Mean float64 + StdDev float64 + TStat float64 + WinRate float64 + EWMA float64 + Available bool +} + +func Rolling(values []float64, window int, lambda float64) RollingResult { + if window <= 0 || len(values) < window { + return RollingResult{} + } + sample := values[len(values)-window:] + mean := Mean(sample) + std := StdDev(sample) + win := WinRate(sample) + ewma := EWMA(values, lambda) + res := RollingResult{ + Mean: mean, + StdDev: std, + WinRate: win, + EWMA: ewma, + Available: true, + } + if std > 0 { + res.TStat = mean / std * math.Sqrt(float64(window)) + } + return res +} + +func Mean(values []float64) float64 { + if len(values) == 0 { + return 0 + } + sum := 0.0 + for _, value := range values { + sum += value + } + return sum / float64(len(values)) +} + +func StdDev(values []float64) float64 { + if len(values) < 2 { + return 0 + } + mean := Mean(values) + sum := 0.0 + for _, value := range values { + diff := value - mean + sum += diff * diff + } + return math.Sqrt(sum / float64(len(values)-1)) +} + +func WinRate(values []float64) float64 { + if len(values) == 0 { + return 0 + } + wins := 0 + for _, value := range values { + if value > 0 { + wins++ + } + } + return float64(wins) / float64(len(values)) +} + +func EWMA(values []float64, lambda float64) float64 { + if len(values) == 0 { + return 0 + } + if lambda <= 0 || lambda > 1 { + lambda = 0.08 + } + ewma := values[0] + for _, value := range values[1:] { + ewma = lambda*value + (1-lambda)*ewma + } + return ewma +} + +type SpreadResult struct { + SpreadAbs decimal.Decimal + SpreadBps decimal.Decimal + HalfSpreadBps decimal.Decimal + TickBps decimal.Decimal + Mid decimal.Decimal +} + +func Spread(bestBid, bestAsk, tick decimal.Decimal) (SpreadResult, error) { + if !bestBid.IsPositive() || !bestAsk.IsPositive() || bestAsk.LessThanOrEqual(bestBid) { + return SpreadResult{}, ErrInvalidPrice + } + mid := bestAsk.Add(bestBid).Div(decimal.NewFromInt(2)) + spreadAbs := bestAsk.Sub(bestBid) + spreadBps, err := money.Bps(spreadAbs, mid) + if err != nil { + return SpreadResult{}, err + } + tickBps := decimal.Zero + if tick.IsPositive() { + tickBps, err = money.Bps(tick, mid) + if err != nil { + return SpreadResult{}, err + } + } + return SpreadResult{ + SpreadAbs: spreadAbs, + SpreadBps: spreadBps, + HalfSpreadBps: spreadBps.Div(decimal.NewFromInt(2)), + TickBps: tickBps, + Mid: mid, + }, nil +} + +func ADV(candles []domain.Candle, lot int64, window int) decimal.Decimal { + if lot <= 0 || window <= 0 || len(candles) == 0 { + return decimal.Zero + } + sort.Slice(candles, func(i, j int) bool { + return candles[i].TradeDate.Before(candles[j].TradeDate) + }) + if len(candles) > window { + candles = candles[len(candles)-window:] + } + total := decimal.Zero + for _, candle := range candles { + total = total.Add(candle.VolumeLots.Mul(decimal.NewFromInt(lot)).Mul(candle.Close)) + } + return total.Div(decimal.NewFromInt(int64(len(candles)))) +} + +func Quantile(values []float64, q float64) float64 { + if len(values) == 0 { + return 0 + } + cp := append([]float64(nil), values...) + sort.Float64s(cp) + if q <= 0 { + return cp[0] + } + if q >= 1 { + return cp[len(cp)-1] + } + pos := q * float64(len(cp)-1) + lower := int(math.Floor(pos)) + upper := int(math.Ceil(pos)) + if lower == upper { + return cp[lower] + } + weight := pos - float64(lower) + return cp[lower]*(1-weight) + cp[upper]*weight +} diff --git a/internal/features/returns_test.go b/internal/features/returns_test.go new file mode 100644 index 0000000..9d79c0c --- /dev/null +++ b/internal/features/returns_test.go @@ -0,0 +1,38 @@ +package features + +import ( + "math" + "testing" + + "github.com/shopspring/decimal" +) + +func dec(raw string) decimal.Decimal { + v, err := decimal.NewFromString(raw) + if err != nil { + panic(err) + } + return v +} + +func TestReturnsAndLogIdentity(t *testing.T) { + rOn, err := OvernightReturn(dec("102"), dec("100")) + if err != nil { + t.Fatal(err) + } + if !rOn.Equal(dec("0.02")) { + t.Fatalf("overnight return=%s", rOn) + } + rDay, err := IntradayReturn(dec("105"), dec("102")) + if err != nil { + t.Fatal(err) + } + if !rDay.Round(10).Equal(dec("0.0294117647")) { + t.Fatalf("intraday return=%s", rDay) + } + linear := CumulativeLinear([]decimal.Decimal{dec("0.01"), dec("-0.02"), dec("0.03")}) + logs := []float64{math.Log(1.01), math.Log(0.98), math.Log(1.03)} + if math.Abs(linear.InexactFloat64()-CumulativeLog(logs)) > 1e-10 { + t.Fatalf("linear/log cumulative mismatch") + } +} diff --git a/internal/features/rolling_test.go b/internal/features/rolling_test.go new file mode 100644 index 0000000..0dcb731 --- /dev/null +++ b/internal/features/rolling_test.go @@ -0,0 +1,30 @@ +package features + +import ( + "math" + "testing" +) + +func TestRollingStats(t *testing.T) { + values := []float64{0.01, -0.01, 0.02, 0.03} + got := Rolling(values, 4, 0.5) + if !got.Available { + t.Fatal("expected rolling result") + } + if math.Abs(got.Mean-0.0125) > 1e-12 { + t.Fatalf("mean=%f", got.Mean) + } + if math.Abs(got.WinRate-0.75) > 1e-12 { + t.Fatalf("win=%f", got.WinRate) + } + if got.StdDev <= 0 || got.TStat <= 0 { + t.Fatalf("std/tstat invalid: %+v", got) + } +} + +func TestRollingSigmaZero(t *testing.T) { + got := Rolling([]float64{0.01, 0.01, 0.01}, 3, 0.08) + if got.StdDev != 0 || got.TStat != 0 { + t.Fatalf("expected zero sigma/tstat, got %+v", got) + } +} diff --git a/internal/features/spread_test.go b/internal/features/spread_test.go new file mode 100644 index 0000000..cd0155a --- /dev/null +++ b/internal/features/spread_test.go @@ -0,0 +1,13 @@ +package features + +import "testing" + +func TestSpread(t *testing.T) { + got, err := Spread(dec("99"), dec("101"), dec("0.1")) + if err != nil { + t.Fatal(err) + } + if !got.Mid.Equal(dec("100")) || !got.SpreadBps.Equal(dec("200")) || !got.HalfSpreadBps.Equal(dec("100")) || !got.TickBps.Equal(dec("10")) { + t.Fatalf("unexpected spread: %+v", got) + } +} diff --git a/internal/healthcheck/healthcheck.go b/internal/healthcheck/healthcheck.go new file mode 100644 index 0000000..49c31bc --- /dev/null +++ b/internal/healthcheck/healthcheck.go @@ -0,0 +1,109 @@ +package healthcheck + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "time" + + "overnight-trading-bot/internal/timeutil" + "overnight-trading-bot/internal/tinvest" +) + +type Service struct { + db *sql.DB + gateway tinvest.Gateway + maxDrift time.Duration + server *http.Server +} + +func New(db *sql.DB, gateway tinvest.Gateway, maxDrift time.Duration) *Service { + return &Service{db: db, gateway: gateway, maxDrift: maxDrift} +} + +func (s *Service) Start(addr string) { + mux := http.NewServeMux() + mux.HandleFunc("/health", s.handleHealth) + mux.HandleFunc("/ready", s.handleReady) + s.server = &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 3 * time.Second} + go func() { + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + // HTTP health errors are intentionally surfaced through /ready and logs by caller. + return + } + }() +} + +func (s *Service) Shutdown(ctx context.Context) error { + if s.server == nil { + return nil + } + return s.server.Shutdown(ctx) +} + +func (s *Service) Check(ctx context.Context) map[string]string { + status := map[string]string{"status": "ok"} + if s.db != nil { + if err := s.db.PingContext(ctx); err != nil { + status["status"] = "fail" + status["db"] = err.Error() + } else { + status["db"] = "ok" + } + } + if s.gateway != nil { + serverTime, err := s.gateway.GetServerTime(ctx) + if err != nil { + status["status"] = "fail" + status["api"] = err.Error() + } else { + status["api"] = "ok" + drift := timeutil.Drift(time.Now().UTC(), serverTime) + status["clock_drift"] = drift.String() + if s.maxDrift > 0 && drift > s.maxDrift { + status["status"] = "fail" + status["clock"] = fmt.Sprintf("drift %s exceeds %s", drift, s.maxDrift) + } + } + } + return status +} + +func (s *Service) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Service) handleReady(w http.ResponseWriter, r *http.Request) { + status := s.Check(r.Context()) + code := http.StatusOK + if status["status"] != "ok" { + code = http.StatusServiceUnavailable + } + writeJSON(w, code, status) +} + +func CheckEndpoint(ctx context.Context, url string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode >= 300 { + return fmt.Errorf("healthcheck returned %s", resp.Status) + } + return nil +} + +func writeJSON(w http.ResponseWriter, code int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/internal/instruments/registry.go b/internal/instruments/registry.go new file mode 100644 index 0000000..40bd04f --- /dev/null +++ b/internal/instruments/registry.go @@ -0,0 +1,61 @@ +package instruments + +import ( + "context" + "fmt" + "strings" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/repository" + "overnight-trading-bot/internal/tinvest" +) + +type Registry struct { + repo repository.Repository + gateway tinvest.Gateway +} + +func NewRegistry(repo repository.Repository, gateway tinvest.Gateway) Registry { + return Registry{repo: repo, gateway: gateway} +} + +func (r Registry) SyncMetadata(ctx context.Context) error { + instruments, err := r.repo.ListInstruments(ctx, true) + if err != nil { + return err + } + for _, instrument := range instruments { + if strings.HasPrefix(instrument.InstrumentUID, "PENDING:") || !instrument.MetadataValid() { + remote, err := r.gateway.GetInstrument(ctx, instrument.Ticker, instrument.ClassCode) + if err != nil { + return fmt.Errorf("sync %s: %w", instrument.Ticker, err) + } + remote.Enabled = instrument.Enabled && remote.Enabled + remote.FundType = instrument.FundType + remote.ExpectedCommissionBpsPerSide = instrument.ExpectedCommissionBpsPerSide + remote.FreeOrderLimitPerDay = instrument.FreeOrderLimitPerDay + remote.Quarantine = instrument.Quarantine + remote.QuarantineReason = instrument.QuarantineReason + remote.ExcludeReason = instrument.ExcludeReason + if err := r.repo.ReplaceInstrument(ctx, instrument.InstrumentUID, remote); err != nil { + return fmt.Errorf("replace synced instrument %s: %w", instrument.Ticker, err) + } + } + } + return nil +} + +func CheckInstrument(instrument domain.Instrument, status domain.TradingStatus) error { + switch { + case !instrument.Enabled: + return fmt.Errorf("%s disabled", instrument.Ticker) + case instrument.Quarantine: + return fmt.Errorf("%s quarantined: %s", instrument.Ticker, instrument.QuarantineReason) + case !instrument.MetadataValid(): + return fmt.Errorf("%s invalid metadata", instrument.Ticker) + case status != domain.TradingStatusNormal: + return fmt.Errorf("%s trading status %s", instrument.Ticker, status) + default: + return nil + } +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..e0fb652 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,48 @@ +package logging + +import ( + "io" + "log/slog" + "os" + "strings" +) + +func New(level string, out io.Writer) *slog.Logger { + if out == nil { + out = os.Stdout + } + var slogLevel slog.Level + switch strings.ToLower(level) { + case "debug": + slogLevel = slog.LevelDebug + case "warn", "warning": + slogLevel = slog.LevelWarn + case "error": + slogLevel = slog.LevelError + default: + slogLevel = slog.LevelInfo + } + return slog.New(slog.NewJSONHandler(out, &slog.HandlerOptions{Level: slogLevel})) +} + +type SDKLogger struct { + Logger *slog.Logger +} + +func (l SDKLogger) Infof(template string, args ...any) { + if l.Logger != nil { + l.Logger.Info(template, "args", args) + } +} + +func (l SDKLogger) Errorf(template string, args ...any) { + if l.Logger != nil { + l.Logger.Error(template, "args", args) + } +} + +func (l SDKLogger) Fatalf(template string, args ...any) { + if l.Logger != nil { + l.Logger.Error(template, "args", args) + } +} diff --git a/internal/marketdata/loader.go b/internal/marketdata/loader.go new file mode 100644 index 0000000..b6d9b6c --- /dev/null +++ b/internal/marketdata/loader.go @@ -0,0 +1,67 @@ +package marketdata + +import ( + "context" + "fmt" + "time" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/repository" + "overnight-trading-bot/internal/tinvest" +) + +type Loader struct { + repo repository.Repository + gateway tinvest.Gateway +} + +func NewLoader(repo repository.Repository, gateway tinvest.Gateway) Loader { + return Loader{repo: repo, gateway: gateway} +} + +func (l Loader) BackfillDaily(ctx context.Context, instruments []domain.Instrument, from, to time.Time) error { + for _, instrument := range instruments { + if !instrument.Enabled || instrument.Quarantine { + continue + } + candles, err := l.gateway.GetCandles(ctx, instrument.InstrumentUID, "day", from, to) + if err != nil { + return fmt.Errorf("load candles %s: %w", instrument.Ticker, err) + } + if err := l.repo.UpsertDailyCandles(ctx, candles); err != nil { + return fmt.Errorf("persist candles %s: %w", instrument.Ticker, err) + } + } + return nil +} + +func (l Loader) BackfillMinute(ctx context.Context, instruments []domain.Instrument, from, to time.Time) error { + for _, instrument := range instruments { + if !instrument.Enabled || instrument.Quarantine { + continue + } + candles, err := l.gateway.GetCandles(ctx, instrument.InstrumentUID, "minute", from, to) + if err != nil { + return fmt.Errorf("load minute candles %s: %w", instrument.Ticker, err) + } + if err := l.repo.UpsertMinuteCandles(ctx, candles); err != nil { + return fmt.Errorf("persist minute candles %s: %w", instrument.Ticker, err) + } + } + return nil +} + +func (l Loader) LatestQuote(ctx context.Context, instrumentUID string, depth int32, maxAge time.Duration) (domain.OrderBook, error) { + book, err := l.gateway.GetOrderBook(ctx, instrumentUID, depth) + if err != nil { + return domain.OrderBook{}, err + } + age := time.Since(book.ReceivedAt) + if book.ReceivedAt.IsZero() { + age = time.Since(book.Time) + } + if maxAge > 0 && age > maxAge { + return domain.OrderBook{}, fmt.Errorf("quote age %s exceeds %s", age, maxAge) + } + return book, nil +} diff --git a/internal/money/money.go b/internal/money/money.go new file mode 100644 index 0000000..297764d --- /dev/null +++ b/internal/money/money.go @@ -0,0 +1,117 @@ +package money + +import ( + "errors" + + "github.com/shopspring/decimal" + + pb "github.com/russianinvestments/invest-api-go-sdk/proto" +) + +var ( + ErrInvalidTick = errors.New("tick must be positive") + ErrInvalidBase = errors.New("base must be positive") +) + +type RoundMode int + +const ( + RoundNearest RoundMode = iota + RoundFloor + RoundCeil +) + +func QuotationToDecimal(q *pb.Quotation) decimal.Decimal { + if q == nil { + return decimal.Zero + } + return decimal.NewFromInt(q.GetUnits()).Add(decimal.New(int64(q.GetNano()), -9)) +} + +func DecimalToQuotation(d decimal.Decimal) *pb.Quotation { + units := d.Truncate(0) + nano := d.Sub(units).Mul(decimal.NewFromInt(1_000_000_000)).Round(0) + if nano.Equal(decimal.NewFromInt(1_000_000_000)) { + units = units.Add(decimal.NewFromInt(1)) + nano = decimal.Zero + } + if nano.Equal(decimal.NewFromInt(-1_000_000_000)) { + units = units.Sub(decimal.NewFromInt(1)) + nano = decimal.Zero + } + nanoPart := nano.IntPart() + if nanoPart < -999_999_999 || nanoPart > 999_999_999 { + panic("decimal quotation nano is out of protobuf range") + } + return &pb.Quotation{ + Units: units.IntPart(), + Nano: int32(nanoPart), // #nosec G115 -- nanoPart is bounded above. + } +} + +func MoneyValueToDecimal(v *pb.MoneyValue) decimal.Decimal { + if v == nil { + return decimal.Zero + } + return decimal.NewFromInt(v.GetUnits()).Add(decimal.New(int64(v.GetNano()), -9)) +} + +func Bps(part, base decimal.Decimal) (decimal.Decimal, error) { + if !base.IsPositive() { + return decimal.Zero, ErrInvalidBase + } + return part.Div(base).Mul(decimal.NewFromInt(10_000)), nil +} + +func FromBps(bps decimal.Decimal) decimal.Decimal { + return bps.Div(decimal.NewFromInt(10_000)) +} + +func RoundToTick(price, tick decimal.Decimal, mode RoundMode) (decimal.Decimal, error) { + if !tick.IsPositive() { + return decimal.Zero, ErrInvalidTick + } + steps := price.Div(tick) + switch mode { + case RoundFloor: + steps = steps.Floor() + case RoundCeil: + steps = steps.Ceil() + default: + steps = steps.Round(0) + } + return steps.Mul(tick), nil +} + +func Min(values ...decimal.Decimal) decimal.Decimal { + if len(values) == 0 { + return decimal.Zero + } + min := values[0] + for _, value := range values[1:] { + if value.LessThan(min) { + min = value + } + } + return min +} + +func Max(values ...decimal.Decimal) decimal.Decimal { + if len(values) == 0 { + return decimal.Zero + } + max := values[0] + for _, value := range values[1:] { + if value.GreaterThan(max) { + max = value + } + } + return max +} + +func Abs(value decimal.Decimal) decimal.Decimal { + if value.IsNegative() { + return value.Neg() + } + return value +} diff --git a/internal/money/rounding_test.go b/internal/money/rounding_test.go new file mode 100644 index 0000000..3f0f6a9 --- /dev/null +++ b/internal/money/rounding_test.go @@ -0,0 +1,39 @@ +package money + +import ( + "testing" + + "github.com/shopspring/decimal" +) + +func d(raw string) decimal.Decimal { + v, err := decimal.NewFromString(raw) + if err != nil { + panic(err) + } + return v +} + +func TestRoundToTick(t *testing.T) { + tests := []struct { + price string + tick string + mode RoundMode + want string + }{ + {"10.12346", "0.0001", RoundNearest, "10.1235"}, + {"10.126", "0.01", RoundFloor, "10.12"}, + {"10.126", "0.01", RoundCeil, "10.13"}, + {"10.24", "0.5", RoundNearest, "10"}, + {"10.26", "0.5", RoundNearest, "10.5"}, + } + for _, tt := range tests { + got, err := RoundToTick(d(tt.price), d(tt.tick), tt.mode) + if err != nil { + t.Fatal(err) + } + if !got.Equal(d(tt.want)) { + t.Fatalf("RoundToTick(%s,%s)=%s want %s", tt.price, tt.tick, got, tt.want) + } + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..75e9ec1 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,219 @@ +package notify + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "overnight-trading-bot/internal/domain" +) + +type Notifier interface { + Info(ctx context.Context, msg string) error + Warn(ctx context.Context, msg string) error + Alert(ctx context.Context, msg string) error + Report(ctx context.Context, msg string) error + Close() error +} + +type Noop struct{} + +func (Noop) Info(context.Context, string) error { return nil } +func (Noop) Warn(context.Context, string) error { return nil } +func (Noop) Alert(context.Context, string) error { return nil } +func (Noop) Report(context.Context, string) error { return nil } +func (Noop) Close() error { return nil } + +type TelegramConfig struct { + BotToken string + ChatID int64 + NotifyInfo bool + NotifyWarn bool + NotifyAlert bool + NotifyReport bool + AuditSink AuditSink +} + +type AuditSink interface { + InsertRiskEvent(ctx context.Context, event domain.RiskEvent) error +} + +type Telegram struct { + cfg TelegramConfig + bot *tgbotapi.BotAPI + log *slog.Logger + queue chan outbound + done chan struct{} + closed chan struct{} +} + +type outbound struct { + level domain.Severity + text string +} + +func NewTelegram(cfg TelegramConfig, log *slog.Logger) (Notifier, error) { + if cfg.BotToken == "" || cfg.ChatID == 0 { + return Noop{}, nil + } + bot, err := tgbotapi.NewBotAPI(cfg.BotToken) + if err != nil { + return nil, err + } + t := &Telegram{ + cfg: cfg, + bot: bot, + log: log, + queue: make(chan outbound, 256), + done: make(chan struct{}), + closed: make(chan struct{}), + } + go t.dispatch() + return t, nil +} + +func (t *Telegram) Info(ctx context.Context, msg string) error { + if !t.cfg.NotifyInfo { + return nil + } + return t.enqueue(ctx, domain.SeverityInfo, msg, false) +} + +func (t *Telegram) Warn(ctx context.Context, msg string) error { + if !t.cfg.NotifyWarn { + return nil + } + return t.enqueue(ctx, domain.SeverityWarn, msg, false) +} + +func (t *Telegram) Alert(ctx context.Context, msg string) error { + if !t.cfg.NotifyAlert { + return nil + } + return t.enqueue(ctx, domain.SeverityAlert, msg, true) +} + +func (t *Telegram) Report(ctx context.Context, msg string) error { + if !t.cfg.NotifyReport { + return nil + } + return t.enqueueText(ctx, domain.SeverityInfo, formatMessage("[REPORT]", msg), true) +} + +func (t *Telegram) Close() error { + close(t.done) + <-t.closed + return nil +} + +func (t *Telegram) enqueue(ctx context.Context, level domain.Severity, msg string, mustDeliver bool) error { + return t.enqueueText(ctx, level, formatMessage(prefix(level), msg), mustDeliver) +} + +func (t *Telegram) enqueueText(ctx context.Context, level domain.Severity, text string, mustDeliver bool) error { + item := outbound{level: level, text: text} + if mustDeliver { + select { + case t.queue <- item: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + select { + case t.queue <- item: + default: + if t.log != nil { + t.log.Warn("telegram queue full; dropping non-critical notification", "level", level) + } + if t.cfg.AuditSink != nil { + _ = t.cfg.AuditSink.InsertRiskEvent(ctx, domain.RiskEvent{ + TS: time.Now().UTC(), + Severity: domain.SeverityWarn, + EventType: "notification_dropped", + Message: fmt.Sprintf("telegram queue full; dropped %s notification", level), + ContextJSON: "{}", + }) + } + } + return nil +} + +func (t *Telegram) dispatch() { + defer close(t.closed) + for { + select { + case item := <-t.queue: + t.send(item) + case <-t.done: + for { + select { + case item := <-t.queue: + t.send(item) + default: + return + } + } + } + } +} + +func (t *Telegram) send(item outbound) { + msg := tgbotapi.NewMessage(t.cfg.ChatID, item.text) + for attempt := 0; attempt < 3; attempt++ { + if _, err := t.bot.Send(msg); err != nil { + delay := telegramRetryDelay(err, attempt) + if t.log != nil { + t.log.Warn("telegram send failed", "attempt", attempt+1, "err", err, "retry_in", delay) + } + timer := time.NewTimer(delay) + select { + case <-timer.C: + case <-t.done: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return + } + continue + } + return + } +} + +func telegramRetryDelay(err error, attempt int) time.Duration { + var apiErr tgbotapi.Error + if errors.As(err, &apiErr) && apiErr.RetryAfter > 0 { + return time.Duration(apiErr.RetryAfter) * time.Second + } + var apiErrPtr *tgbotapi.Error + if errors.As(err, &apiErrPtr) && apiErrPtr != nil && apiErrPtr.RetryAfter > 0 { + return time.Duration(apiErrPtr.RetryAfter) * time.Second + } + return time.Duration(attempt+1) * time.Second +} + +func prefix(level domain.Severity) string { + switch level { + case domain.SeverityInfo: + return "[INFO]" + case domain.SeverityWarn: + return "[WARN]" + case domain.SeverityAlert: + return "[ALERT]" + default: + return fmt.Sprintf("[%s]", strings.ToUpper(string(level))) + } +} + +func formatMessage(prefixValue, msg string) string { + return prefixValue + " " + msg +} diff --git a/internal/notify/telegram_test.go b/internal/notify/telegram_test.go new file mode 100644 index 0000000..36c1be6 --- /dev/null +++ b/internal/notify/telegram_test.go @@ -0,0 +1,49 @@ +package notify + +import ( + "testing" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "overnight-trading-bot/internal/domain" +) + +func TestPrefix(t *testing.T) { + tests := map[domain.Severity]string{ + domain.SeverityInfo: "[INFO]", + domain.SeverityWarn: "[WARN]", + domain.SeverityAlert: "[ALERT]", + domain.Severity("alert"): "[ALERT]", + } + for severity, want := range tests { + if got := prefix(severity); got != want { + t.Fatalf("prefix(%s)=%s, want %s", severity, got, want) + } + } +} + +func TestFormatReportPrefix(t *testing.T) { + if got := formatMessage("[REPORT]", "daily"); got != "[REPORT] daily" { + t.Fatalf("message=%s", got) + } +} + +func TestTelegramRetryDelayUsesRetryAfter(t *testing.T) { + err := &tgbotapi.Error{ + Code: 429, + ResponseParameters: tgbotapi.ResponseParameters{ + RetryAfter: 7, + }, + } + if got := telegramRetryDelay(err, 0); got != 7*time.Second { + t.Fatalf("delay=%s, want 7s", got) + } + if got := telegramRetryDelay(assertErr{}, 1); got != 2*time.Second { + t.Fatalf("fallback delay=%s, want 2s", got) + } +} + +type assertErr struct{} + +func (assertErr) Error() string { return "boom" } diff --git a/internal/position/manager.go b/internal/position/manager.go new file mode 100644 index 0000000..956f3e1 --- /dev/null +++ b/internal/position/manager.go @@ -0,0 +1,93 @@ +package position + +import ( + "context" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/money" + "overnight-trading-bot/internal/repository" +) + +type Manager struct { + repo repository.Repository +} + +func NewManager(repo repository.Repository) Manager { + return Manager{repo: repo} +} + +func (m Manager) OnEntryFill(ctx context.Context, accountIDHash string, instrument domain.Instrument, order domain.Order) (domain.Position, error) { + now := time.Now().UTC() + lot := instrument.Lot + if lot <= 0 { + lot = 1 + } + pos := domain.Position{ + AccountIDHash: accountIDHash, + InstrumentUID: order.InstrumentUID, + OpenTradeDate: order.TradeDate, + Lots: order.FilledLots, + Lot: lot, + AvgBuyPrice: order.AvgFillPrice, + CommissionTotal: order.Commission, + Status: domain.PositionHoldingOvernight, + OpenedAt: &now, + UpdatedAt: now, + } + if pos.Lots < order.QuantityLots { + pos.Status = domain.PositionEntryPartiallyFilled + } + if err := m.repo.UpsertPosition(ctx, pos); err != nil { + return domain.Position{}, err + } + return pos, nil +} + +func (m Manager) OnExitFill(ctx context.Context, pos domain.Position, exitOrder domain.Order) (domain.Position, error) { + now := time.Now().UTC() + lot := pos.Lot + if lot <= 0 { + lot = 1 + } + executedLots := min(exitOrder.FilledLots, pos.Lots) + if executedLots < 0 { + executedLots = 0 + } + previousExitLots := pos.ExitFilledLots + pos.ExitFilledLots += executedLots + if executedLots > 0 { + previousValue := pos.AvgSellPrice.Mul(decimal.NewFromInt(previousExitLots)) + newValue := exitOrder.AvgFillPrice.Mul(decimal.NewFromInt(executedLots)) + pos.AvgSellPrice = previousValue.Add(newValue).Div(decimal.NewFromInt(pos.ExitFilledLots)) + } + pos.CommissionTotal = pos.CommissionTotal.Add(exitOrder.Commission) + executedUnits := decimal.NewFromInt(executedLots).Mul(decimal.NewFromInt(lot)) + pos.GrossPnL = pos.GrossPnL.Add(exitOrder.AvgFillPrice.Sub(pos.AvgBuyPrice).Mul(executedUnits)) + pos.NetPnL = pos.GrossPnL.Sub(pos.CommissionTotal) + if pos.AvgBuyPrice.IsPositive() { + baseLots := pos.ExitFilledLots + if baseLots <= 0 { + baseLots = pos.Lots + } + base := pos.AvgBuyPrice.Mul(decimal.NewFromInt(baseLots)).Mul(decimal.NewFromInt(lot)) + edge, _ := money.Bps(pos.NetPnL, base) + pos.RealizedEdgeBps = edge + } + pos.Status = domain.PositionExitFilled + if executedLots < pos.Lots { + pos.Lots -= executedLots + pos.Status = domain.PositionExitPartiallyFilled + pos.ClosedAt = nil + } else { + pos.Lots = 0 + pos.ClosedAt = &now + } + pos.UpdatedAt = now + if err := m.repo.UpsertPosition(ctx, pos); err != nil { + return domain.Position{}, err + } + return pos, nil +} diff --git a/internal/position/manager_test.go b/internal/position/manager_test.go new file mode 100644 index 0000000..475a8a0 --- /dev/null +++ b/internal/position/manager_test.go @@ -0,0 +1,141 @@ +package position + +import ( + "context" + "testing" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/testutil" +) + +func TestOnEntryFillKeepsBuyCommission(t *testing.T) { + ctx := context.Background() + manager := NewManager(testutil.NewMemoryRepository()) + pos, err := manager.OnEntryFill(ctx, "hash", domain.Instrument{Lot: 1}, domain.Order{ + InstrumentUID: "uid", + TradeDate: time.Now().UTC(), + QuantityLots: 10, + FilledLots: 10, + AvgFillPrice: decimal.NewFromInt(100), + Commission: decimal.NewFromInt(3), + }) + if err != nil { + t.Fatal(err) + } + if !pos.CommissionTotal.Equal(decimal.NewFromInt(3)) { + t.Fatalf("commission=%s, want 3", pos.CommissionTotal) + } +} + +func TestOnExitFillPartialUsesExecutedLots(t *testing.T) { + ctx := context.Background() + manager := NewManager(testutil.NewMemoryRepository()) + openAt := time.Now().UTC() + pos := domain.Position{ + AccountIDHash: "hash", + InstrumentUID: "uid", + OpenTradeDate: openAt, + Lots: 10, + Lot: 1, + AvgBuyPrice: decimal.NewFromInt(100), + Status: domain.PositionHoldingOvernight, + CommissionTotal: decimal.NewFromInt(2), + OpenedAt: &openAt, + } + updated, err := manager.OnExitFill(ctx, pos, domain.Order{ + InstrumentUID: "uid", + FilledLots: 4, + AvgFillPrice: decimal.NewFromInt(110), + Commission: decimal.NewFromInt(1), + }) + if err != nil { + t.Fatal(err) + } + if updated.Status != domain.PositionExitPartiallyFilled || updated.ClosedAt != nil { + t.Fatalf("unexpected partial status/closed_at: %+v", updated) + } + if updated.Lots != 6 { + t.Fatalf("remaining lots=%d, want 6", updated.Lots) + } + if !updated.GrossPnL.Equal(decimal.NewFromInt(40)) { + t.Fatalf("gross pnl=%s, want 40", updated.GrossPnL) + } + if updated.ExitFilledLots != 4 || !updated.AvgSellPrice.Equal(decimal.NewFromInt(110)) { + t.Fatalf("exit aggregation lots=%d avg=%s", updated.ExitFilledLots, updated.AvgSellPrice) + } + second, err := manager.OnExitFill(ctx, updated, domain.Order{ + InstrumentUID: "uid", + FilledLots: 3, + AvgFillPrice: decimal.NewFromInt(120), + }) + if err != nil { + t.Fatal(err) + } + wantAvg := decimal.NewFromInt(800).Div(decimal.NewFromInt(7)) + if second.ExitFilledLots != 7 || !second.AvgSellPrice.Equal(wantAvg) { + t.Fatalf("weighted avg sell=%s lots=%d, want %s/7", second.AvgSellPrice, second.ExitFilledLots, wantAvg) + } +} + +func TestOnExitFillUsesInstrumentLotForAbsolutePnL(t *testing.T) { + ctx := context.Background() + manager := NewManager(testutil.NewMemoryRepository()) + openAt := time.Now().UTC() + pos := domain.Position{ + AccountIDHash: "hash", + InstrumentUID: "uid", + OpenTradeDate: openAt, + Lots: 4, + Lot: 10, + AvgBuyPrice: decimal.NewFromInt(100), + Status: domain.PositionHoldingOvernight, + CommissionTotal: decimal.NewFromInt(2), + OpenedAt: &openAt, + } + updated, err := manager.OnExitFill(ctx, pos, domain.Order{ + InstrumentUID: "uid", + FilledLots: 4, + AvgFillPrice: decimal.NewFromInt(105), + Commission: decimal.NewFromInt(3), + }) + if err != nil { + t.Fatal(err) + } + if !updated.GrossPnL.Equal(decimal.NewFromInt(200)) { + t.Fatalf("gross pnl=%s, want 200", updated.GrossPnL) + } + if !updated.NetPnL.Equal(decimal.NewFromInt(195)) { + t.Fatalf("net pnl=%s, want 195", updated.NetPnL) + } +} + +func TestOnExitFillUsesLotInRealizedEdgeCommissionBase(t *testing.T) { + ctx := context.Background() + manager := NewManager(testutil.NewMemoryRepository()) + openAt := time.Now().UTC() + pos := domain.Position{ + AccountIDHash: "hash", + InstrumentUID: "uid", + OpenTradeDate: openAt, + Lots: 1, + Lot: 100, + AvgBuyPrice: decimal.NewFromInt(100), + Status: domain.PositionHoldingOvernight, + OpenedAt: &openAt, + } + updated, err := manager.OnExitFill(ctx, pos, domain.Order{ + InstrumentUID: "uid", + FilledLots: 1, + AvgFillPrice: decimal.NewFromInt(100), + Commission: decimal.NewFromInt(10), + }) + if err != nil { + t.Fatal(err) + } + if !updated.RealizedEdgeBps.Equal(decimal.NewFromInt(-10)) { + t.Fatalf("realized edge=%s, want -10 bps", updated.RealizedEdgeBps) + } +} diff --git a/internal/reconciliation/engine.go b/internal/reconciliation/engine.go new file mode 100644 index 0000000..d8cd29f --- /dev/null +++ b/internal/reconciliation/engine.go @@ -0,0 +1,230 @@ +package reconciliation + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/money" + "overnight-trading-bot/internal/repository" + "overnight-trading-bot/internal/tinvest" +) + +type Engine struct { + repo repository.Repository + gateway tinvest.Gateway + accountID string + accountIDHash string + window time.Duration + inFlightGrace time.Duration +} + +func New(repo repository.Repository, gateway tinvest.Gateway, accountID, accountIDHash string) Engine { + return Engine{repo: repo, gateway: gateway, accountID: accountID, accountIDHash: accountIDHash, window: 72 * time.Hour} +} + +func (e Engine) WithWindow(window time.Duration) Engine { + if window > 0 { + e.window = window + } + return e +} + +func (e Engine) WithInFlightGrace(grace time.Duration) Engine { + if grace >= 0 { + e.inFlightGrace = grace + } + return e +} + +func (e Engine) Run(ctx context.Context) ([]domain.ReconciliationDiff, error) { + localOrders, err := e.repo.ListActiveOrders(ctx, e.accountIDHash) + if err != nil { + return nil, err + } + brokerOrders, err := e.gateway.GetActiveOrders(ctx, e.accountID) + if err != nil { + return nil, err + } + now := time.Now().UTC() + localByBroker := make(map[string]domain.Order, len(localOrders)) + brokerByID := make(map[string]domain.Order, len(brokerOrders)) + for _, order := range localOrders { + if order.BrokerOrderID != "" { + localByBroker[order.BrokerOrderID] = order + } + } + var diffs []domain.ReconciliationDiff + for _, brokerOrder := range brokerOrders { + brokerByID[brokerOrder.BrokerOrderID] = brokerOrder + if _, ok := localByBroker[brokerOrder.BrokerOrderID]; !ok { + diffs = append(diffs, domain.ReconciliationDiff{ + Kind: "unknown_active_order", + InstrumentUID: brokerOrder.InstrumentUID, + Message: fmt.Sprintf("broker order %s is not known locally", brokerOrder.BrokerOrderID), + Critical: true, + }) + } + } + for _, localOrder := range localOrders { + if e.isInFlight(localOrder, now) { + continue + } + if localOrder.BrokerOrderID == "" { + diffs = append(diffs, domain.ReconciliationDiff{ + Kind: "local_order_without_broker_id", + InstrumentUID: localOrder.InstrumentUID, + Message: fmt.Sprintf("local order %s is active without broker order id", localOrder.ClientOrderID), + Critical: true, + }) + continue + } + if _, ok := brokerByID[localOrder.BrokerOrderID]; !ok { + diffs = append(diffs, domain.ReconciliationDiff{ + Kind: "missing_local_order", + InstrumentUID: localOrder.InstrumentUID, + Message: fmt.Sprintf("local active order %s/%s is not active at broker", localOrder.ClientOrderID, localOrder.BrokerOrderID), + Critical: true, + }) + } + } + localPositions, err := e.repo.ListOpenPositions(ctx, e.accountIDHash) + if err != nil { + return nil, err + } + portfolio, err := e.gateway.GetPortfolio(ctx, e.accountID) + if err != nil { + return nil, err + } + brokerLots := make(map[string]int64, len(portfolio.Holdings)) + for _, holding := range portfolio.Holdings { + brokerLots[holding.InstrumentUID] += holding.QuantityLots + } + for _, pos := range localPositions { + if brokerLots[pos.InstrumentUID] != pos.Lots { + diffs = append(diffs, domain.ReconciliationDiff{ + Kind: "position_lots_mismatch", + InstrumentUID: pos.InstrumentUID, + Message: fmt.Sprintf("local lots=%d broker lots=%d", pos.Lots, brokerLots[pos.InstrumentUID]), + Critical: true, + }) + } + } + localLots := make(map[string]int64, len(localPositions)) + for _, pos := range localPositions { + localLots[pos.InstrumentUID] += pos.Lots + } + for instrumentUID, lots := range brokerLots { + if lots > 0 && localLots[instrumentUID] == 0 { + diffs = append(diffs, domain.ReconciliationDiff{ + Kind: "unknown_broker_position", + InstrumentUID: instrumentUID, + Message: fmt.Sprintf("broker holds %d lots but local position is absent", lots), + Critical: true, + }) + } + } + from := now.Add(-e.window) + recentOrders, err := e.repo.ListOrders(ctx, e.accountIDHash, from, now) + if err != nil { + return nil, err + } + operations, err := e.gateway.GetOperations(ctx, e.accountID, from, now) + if err != nil { + return nil, err + } + diffs = append(diffs, compareOperations(recentOrders, operations)...) + raw, _ := json.Marshal(diffs) + if err := e.repo.InsertReconciliation(ctx, now, string(raw), len(diffs) > 0); err != nil { + return nil, err + } + return diffs, nil +} + +func (e Engine) isInFlight(order domain.Order, now time.Time) bool { + if e.inFlightGrace <= 0 || order.CreatedAt.IsZero() { + return false + } + return order.CreatedAt.After(now.Add(-e.inFlightGrace)) +} + +func HasCritical(diffs []domain.ReconciliationDiff) bool { + for _, diff := range diffs { + if diff.Critical { + return true + } + } + return false +} + +func compareOperations(orders []domain.Order, operations []domain.Operation) []domain.ReconciliationDiff { + var diffs []domain.ReconciliationDiff + localCommissionByInstrument := make(map[string]decimal.Decimal) + localTraded := make(map[string]bool) + for _, order := range orders { + if order.Status == domain.OrderStatusFilled || order.Status == domain.OrderStatusPartiallyFilled { + localCommissionByInstrument[order.InstrumentUID] = localCommissionByInstrument[order.InstrumentUID].Add(order.Commission) + localTraded[order.InstrumentUID] = true + } + } + brokerCommissionByInstrument := make(map[string]decimal.Decimal) + brokerTraded := make(map[string]bool) + for _, op := range operations { + if !op.Commission.IsZero() { + brokerCommissionByInstrument[op.InstrumentUID] = brokerCommissionByInstrument[op.InstrumentUID].Add(op.Commission) + } + if isTradeOperation(op.Type) { + brokerTraded[op.InstrumentUID] = true + } + } + instruments := make(map[string]struct{}, len(localCommissionByInstrument)+len(brokerCommissionByInstrument)) + for instrumentUID := range localCommissionByInstrument { + instruments[instrumentUID] = struct{}{} + } + for instrumentUID := range brokerCommissionByInstrument { + instruments[instrumentUID] = struct{}{} + } + for instrumentUID := range instruments { + localCommission := localCommissionByInstrument[instrumentUID] + brokerCommission := brokerCommissionByInstrument[instrumentUID] + if diff := money.Abs(localCommission.Sub(brokerCommission)); diff.GreaterThan(decimal.NewFromFloat(0.01)) { + diffs = append(diffs, domain.ReconciliationDiff{ + Kind: "commission_mismatch", + InstrumentUID: instrumentUID, + Message: fmt.Sprintf("local commission=%s broker commission=%s", localCommission.StringFixed(2), brokerCommission.StringFixed(2)), + Critical: true, + }) + } + } + for instrumentUID := range brokerTraded { + if instrumentUID != "" && !localTraded[instrumentUID] { + diffs = append(diffs, domain.ReconciliationDiff{ + Kind: "unknown_broker_operation", + InstrumentUID: instrumentUID, + Message: "broker has executed operation without local filled order", + Critical: true, + }) + } + } + for instrumentUID := range localTraded { + if !brokerTraded[instrumentUID] { + diffs = append(diffs, domain.ReconciliationDiff{ + Kind: "missing_broker_operation", + InstrumentUID: instrumentUID, + Message: "local filled order has no matching broker operation in reconciliation window", + Critical: true, + }) + } + } + return diffs +} + +func isTradeOperation(raw string) bool { + raw = strings.ToUpper(raw) + return strings.Contains(raw, "OPERATION_TYPE_BUY") || strings.Contains(raw, "OPERATION_TYPE_SELL") +} diff --git a/internal/reconciliation/engine_test.go b/internal/reconciliation/engine_test.go new file mode 100644 index 0000000..92febb0 --- /dev/null +++ b/internal/reconciliation/engine_test.go @@ -0,0 +1,131 @@ +package reconciliation + +import ( + "context" + "testing" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/testutil" + "overnight-trading-bot/internal/tinvest" +) + +func TestReconciliationFindsCriticalDiffs(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + gateway := tinvest.NewFakeGateway() + now := time.Now().UTC() + if err := repo.UpsertOrder(ctx, domain.Order{ + ClientOrderID: "local", + BrokerOrderID: "broker-missing", + AccountIDHash: "hash", + InstrumentUID: "uid-local", + TradeDate: now, + Side: domain.SideBuy, + OrderType: domain.OrderTypeLimit, + QuantityLots: 1, + Status: domain.OrderStatusSent, + }); err != nil { + t.Fatal(err) + } + gateway.Orders["broker-unknown"] = domain.Order{ + ClientOrderID: "unknown", + BrokerOrderID: "broker-unknown", + AccountIDHash: "hash", + InstrumentUID: "uid-broker", + QuantityLots: 1, + Status: domain.OrderStatusSent, + } + if err := repo.UpsertPosition(ctx, domain.Position{ + AccountIDHash: "hash", + InstrumentUID: "uid-local", + OpenTradeDate: now, + Lots: 2, + Status: domain.PositionHoldingOvernight, + }); err != nil { + t.Fatal(err) + } + gateway.Portfolio = domain.Portfolio{ + Equity: decimal.NewFromInt(100000), + Cash: decimal.NewFromInt(90000), + Holdings: []domain.Holding{ + {InstrumentUID: "uid-local", QuantityLots: 1}, + {InstrumentUID: "uid-broker-only", QuantityLots: 3}, + }, + } + diffs, err := New(repo, gateway, "account", "hash").Run(ctx) + if err != nil { + t.Fatal(err) + } + wantKinds := map[string]bool{ + "unknown_active_order": false, + "missing_local_order": false, + "position_lots_mismatch": false, + "unknown_broker_position": false, + } + for _, diff := range diffs { + if _, ok := wantKinds[diff.Kind]; ok { + wantKinds[diff.Kind] = true + } + } + for kind, seen := range wantKinds { + if !seen { + t.Fatalf("missing diff kind %s in %+v", kind, diffs) + } + } + if !HasCritical(diffs) { + t.Fatalf("expected critical diffs") + } +} + +func TestCompareOperationsCommissionPerInstrument(t *testing.T) { + orders := []domain.Order{ + {InstrumentUID: "TRUR", Status: domain.OrderStatusFilled, Commission: decimal.NewFromInt(2)}, + {InstrumentUID: "TGLD", Status: domain.OrderStatusFilled, Commission: decimal.NewFromInt(1)}, + } + operations := []domain.Operation{ + {InstrumentUID: "TRUR", Type: "OPERATION_TYPE_BUY", Commission: decimal.NewFromInt(1)}, + {InstrumentUID: "TGLD", Type: "OPERATION_TYPE_BUY", Commission: decimal.NewFromInt(2)}, + } + diffs := compareOperations(orders, operations) + seen := map[string]bool{} + for _, diff := range diffs { + if diff.Kind == "commission_mismatch" { + seen[diff.InstrumentUID] = true + } + } + if !seen["TRUR"] || !seen["TGLD"] { + t.Fatalf("expected per-instrument commission diffs, got %+v", diffs) + } +} + +func TestReconciliationSkipsFreshInFlightLocalOrders(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + gateway := tinvest.NewFakeGateway() + now := time.Now().UTC() + if err := repo.UpsertOrder(ctx, domain.Order{ + ClientOrderID: "fresh", + AccountIDHash: "hash", + InstrumentUID: "uid", + TradeDate: now, + Side: domain.SideBuy, + OrderType: domain.OrderTypeLimit, + QuantityLots: 1, + Status: domain.OrderStatusSent, + CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + diffs, err := New(repo, gateway, "account", "hash").WithInFlightGrace(10 * time.Second).Run(ctx) + if err != nil { + t.Fatal(err) + } + for _, diff := range diffs { + if diff.Kind == "local_order_without_broker_id" || diff.Kind == "missing_local_order" { + t.Fatalf("fresh in-flight order produced diff: %+v", diffs) + } + } +} diff --git a/internal/report/daily.go b/internal/report/daily.go new file mode 100644 index 0000000..3a965a7 --- /dev/null +++ b/internal/report/daily.go @@ -0,0 +1,46 @@ +package report + +import ( + "fmt" + "strings" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" +) + +type DailyInput struct { + Date time.Time + Mode domain.Mode + Signals []domain.Signal + Positions []domain.Position + AverageSpreadBps decimal.Decimal + AverageSlipBps decimal.Decimal + RiskStatus string +} + +func ComposeDaily(input DailyInput) string { + var b strings.Builder + fmt.Fprintf(&b, "Дата: %s\n", input.Date.Format("2006-01-02")) + fmt.Fprintf(&b, "Режим: %s\n", input.Mode) + fmt.Fprintf(&b, "Сигналы: %d\n", len(input.Signals)) + for _, signal := range input.Signals { + fmt.Fprintf(&b, "- %s %s edge=%s reason=%s\n", signal.InstrumentUID, signal.Decision, signal.NetEdgeBps.StringFixed(2), signal.RejectReason) + } + gross := decimal.Zero + net := decimal.Zero + commission := decimal.Zero + for _, pos := range input.Positions { + gross = gross.Add(pos.GrossPnL) + net = net.Add(pos.NetPnL) + commission = commission.Add(pos.CommissionTotal) + } + fmt.Fprintf(&b, "Gross PnL: %s\n", gross.StringFixed(2)) + fmt.Fprintf(&b, "Net PnL: %s\n", net.StringFixed(2)) + fmt.Fprintf(&b, "Комиссии: %s\n", commission.StringFixed(2)) + fmt.Fprintf(&b, "Средний spread: %s bps\n", input.AverageSpreadBps.StringFixed(2)) + fmt.Fprintf(&b, "Среднее проскальзывание: %s bps\n", input.AverageSlipBps.StringFixed(2)) + fmt.Fprintf(&b, "Risk: %s", input.RiskStatus) + return b.String() +} diff --git a/internal/repository/migrations/0001_init.down.sql b/internal/repository/migrations/0001_init.down.sql new file mode 100644 index 0000000..e023497 --- /dev/null +++ b/internal/repository/migrations/0001_init.down.sql @@ -0,0 +1,13 @@ +DROP TABLE IF EXISTS reconciliations; +DROP TABLE IF EXISTS daily_reports; +DROP TABLE IF EXISTS system_state; +DROP TABLE IF EXISTS free_order_counters; +DROP TABLE IF EXISTS risk_events; +DROP TABLE IF EXISTS positions; +DROP TABLE IF EXISTS orders; +DROP TABLE IF EXISTS signals; +DROP TABLE IF EXISTS features; +DROP TABLE IF EXISTS candles_minute; +DROP TABLE IF EXISTS candles_daily; +DROP TABLE IF EXISTS instruments; +DROP TABLE IF EXISTS schema_meta; diff --git a/internal/repository/migrations/0001_init.up.sql b/internal/repository/migrations/0001_init.up.sql new file mode 100644 index 0000000..1f9ae75 --- /dev/null +++ b/internal/repository/migrations/0001_init.up.sql @@ -0,0 +1,181 @@ +CREATE TABLE IF NOT EXISTS schema_meta ( + meta_key VARCHAR(64) PRIMARY KEY, + meta_value VARCHAR(255) NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS instruments ( + instrument_uid VARCHAR(128) PRIMARY KEY, + figi VARCHAR(64), + ticker VARCHAR(32) NOT NULL, + class_code VARCHAR(32) NOT NULL DEFAULT 'TQTF', + name VARCHAR(255) NOT NULL DEFAULT '', + lot BIGINT NOT NULL DEFAULT 1, + min_price_increment DECIMAL(20,8) NOT NULL DEFAULT 0, + currency VARCHAR(8) NOT NULL DEFAULT 'RUB', + enabled TINYINT(1) NOT NULL DEFAULT 1, + fund_type VARCHAR(64) NOT NULL DEFAULT '', + expected_commission_bps_per_side DECIMAL(12,4) NOT NULL DEFAULT 0, + free_order_limit_per_day INT NOT NULL DEFAULT 0 COMMENT '0 means no configured free-order cap', + quarantine TINYINT(1) NOT NULL DEFAULT 0, + quarantine_reason TEXT, + exclude_reason TEXT, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY ux_instruments_ticker_class (ticker, class_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS candles_daily ( + instrument_uid VARCHAR(128) NOT NULL, + trade_date DATE NOT NULL, + open DECIMAL(20,8) NOT NULL, + high DECIMAL(20,8) NOT NULL, + low DECIMAL(20,8) NOT NULL, + close DECIMAL(20,8) NOT NULL, + volume_lots DECIMAL(20,8) NOT NULL DEFAULT 0, + source VARCHAR(32) NOT NULL, + loaded_at DATETIME(3) NOT NULL, + PRIMARY KEY (instrument_uid, trade_date), + CONSTRAINT fk_candles_daily_instrument FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS candles_minute ( + instrument_uid VARCHAR(128) NOT NULL, + ts DATETIME(3) NOT NULL, + open DECIMAL(20,8) NOT NULL, + high DECIMAL(20,8) NOT NULL, + low DECIMAL(20,8) NOT NULL, + close DECIMAL(20,8) NOT NULL, + volume_lots DECIMAL(20,8) NOT NULL DEFAULT 0, + source VARCHAR(32) NOT NULL, + loaded_at DATETIME(3) NOT NULL, + PRIMARY KEY (instrument_uid, ts), + CONSTRAINT fk_candles_minute_instrument FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS features ( + instrument_uid VARCHAR(128) NOT NULL, + trade_date DATE NOT NULL, + r_on DECIMAL(20,10) NOT NULL DEFAULT 0, + r_day DECIMAL(20,10) NOT NULL DEFAULT 0, + mu_on_60 DECIMAL(20,10) NOT NULL DEFAULT 0, + mu_on_252 DECIMAL(20,10) NOT NULL DEFAULT 0, + sigma_on_60 DECIMAL(20,10) NOT NULL DEFAULT 0, + tstat_on_60 DECIMAL(20,10) NOT NULL DEFAULT 0, + win_on_60 DECIMAL(20,10) NOT NULL DEFAULT 0, + ewma_on DECIMAL(20,10) NOT NULL DEFAULT 0, + spread_bps DECIMAL(12,4) NOT NULL DEFAULT 0, + half_spread_bps DECIMAL(12,4) NOT NULL DEFAULT 0, + tick_bps DECIMAL(12,4) NOT NULL DEFAULT 0, + adv_20 DECIMAL(20,8) NOT NULL DEFAULT 0, + expected_cost_bps DECIMAL(12,4) NOT NULL DEFAULT 0, + net_edge_bps DECIMAL(12,4) NOT NULL DEFAULT 0, + entry_interval_volume DECIMAL(20,8) NOT NULL DEFAULT 0, + exit_interval_volume DECIMAL(20,8) NOT NULL DEFAULT 0, + calculated_at DATETIME(3) NOT NULL, + PRIMARY KEY (instrument_uid, trade_date), + CONSTRAINT fk_features_instrument FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS signals ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + trade_date DATE NOT NULL, + instrument_uid VARCHAR(128) NOT NULL, + decision ENUM('ENTER','SKIP','REJECT') NOT NULL, + score DECIMAL(20,10) NOT NULL DEFAULT 0, + net_edge_bps DECIMAL(12,4) NOT NULL DEFAULT 0, + target_notional DECIMAL(20,8) NOT NULL DEFAULT 0, + target_lots BIGINT NOT NULL DEFAULT 0, + reject_reason VARCHAR(128), + context_json JSON, + created_at DATETIME(3) NOT NULL, + UNIQUE KEY ux_signals_date_instr (trade_date, instrument_uid), + CONSTRAINT fk_signals_instrument FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS orders ( + client_order_id VARCHAR(128) PRIMARY KEY, + broker_order_id VARCHAR(128), + account_id_hash VARCHAR(128) NOT NULL, + instrument_uid VARCHAR(128) NOT NULL, + trade_date DATE NOT NULL, + side ENUM('BUY','SELL') NOT NULL, + order_type ENUM('LIMIT') NOT NULL, + limit_price DECIMAL(20,8) NOT NULL DEFAULT 0, + quantity_lots BIGINT NOT NULL, + filled_lots BIGINT NOT NULL DEFAULT 0, + avg_fill_price DECIMAL(20,8) NOT NULL DEFAULT 0, + status ENUM('NEW','SENT','PARTIALLY_FILLED','FILLED','CANCELLED','REJECTED','EXPIRED','FAILED') NOT NULL, + commission DECIMAL(20,8) NOT NULL DEFAULT 0, + attempt_no INT NOT NULL DEFAULT 1, + raw_state_json JSON, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + UNIQUE KEY ux_orders_broker_order_id (broker_order_id), + KEY ix_orders_active (account_id_hash, status), + CONSTRAINT fk_orders_instrument FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS positions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + account_id_hash VARCHAR(128) NOT NULL, + instrument_uid VARCHAR(128) NOT NULL, + open_trade_date DATE NOT NULL, + lots BIGINT NOT NULL, + avg_buy_price DECIMAL(20,8) NOT NULL DEFAULT 0, + avg_sell_price DECIMAL(20,8) NOT NULL DEFAULT 0, + status ENUM('NO_POSITION','ENTRY_SIGNALLED','ENTRY_ORDER_SENT','ENTRY_PARTIALLY_FILLED','ENTRY_FILLED','HOLDING_OVERNIGHT','EXIT_ORDER_SENT','EXIT_PARTIALLY_FILLED','EXIT_FILLED','EXIT_FAILED','QUARANTINE') NOT NULL, + gross_pnl DECIMAL(20,8) NOT NULL DEFAULT 0, + net_pnl DECIMAL(20,8) NOT NULL DEFAULT 0, + commission_total DECIMAL(20,8) NOT NULL DEFAULT 0, + realized_edge_bps DECIMAL(12,4) NOT NULL DEFAULT 0, + opened_at DATETIME(3), + closed_at DATETIME(3), + updated_at DATETIME(3) NOT NULL, + KEY ix_positions_open (account_id_hash, status), + CONSTRAINT fk_positions_instrument FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS risk_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + ts DATETIME(3) NOT NULL, + severity ENUM('INFO','WARN','ALERT','CRITICAL') NOT NULL, + event_type VARCHAR(128) NOT NULL, + instrument_uid VARCHAR(128), + message TEXT NOT NULL, + raw_context_json JSON, + KEY ix_risk_events_ts (ts) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS free_order_counters ( + trade_date DATE NOT NULL, + instrument_uid VARCHAR(128) NOT NULL, + orders_sent INT NOT NULL DEFAULT 0, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (trade_date, instrument_uid), + CONSTRAINT fk_free_orders_instrument FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS system_state ( + id TINYINT NOT NULL PRIMARY KEY, + state ENUM('INIT','SYNC_INSTRUMENTS','SYNC_MARKET_DATA','GENERATE_SIGNALS','WAIT_ENTRY_WINDOW','PLACE_ENTRY_ORDERS','MONITOR_ENTRY_ORDERS','HOLD_OVERNIGHT','WAIT_EXIT_WINDOW','PLACE_EXIT_ORDERS','MONITOR_EXIT_ORDERS','RECONCILE','REPORT','SLEEP','HALTED') NOT NULL, + mode ENUM('backtest','paper','sandbox','live_readonly','live_trade') NOT NULL, + halted TINYINT(1) NOT NULL DEFAULT 0, + halt_reason TEXT, + last_heartbeat DATETIME(3) NOT NULL, + context_json JSON +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS reconciliations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + ts DATETIME(3) NOT NULL, + has_diff TINYINT(1) NOT NULL, + diff_json JSON, + KEY ix_reconciliations_ts (ts) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO schema_meta(meta_key, meta_value) VALUES ('schema_version', '0001') +ON DUPLICATE KEY UPDATE meta_value=VALUES(meta_value); + +INSERT INTO system_state(id, state, mode, halted, last_heartbeat, context_json) +VALUES (1, 'INIT', 'paper', 0, UTC_TIMESTAMP(3), JSON_OBJECT()) +ON DUPLICATE KEY UPDATE id=id; diff --git a/internal/repository/migrations/0002_seed_instruments.down.sql b/internal/repository/migrations/0002_seed_instruments.down.sql new file mode 100644 index 0000000..d6fc225 --- /dev/null +++ b/internal/repository/migrations/0002_seed_instruments.down.sql @@ -0,0 +1,7 @@ +DELETE FROM instruments WHERE instrument_uid IN ( + 'PENDING:TRUR','PENDING:TGLD','PENDING:TBRU','PENDING:TDIV','PENDING:TMON', + 'PENDING:TOFZ','PENDING:TLCB','PENDING:TITR','PENDING:TRND','PENDING:TMOS' +); + +UPDATE schema_meta SET meta_value='0001' WHERE meta_key='schema_version'; + diff --git a/internal/repository/migrations/0002_seed_instruments.up.sql b/internal/repository/migrations/0002_seed_instruments.up.sql new file mode 100644 index 0000000..025f354 --- /dev/null +++ b/internal/repository/migrations/0002_seed_instruments.up.sql @@ -0,0 +1,24 @@ +INSERT INTO instruments ( + instrument_uid, ticker, class_code, name, lot, min_price_increment, currency, + enabled, fund_type, expected_commission_bps_per_side, free_order_limit_per_day, + quarantine, exclude_reason, updated_at +) VALUES + ('PENDING:TRUR', 'TRUR', 'TQTF', 'TRUR', 1, 0.0001, 'RUB', 1, 'mixed', 0, 15, 0, NULL, UTC_TIMESTAMP(3)), + ('PENDING:TGLD', 'TGLD', 'TQTF', 'TGLD', 1, 0.0001, 'RUB', 1, 'commodity', 0, 15, 0, NULL, UTC_TIMESTAMP(3)), + ('PENDING:TBRU', 'TBRU', 'TQTF', 'TBRU', 1, 0.0001, 'RUB', 1, 'bonds', 0, 0, 0, NULL, UTC_TIMESTAMP(3)), + ('PENDING:TDIV', 'TDIV', 'TQTF', 'TDIV', 1, 0.0001, 'RUB', 1, 'equity_income', 0, 0, 0, NULL, UTC_TIMESTAMP(3)), + ('PENDING:TMON', 'TMON', 'TQTF', 'TMON', 1, 0.0001, 'RUB', 1, 'money_market', 0, 0, 0, NULL, UTC_TIMESTAMP(3)), + ('PENDING:TOFZ', 'TOFZ', 'TQTF', 'TOFZ', 1, 0.0001, 'RUB', 1, 'bonds', 0, 0, 0, NULL, UTC_TIMESTAMP(3)), + ('PENDING:TLCB', 'TLCB', 'TQTF', 'TLCB', 1, 0.0001, 'RUB', 1, 'corporate_bonds', 0, 0, 0, NULL, UTC_TIMESTAMP(3)), + ('PENDING:TITR', 'TITR', 'TQTF', 'TITR', 1, 0.0001, 'RUB', 1, 'equity', 0, 0, 0, NULL, UTC_TIMESTAMP(3)), + ('PENDING:TRND', 'TRND', 'TQTF', 'TRND', 1, 0.0001, 'RUB', 1, 'equity', 0, 0, 0, NULL, UTC_TIMESTAMP(3)), + ('PENDING:TMOS', 'TMOS', 'TQTF', 'TMOS', 1, 0.0001, 'RUB', 0, 'equity', 0, 0, 0, 'Excluded by default due to possible non-zero sell-side fee', UTC_TIMESTAMP(3)) +ON DUPLICATE KEY UPDATE + enabled=VALUES(enabled), + fund_type=VALUES(fund_type), + expected_commission_bps_per_side=VALUES(expected_commission_bps_per_side), + free_order_limit_per_day=VALUES(free_order_limit_per_day), + exclude_reason=VALUES(exclude_reason), + updated_at=UTC_TIMESTAMP(3); + +UPDATE schema_meta SET meta_value='0002' WHERE meta_key='schema_version'; diff --git a/internal/repository/migrations/0003_instrument_uid_cascade.down.sql b/internal/repository/migrations/0003_instrument_uid_cascade.down.sql new file mode 100644 index 0000000..733dfac --- /dev/null +++ b/internal/repository/migrations/0003_instrument_uid_cascade.down.sql @@ -0,0 +1,29 @@ +ALTER TABLE free_order_counters DROP FOREIGN KEY fk_free_orders_instrument; +ALTER TABLE free_order_counters ADD CONSTRAINT fk_free_orders_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid); + +ALTER TABLE positions DROP FOREIGN KEY fk_positions_instrument; +ALTER TABLE positions ADD CONSTRAINT fk_positions_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid); + +ALTER TABLE orders DROP FOREIGN KEY fk_orders_instrument; +ALTER TABLE orders ADD CONSTRAINT fk_orders_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid); + +ALTER TABLE signals DROP FOREIGN KEY fk_signals_instrument; +ALTER TABLE signals ADD CONSTRAINT fk_signals_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid); + +ALTER TABLE features DROP FOREIGN KEY fk_features_instrument; +ALTER TABLE features ADD CONSTRAINT fk_features_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid); + +ALTER TABLE candles_minute DROP FOREIGN KEY fk_candles_minute_instrument; +ALTER TABLE candles_minute ADD CONSTRAINT fk_candles_minute_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid); + +ALTER TABLE candles_daily DROP FOREIGN KEY fk_candles_daily_instrument; +ALTER TABLE candles_daily ADD CONSTRAINT fk_candles_daily_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid); + +UPDATE schema_meta SET meta_value='0002' WHERE meta_key='schema_version'; diff --git a/internal/repository/migrations/0003_instrument_uid_cascade.up.sql b/internal/repository/migrations/0003_instrument_uid_cascade.up.sql new file mode 100644 index 0000000..30cae7c --- /dev/null +++ b/internal/repository/migrations/0003_instrument_uid_cascade.up.sql @@ -0,0 +1,29 @@ +ALTER TABLE candles_daily DROP FOREIGN KEY fk_candles_daily_instrument; +ALTER TABLE candles_daily ADD CONSTRAINT fk_candles_daily_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE; + +ALTER TABLE candles_minute DROP FOREIGN KEY fk_candles_minute_instrument; +ALTER TABLE candles_minute ADD CONSTRAINT fk_candles_minute_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE; + +ALTER TABLE features DROP FOREIGN KEY fk_features_instrument; +ALTER TABLE features ADD CONSTRAINT fk_features_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE; + +ALTER TABLE signals DROP FOREIGN KEY fk_signals_instrument; +ALTER TABLE signals ADD CONSTRAINT fk_signals_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE; + +ALTER TABLE orders DROP FOREIGN KEY fk_orders_instrument; +ALTER TABLE orders ADD CONSTRAINT fk_orders_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE; + +ALTER TABLE positions DROP FOREIGN KEY fk_positions_instrument; +ALTER TABLE positions ADD CONSTRAINT fk_positions_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE; + +ALTER TABLE free_order_counters DROP FOREIGN KEY fk_free_orders_instrument; +ALTER TABLE free_order_counters ADD CONSTRAINT fk_free_orders_instrument + FOREIGN KEY (instrument_uid) REFERENCES instruments(instrument_uid) ON UPDATE CASCADE; + +UPDATE schema_meta SET meta_value='0003' WHERE meta_key='schema_version'; diff --git a/internal/repository/migrations/0004_positions_reports.down.sql b/internal/repository/migrations/0004_positions_reports.down.sql new file mode 100644 index 0000000..4c2ca09 --- /dev/null +++ b/internal/repository/migrations/0004_positions_reports.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS daily_reports; +ALTER TABLE positions DROP INDEX ux_positions_trade; +ALTER TABLE positions DROP COLUMN exit_filled_lots; + +UPDATE schema_meta SET meta_value='0003' WHERE meta_key='schema_version'; diff --git a/internal/repository/migrations/0004_positions_reports.up.sql b/internal/repository/migrations/0004_positions_reports.up.sql new file mode 100644 index 0000000..b4966c2 --- /dev/null +++ b/internal/repository/migrations/0004_positions_reports.up.sql @@ -0,0 +1,11 @@ +ALTER TABLE positions ADD COLUMN exit_filled_lots BIGINT NOT NULL DEFAULT 0 AFTER lots; +ALTER TABLE positions ADD UNIQUE KEY ux_positions_trade (account_id_hash, instrument_uid, open_trade_date); + +CREATE TABLE IF NOT EXISTS daily_reports ( + report_date DATE NOT NULL, + account_id_hash VARCHAR(128) NOT NULL, + sent_at DATETIME(3) NOT NULL, + PRIMARY KEY (report_date, account_id_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +UPDATE schema_meta SET meta_value='0004' WHERE meta_key='schema_version'; diff --git a/internal/repository/migrations/0005_policy_cleanup.down.sql b/internal/repository/migrations/0005_policy_cleanup.down.sql new file mode 100644 index 0000000..b532cab --- /dev/null +++ b/internal/repository/migrations/0005_policy_cleanup.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE risk_events + MODIFY severity ENUM('INFO','WARN','ALERT','CRITICAL','REPORT') NOT NULL; + +ALTER TABLE instruments + MODIFY free_order_limit_per_day INT NOT NULL DEFAULT 0; + +UPDATE schema_meta SET meta_value='0004' WHERE meta_key='schema_version'; diff --git a/internal/repository/migrations/0005_policy_cleanup.up.sql b/internal/repository/migrations/0005_policy_cleanup.up.sql new file mode 100644 index 0000000..36cd31a --- /dev/null +++ b/internal/repository/migrations/0005_policy_cleanup.up.sql @@ -0,0 +1,20 @@ +UPDATE instruments +SET free_order_limit_per_day=0 +WHERE ticker NOT IN ('TRUR', 'TGLD') AND free_order_limit_per_day=15; + +ALTER TABLE instruments + MODIFY free_order_limit_per_day INT NOT NULL DEFAULT 0 COMMENT '0 means no configured free-order cap'; + +UPDATE risk_events +SET + severity='INFO', + event_type=CASE + WHEN event_type LIKE 'report_%' THEN event_type + ELSE CONCAT('report_', event_type) + END +WHERE severity='REPORT'; + +ALTER TABLE risk_events + MODIFY severity ENUM('INFO','WARN','ALERT','CRITICAL') NOT NULL; + +UPDATE schema_meta SET meta_value='0005' WHERE meta_key='schema_version'; diff --git a/internal/repository/migrations/0006_position_lot_size.down.sql b/internal/repository/migrations/0006_position_lot_size.down.sql new file mode 100644 index 0000000..0cdc3ec --- /dev/null +++ b/internal/repository/migrations/0006_position_lot_size.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE positions DROP COLUMN lot_size; + +UPDATE schema_meta SET meta_value='0005' WHERE meta_key='schema_version'; diff --git a/internal/repository/migrations/0006_position_lot_size.up.sql b/internal/repository/migrations/0006_position_lot_size.up.sql new file mode 100644 index 0000000..05c64ee --- /dev/null +++ b/internal/repository/migrations/0006_position_lot_size.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE positions ADD COLUMN lot_size BIGINT NOT NULL DEFAULT 1 AFTER lots; + +UPDATE positions p +JOIN instruments i ON i.instrument_uid = p.instrument_uid +SET p.lot_size = i.lot +WHERE p.lot_size = 1 AND i.lot > 1; + +UPDATE schema_meta SET meta_value='0006' WHERE meta_key='schema_version'; diff --git a/internal/repository/migrations/migrations.go b/internal/repository/migrations/migrations.go new file mode 100644 index 0000000..fa4e551 --- /dev/null +++ b/internal/repository/migrations/migrations.go @@ -0,0 +1,8 @@ +package migrations + +import "embed" + +// FS contains SQL migrations used by both the daemon and cmd/migrate. +// +//go:embed *.sql +var FS embed.FS diff --git a/internal/repository/mysql/migrate.go b/internal/repository/mysql/migrate.go new file mode 100644 index 0000000..bd985c0 --- /dev/null +++ b/internal/repository/mysql/migrate.go @@ -0,0 +1,61 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/golang-migrate/migrate/v4" + migratemysql "github.com/golang-migrate/migrate/v4/database/mysql" + "github.com/golang-migrate/migrate/v4/source/iofs" + + "overnight-trading-bot/internal/repository/migrations" +) + +func ApplyMigrations(ctx context.Context, db *sql.DB) error { + if err := ctx.Err(); err != nil { + return err + } + driver, err := migratemysql.WithInstance(db, &migratemysql.Config{}) + if err != nil { + return fmt.Errorf("create mysql migration driver: %w", err) + } + source, err := iofs.New(migrations.FS, ".") + if err != nil { + return fmt.Errorf("create iofs migration source: %w", err) + } + m, err := migrate.NewWithInstance("iofs", source, "mysql", driver) + if err != nil { + return fmt.Errorf("create migrate instance: %w", err) + } + defer func() { + _, _ = m.Close() + }() + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("apply migrations: %w", err) + } + return nil +} + +func RollbackAll(db *sql.DB) error { + driver, err := migratemysql.WithInstance(db, &migratemysql.Config{}) + if err != nil { + return fmt.Errorf("create mysql migration driver: %w", err) + } + source, err := iofs.New(migrations.FS, ".") + if err != nil { + return fmt.Errorf("create iofs migration source: %w", err) + } + m, err := migrate.NewWithInstance("iofs", source, "mysql", driver) + if err != nil { + return fmt.Errorf("create migrate instance: %w", err) + } + defer func() { + _, _ = m.Close() + }() + if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("rollback migrations: %w", err) + } + return nil +} diff --git a/internal/repository/mysql/repository.go b/internal/repository/mysql/repository.go new file mode 100644 index 0000000..24c0752 --- /dev/null +++ b/internal/repository/mysql/repository.go @@ -0,0 +1,790 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + _ "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/repository" +) + +var _ repository.Repository = (*Repository)(nil) + +type Repository struct { + db *sqlx.DB + tx *sqlx.Tx +} + +func NewRepository(db *sqlx.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) RunInTx(ctx context.Context, fn func(ctx context.Context, repo repository.Repository) error) error { + if r.tx != nil { + return fn(ctx, r) + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + txRepo := &Repository{db: r.db, tx: tx} + if err := fn(ctx, txRepo); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("%w; rollback: %v", err, rbErr) + } + return err + } + return tx.Commit() +} + +func (r *Repository) execer() sqlx.ExtContext { + if r.tx != nil { + return r.tx + } + return r.db +} + +func (r *Repository) selectContext(ctx context.Context, dest any, query string, args ...any) error { + if r.tx != nil { + return r.tx.SelectContext(ctx, dest, query, args...) + } + return r.db.SelectContext(ctx, dest, query, args...) +} + +func (r *Repository) getContext(ctx context.Context, dest any, query string, args ...any) error { + if r.tx != nil { + return r.tx.GetContext(ctx, dest, query, args...) + } + return r.db.GetContext(ctx, dest, query, args...) +} + +func (r *Repository) UpsertInstrument(ctx context.Context, instrument domain.Instrument) error { + if instrument.UpdatedAt.IsZero() { + instrument.UpdatedAt = time.Now().UTC() + } + _, err := sqlx.NamedExecContext(ctx, r.execer(), ` +INSERT INTO instruments ( + instrument_uid, figi, ticker, class_code, name, lot, min_price_increment, currency, + enabled, fund_type, expected_commission_bps_per_side, free_order_limit_per_day, + quarantine, quarantine_reason, exclude_reason, updated_at +) VALUES ( + :instrument_uid, :figi, :ticker, :class_code, :name, :lot, :min_price_increment, :currency, + :enabled, :fund_type, :expected_commission_bps_per_side, :free_order_limit_per_day, + :quarantine, :quarantine_reason, :exclude_reason, :updated_at +) ON DUPLICATE KEY UPDATE + instrument_uid=VALUES(instrument_uid), + figi=VALUES(figi), + name=VALUES(name), + lot=VALUES(lot), + min_price_increment=VALUES(min_price_increment), + currency=VALUES(currency), + enabled=VALUES(enabled), + fund_type=VALUES(fund_type), + expected_commission_bps_per_side=VALUES(expected_commission_bps_per_side), + free_order_limit_per_day=VALUES(free_order_limit_per_day), + quarantine=VALUES(quarantine), + quarantine_reason=VALUES(quarantine_reason), + exclude_reason=VALUES(exclude_reason), + updated_at=VALUES(updated_at)`, instrumentRowFromDomain(instrument)) + return err +} + +func (r *Repository) ReplaceInstrument(ctx context.Context, oldInstrumentUID string, instrument domain.Instrument) error { + if oldInstrumentUID == "" || oldInstrumentUID == instrument.InstrumentUID { + return r.UpsertInstrument(ctx, instrument) + } + return r.RunInTx(ctx, func(ctx context.Context, repo repository.Repository) error { + txRepo, ok := repo.(*Repository) + if !ok { + return errors.New("unexpected repository implementation") + } + return txRepo.replaceInstrument(ctx, oldInstrumentUID, instrument) + }) +} + +func (r *Repository) replaceInstrument(ctx context.Context, oldInstrumentUID string, instrument domain.Instrument) error { + if instrument.UpdatedAt.IsZero() { + instrument.UpdatedAt = time.Now().UTC() + } + exists, err := r.instrumentExists(ctx, instrument.InstrumentUID) + if err != nil { + return err + } + if exists { + if err := r.mergeInstrumentUID(ctx, oldInstrumentUID, instrument.InstrumentUID); err != nil { + return err + } + return r.UpsertInstrument(ctx, instrument) + } + result, err := sqlx.NamedExecContext(ctx, r.execer(), ` +UPDATE instruments SET + instrument_uid=:instrument_uid, + figi=:figi, + ticker=:ticker, + class_code=:class_code, + name=:name, + lot=:lot, + min_price_increment=:min_price_increment, + currency=:currency, + enabled=:enabled, + fund_type=:fund_type, + expected_commission_bps_per_side=:expected_commission_bps_per_side, + free_order_limit_per_day=:free_order_limit_per_day, + quarantine=:quarantine, + quarantine_reason=:quarantine_reason, + exclude_reason=:exclude_reason, + updated_at=:updated_at +WHERE instrument_uid=:old_instrument_uid`, replaceInstrumentRowFromDomain(oldInstrumentUID, instrument)) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return r.UpsertInstrument(ctx, instrument) + } + return nil +} + +func (r *Repository) instrumentExists(ctx context.Context, instrumentUID string) (bool, error) { + var count int + if err := r.getContext(ctx, &count, `SELECT COUNT(*) FROM instruments WHERE instrument_uid=?`, instrumentUID); err != nil { + return false, err + } + return count > 0, nil +} + +func (r *Repository) mergeInstrumentUID(ctx context.Context, oldInstrumentUID, newInstrumentUID string) error { + if oldInstrumentUID == newInstrumentUID { + return nil + } + if err := r.mergeDailyCandles(ctx, oldInstrumentUID, newInstrumentUID); err != nil { + return err + } + if err := r.mergeMinuteCandles(ctx, oldInstrumentUID, newInstrumentUID); err != nil { + return err + } + if err := r.mergeFeatures(ctx, oldInstrumentUID, newInstrumentUID); err != nil { + return err + } + if err := r.mergeSignals(ctx, oldInstrumentUID, newInstrumentUID); err != nil { + return err + } + if err := r.mergeFreeOrders(ctx, oldInstrumentUID, newInstrumentUID); err != nil { + return err + } + for _, table := range []string{"orders", "positions", "risk_events"} { + if _, err := r.execer().ExecContext(ctx, fmt.Sprintf(`UPDATE %s SET instrument_uid=? WHERE instrument_uid=?`, table), newInstrumentUID, oldInstrumentUID); err != nil { + return err + } + } + _, err := r.execer().ExecContext(ctx, `DELETE FROM instruments WHERE instrument_uid=?`, oldInstrumentUID) + return err +} + +func (r *Repository) mergeDailyCandles(ctx context.Context, oldInstrumentUID, newInstrumentUID string) error { + _, err := r.execer().ExecContext(ctx, ` +INSERT INTO candles_daily (instrument_uid, trade_date, open, high, low, close, volume_lots, source, loaded_at) +SELECT ?, trade_date, open, high, low, close, volume_lots, source, loaded_at +FROM candles_daily WHERE instrument_uid=? +ON DUPLICATE KEY UPDATE + open=VALUES(open), high=VALUES(high), low=VALUES(low), close=VALUES(close), + volume_lots=VALUES(volume_lots), source=VALUES(source), loaded_at=VALUES(loaded_at)`, newInstrumentUID, oldInstrumentUID) + if err != nil { + return err + } + _, err = r.execer().ExecContext(ctx, `DELETE FROM candles_daily WHERE instrument_uid=?`, oldInstrumentUID) + return err +} + +func (r *Repository) mergeMinuteCandles(ctx context.Context, oldInstrumentUID, newInstrumentUID string) error { + _, err := r.execer().ExecContext(ctx, ` +INSERT INTO candles_minute (instrument_uid, ts, open, high, low, close, volume_lots, source, loaded_at) +SELECT ?, ts, open, high, low, close, volume_lots, source, loaded_at +FROM candles_minute WHERE instrument_uid=? +ON DUPLICATE KEY UPDATE + open=VALUES(open), high=VALUES(high), low=VALUES(low), close=VALUES(close), + volume_lots=VALUES(volume_lots), source=VALUES(source), loaded_at=VALUES(loaded_at)`, newInstrumentUID, oldInstrumentUID) + if err != nil { + return err + } + _, err = r.execer().ExecContext(ctx, `DELETE FROM candles_minute WHERE instrument_uid=?`, oldInstrumentUID) + return err +} + +func (r *Repository) mergeFeatures(ctx context.Context, oldInstrumentUID, newInstrumentUID string) error { + _, err := r.execer().ExecContext(ctx, ` +INSERT INTO features ( + instrument_uid, trade_date, r_on, r_day, mu_on_60, mu_on_252, sigma_on_60, + tstat_on_60, win_on_60, ewma_on, spread_bps, half_spread_bps, tick_bps, + adv_20, expected_cost_bps, net_edge_bps, entry_interval_volume, + exit_interval_volume, calculated_at +) +SELECT + ?, trade_date, r_on, r_day, mu_on_60, mu_on_252, sigma_on_60, + tstat_on_60, win_on_60, ewma_on, spread_bps, half_spread_bps, tick_bps, + adv_20, expected_cost_bps, net_edge_bps, entry_interval_volume, + exit_interval_volume, calculated_at +FROM features WHERE instrument_uid=? +ON DUPLICATE KEY UPDATE + r_on=VALUES(r_on), r_day=VALUES(r_day), mu_on_60=VALUES(mu_on_60), + mu_on_252=VALUES(mu_on_252), sigma_on_60=VALUES(sigma_on_60), + tstat_on_60=VALUES(tstat_on_60), win_on_60=VALUES(win_on_60), + ewma_on=VALUES(ewma_on), spread_bps=VALUES(spread_bps), + half_spread_bps=VALUES(half_spread_bps), tick_bps=VALUES(tick_bps), + adv_20=VALUES(adv_20), expected_cost_bps=VALUES(expected_cost_bps), + net_edge_bps=VALUES(net_edge_bps), entry_interval_volume=VALUES(entry_interval_volume), + exit_interval_volume=VALUES(exit_interval_volume), calculated_at=VALUES(calculated_at)`, newInstrumentUID, oldInstrumentUID) + if err != nil { + return err + } + _, err = r.execer().ExecContext(ctx, `DELETE FROM features WHERE instrument_uid=?`, oldInstrumentUID) + return err +} + +func (r *Repository) mergeSignals(ctx context.Context, oldInstrumentUID, newInstrumentUID string) error { + _, err := r.execer().ExecContext(ctx, ` +INSERT INTO signals ( + trade_date, instrument_uid, decision, score, net_edge_bps, target_notional, + target_lots, reject_reason, context_json, created_at +) +SELECT trade_date, ?, decision, score, net_edge_bps, target_notional, + target_lots, reject_reason, context_json, created_at +FROM signals WHERE instrument_uid=? +ON DUPLICATE KEY UPDATE + decision=VALUES(decision), score=VALUES(score), net_edge_bps=VALUES(net_edge_bps), + target_notional=VALUES(target_notional), target_lots=VALUES(target_lots), + reject_reason=VALUES(reject_reason), context_json=VALUES(context_json), + created_at=VALUES(created_at)`, newInstrumentUID, oldInstrumentUID) + if err != nil { + return err + } + _, err = r.execer().ExecContext(ctx, `DELETE FROM signals WHERE instrument_uid=?`, oldInstrumentUID) + return err +} + +func (r *Repository) mergeFreeOrders(ctx context.Context, oldInstrumentUID, newInstrumentUID string) error { + _, err := r.execer().ExecContext(ctx, ` +INSERT INTO free_order_counters (trade_date, instrument_uid, orders_sent) +SELECT trade_date, ?, orders_sent FROM free_order_counters WHERE instrument_uid=? +ON DUPLICATE KEY UPDATE orders_sent=GREATEST(orders_sent, VALUES(orders_sent))`, newInstrumentUID, oldInstrumentUID) + if err != nil { + return err + } + _, err = r.execer().ExecContext(ctx, `DELETE FROM free_order_counters WHERE instrument_uid=?`, oldInstrumentUID) + return err +} + +func (r *Repository) ListInstruments(ctx context.Context, includeDisabled bool) ([]domain.Instrument, error) { + query := `SELECT * FROM instruments` + if !includeDisabled { + query += ` WHERE enabled=1` + } + query += ` ORDER BY ticker` + var rows []instrumentRow + if err := r.selectContext(ctx, &rows, query); err != nil { + return nil, err + } + out := make([]domain.Instrument, 0, len(rows)) + for _, row := range rows { + out = append(out, row.domain()) + } + return out, nil +} + +func (r *Repository) QuarantineInstrument(ctx context.Context, instrumentUID, reason string) error { + _, err := r.execer().ExecContext(ctx, ` +UPDATE instruments SET quarantine=1, quarantine_reason=?, updated_at=UTC_TIMESTAMP(3) +WHERE instrument_uid=?`, reason, instrumentUID) + return err +} + +func (r *Repository) UpsertDailyCandles(ctx context.Context, candles []domain.Candle) error { + for _, candle := range candles { + if candle.LoadedAt.IsZero() { + candle.LoadedAt = time.Now().UTC() + } + _, err := sqlx.NamedExecContext(ctx, r.execer(), ` +INSERT INTO candles_daily ( + instrument_uid, trade_date, open, high, low, close, volume_lots, source, loaded_at +) VALUES ( + :instrument_uid, :trade_date, :open, :high, :low, :close, :volume_lots, :source, :loaded_at +) ON DUPLICATE KEY UPDATE + open=VALUES(open), high=VALUES(high), low=VALUES(low), close=VALUES(close), + volume_lots=VALUES(volume_lots), source=VALUES(source), loaded_at=VALUES(loaded_at)`, candleRowFromDomain(candle)) + if err != nil { + return err + } + } + return nil +} + +func (r *Repository) ListDailyCandles(ctx context.Context, instrumentUID string, from, to time.Time) ([]domain.Candle, error) { + var rows []candleRow + if err := r.selectContext(ctx, &rows, ` +SELECT * FROM candles_daily +WHERE instrument_uid=? AND trade_date BETWEEN ? AND ? +ORDER BY trade_date`, instrumentUID, dateOnly(from), dateOnly(to)); err != nil { + return nil, err + } + out := make([]domain.Candle, 0, len(rows)) + for _, row := range rows { + out = append(out, row.domain()) + } + return out, nil +} + +func (r *Repository) UpsertMinuteCandles(ctx context.Context, candles []domain.Candle) error { + for _, candle := range candles { + if candle.LoadedAt.IsZero() { + candle.LoadedAt = time.Now().UTC() + } + _, err := sqlx.NamedExecContext(ctx, r.execer(), ` +INSERT INTO candles_minute ( + instrument_uid, ts, open, high, low, close, volume_lots, source, loaded_at +) VALUES ( + :instrument_uid, :trade_date, :open, :high, :low, :close, :volume_lots, :source, :loaded_at +) ON DUPLICATE KEY UPDATE + open=VALUES(open), high=VALUES(high), low=VALUES(low), close=VALUES(close), + volume_lots=VALUES(volume_lots), source=VALUES(source), loaded_at=VALUES(loaded_at)`, candleRowFromDomain(candle)) + if err != nil { + return err + } + } + return nil +} + +func (r *Repository) ListMinuteCandles(ctx context.Context, instrumentUID string, from, to time.Time) ([]domain.Candle, error) { + var rows []candleRow + if err := r.selectContext(ctx, &rows, ` +SELECT instrument_uid, ts AS trade_date, open, high, low, close, volume_lots, source, loaded_at +FROM candles_minute +WHERE instrument_uid=? AND ts BETWEEN ? AND ? +ORDER BY ts`, instrumentUID, from.UTC(), to.UTC()); err != nil { + return nil, err + } + out := make([]domain.Candle, 0, len(rows)) + for _, row := range rows { + out = append(out, row.domain()) + } + return out, nil +} + +func (r *Repository) UpsertFeature(ctx context.Context, feature domain.FeatureSet) error { + if feature.CalculatedAt.IsZero() { + feature.CalculatedAt = time.Now().UTC() + } + _, err := sqlx.NamedExecContext(ctx, r.execer(), ` +INSERT INTO features ( + instrument_uid, trade_date, r_on, r_day, mu_on_60, mu_on_252, sigma_on_60, + tstat_on_60, win_on_60, ewma_on, spread_bps, half_spread_bps, tick_bps, + adv_20, expected_cost_bps, net_edge_bps, entry_interval_volume, + exit_interval_volume, calculated_at +) VALUES ( + :instrument_uid, :trade_date, :r_on, :r_day, :mu_on_60, :mu_on_252, :sigma_on_60, + :tstat_on_60, :win_on_60, :ewma_on, :spread_bps, :half_spread_bps, :tick_bps, + :adv_20, :expected_cost_bps, :net_edge_bps, :entry_interval_volume, + :exit_interval_volume, :calculated_at +) ON DUPLICATE KEY UPDATE + r_on=VALUES(r_on), r_day=VALUES(r_day), mu_on_60=VALUES(mu_on_60), + mu_on_252=VALUES(mu_on_252), sigma_on_60=VALUES(sigma_on_60), + tstat_on_60=VALUES(tstat_on_60), win_on_60=VALUES(win_on_60), + ewma_on=VALUES(ewma_on), spread_bps=VALUES(spread_bps), + half_spread_bps=VALUES(half_spread_bps), tick_bps=VALUES(tick_bps), + adv_20=VALUES(adv_20), expected_cost_bps=VALUES(expected_cost_bps), + net_edge_bps=VALUES(net_edge_bps), entry_interval_volume=VALUES(entry_interval_volume), + exit_interval_volume=VALUES(exit_interval_volume), calculated_at=VALUES(calculated_at)`, featureRowFromDomain(feature)) + return err +} + +func (r *Repository) GetFeature(ctx context.Context, instrumentUID string, tradeDate time.Time) (domain.FeatureSet, error) { + var row featureRow + if err := r.getContext(ctx, &row, `SELECT * FROM features WHERE instrument_uid=? AND trade_date=?`, instrumentUID, dateOnly(tradeDate)); err != nil { + return domain.FeatureSet{}, err + } + return row.domain(), nil +} + +func (r *Repository) UpsertSignal(ctx context.Context, signal domain.Signal) error { + if signal.CreatedAt.IsZero() { + signal.CreatedAt = time.Now().UTC() + } + if signal.ContextJSON == "" { + signal.ContextJSON = "{}" + } + _, err := sqlx.NamedExecContext(ctx, r.execer(), ` +INSERT INTO signals ( + trade_date, instrument_uid, decision, score, net_edge_bps, target_notional, + target_lots, reject_reason, context_json, created_at +) VALUES ( + :trade_date, :instrument_uid, :decision, :score, :net_edge_bps, :target_notional, + :target_lots, :reject_reason, :context_json, :created_at +) ON DUPLICATE KEY UPDATE + decision=VALUES(decision), score=VALUES(score), net_edge_bps=VALUES(net_edge_bps), + target_notional=VALUES(target_notional), target_lots=VALUES(target_lots), + reject_reason=VALUES(reject_reason), context_json=VALUES(context_json), + created_at=VALUES(created_at)`, signalRowFromDomain(signal)) + return err +} + +func (r *Repository) ListSignals(ctx context.Context, tradeDate time.Time) ([]domain.Signal, error) { + var rows []signalRow + if err := r.selectContext(ctx, &rows, `SELECT * FROM signals WHERE trade_date=? ORDER BY id`, dateOnly(tradeDate)); err != nil { + return nil, err + } + out := make([]domain.Signal, 0, len(rows)) + for _, row := range rows { + out = append(out, row.domain()) + } + return out, nil +} + +func (r *Repository) UpsertOrder(ctx context.Context, order domain.Order) error { + now := time.Now().UTC() + if order.CreatedAt.IsZero() { + order.CreatedAt = now + } + order.UpdatedAt = now + if order.RawStateJSON == "" { + order.RawStateJSON = "{}" + } + _, err := sqlx.NamedExecContext(ctx, r.execer(), ` +INSERT INTO orders ( + client_order_id, broker_order_id, account_id_hash, instrument_uid, trade_date, + side, order_type, limit_price, quantity_lots, filled_lots, avg_fill_price, + status, commission, attempt_no, raw_state_json, created_at, updated_at +) VALUES ( + :client_order_id, :broker_order_id, :account_id_hash, :instrument_uid, :trade_date, + :side, :order_type, :limit_price, :quantity_lots, :filled_lots, :avg_fill_price, + :status, :commission, :attempt_no, :raw_state_json, :created_at, :updated_at +) ON DUPLICATE KEY UPDATE + broker_order_id=VALUES(broker_order_id), filled_lots=VALUES(filled_lots), + avg_fill_price=VALUES(avg_fill_price), status=VALUES(status), + commission=VALUES(commission), raw_state_json=VALUES(raw_state_json), + updated_at=VALUES(updated_at)`, orderRowFromDomain(order)) + return err +} + +func (r *Repository) UpdateOrderStatus(ctx context.Context, clientOrderID string, status domain.OrderStatus, filledLots int64, rawJSON string) error { + if rawJSON == "" { + rawJSON = "{}" + } + _, err := r.execer().ExecContext(ctx, ` +UPDATE orders SET status=?, filled_lots=?, raw_state_json=?, updated_at=UTC_TIMESTAMP(3) +WHERE client_order_id=?`, status, filledLots, rawJSON, clientOrderID) + return err +} + +func (r *Repository) ListActiveOrders(ctx context.Context, accountIDHash string) ([]domain.Order, error) { + var rows []orderRow + if err := r.selectContext(ctx, &rows, ` +SELECT * FROM orders +WHERE account_id_hash=? AND status IN ('NEW','SENT','PARTIALLY_FILLED') +ORDER BY created_at`, accountIDHash); err != nil { + return nil, err + } + out := make([]domain.Order, 0, len(rows)) + for _, row := range rows { + out = append(out, row.domain()) + } + return out, nil +} + +func (r *Repository) ListOrders(ctx context.Context, accountIDHash string, from, to time.Time) ([]domain.Order, error) { + var rows []orderRow + if err := r.selectContext(ctx, &rows, ` +SELECT * FROM orders +WHERE account_id_hash=? AND trade_date BETWEEN ? AND ? +ORDER BY created_at`, accountIDHash, dateOnly(from), dateOnly(to)); err != nil { + return nil, err + } + out := make([]domain.Order, 0, len(rows)) + for _, row := range rows { + out = append(out, row.domain()) + } + return out, nil +} + +func (r *Repository) UpsertPosition(ctx context.Context, position domain.Position) error { + if position.UpdatedAt.IsZero() { + position.UpdatedAt = time.Now().UTC() + } + _, err := sqlx.NamedExecContext(ctx, r.execer(), ` +INSERT INTO positions ( + id, account_id_hash, instrument_uid, open_trade_date, lots, lot_size, exit_filled_lots, + avg_buy_price, avg_sell_price, status, gross_pnl, net_pnl, commission_total, + realized_edge_bps, opened_at, closed_at, updated_at +) VALUES ( + NULLIF(:id, 0), :account_id_hash, :instrument_uid, :open_trade_date, :lots, :lot_size, :exit_filled_lots, + :avg_buy_price, :avg_sell_price, :status, :gross_pnl, :net_pnl, :commission_total, + :realized_edge_bps, :opened_at, :closed_at, :updated_at +) ON DUPLICATE KEY UPDATE + lots=VALUES(lots), lot_size=VALUES(lot_size), exit_filled_lots=VALUES(exit_filled_lots), avg_buy_price=VALUES(avg_buy_price), avg_sell_price=VALUES(avg_sell_price), + status=VALUES(status), gross_pnl=VALUES(gross_pnl), net_pnl=VALUES(net_pnl), + commission_total=VALUES(commission_total), realized_edge_bps=VALUES(realized_edge_bps), + opened_at=VALUES(opened_at), closed_at=VALUES(closed_at), updated_at=VALUES(updated_at)`, positionRowFromDomain(position)) + return err +} + +func (r *Repository) ListOpenPositions(ctx context.Context, accountIDHash string) ([]domain.Position, error) { + var rows []positionRow + if err := r.selectContext(ctx, &rows, ` +SELECT * FROM positions +WHERE account_id_hash=? AND status NOT IN ('NO_POSITION','EXIT_FILLED','QUARANTINE') +ORDER BY updated_at`, accountIDHash); err != nil { + return nil, err + } + out := make([]domain.Position, 0, len(rows)) + for _, row := range rows { + out = append(out, row.domain()) + } + return out, nil +} + +func (r *Repository) ListPositions(ctx context.Context, accountIDHash string, from, to time.Time) ([]domain.Position, error) { + var rows []positionRow + if err := r.selectContext(ctx, &rows, ` +SELECT * FROM positions +WHERE account_id_hash=? AND open_trade_date BETWEEN ? AND ? +ORDER BY updated_at`, accountIDHash, dateOnly(from), dateOnly(to)); err != nil { + return nil, err + } + out := make([]domain.Position, 0, len(rows)) + for _, row := range rows { + out = append(out, row.domain()) + } + return out, nil +} + +func (r *Repository) InsertRiskEvent(ctx context.Context, event domain.RiskEvent) error { + if event.TS.IsZero() { + event.TS = time.Now().UTC() + } + if event.ContextJSON == "" { + event.ContextJSON = "{}" + } + _, err := sqlx.NamedExecContext(ctx, r.execer(), ` +INSERT INTO risk_events (ts, severity, event_type, instrument_uid, message, raw_context_json) +VALUES (:ts, :severity, :event_type, :instrument_uid, :message, :raw_context_json)`, riskEventRowFromDomain(event)) + return err +} + +func (r *Repository) GetFreeOrdersSent(ctx context.Context, tradeDate time.Time, instrumentUID string) (int, error) { + var sent int + err := r.getContext(ctx, &sent, ` +SELECT orders_sent FROM free_order_counters WHERE trade_date=? AND instrument_uid=?`, dateOnly(tradeDate), instrumentUID) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + return sent, err +} + +func (r *Repository) IncrementFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int) error { + _, err := r.execer().ExecContext(ctx, ` +INSERT INTO free_order_counters (trade_date, instrument_uid, orders_sent) +VALUES (?, ?, ?) +ON DUPLICATE KEY UPDATE orders_sent=orders_sent+VALUES(orders_sent)`, dateOnly(tradeDate), instrumentUID, delta) + return err +} + +func (r *Repository) GetSystemState(ctx context.Context) (domain.SystemState, bool, string, error) { + var row struct { + State string `db:"state"` + Halted bool `db:"halted"` + HaltReason sql.NullString `db:"halt_reason"` + } + if err := r.getContext(ctx, &row, `SELECT state, halted, halt_reason FROM system_state WHERE id=1`); err != nil { + return "", false, "", err + } + return domain.SystemState(row.State), row.Halted, row.HaltReason.String, nil +} + +func (r *Repository) SaveSystemState(ctx context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, contextJSON string) error { + if contextJSON == "" { + contextJSON = "{}" + } + _, err := r.execer().ExecContext(ctx, ` +INSERT INTO system_state (id, state, mode, halted, halt_reason, last_heartbeat, context_json) +VALUES (1, ?, ?, ?, ?, UTC_TIMESTAMP(3), ?) +ON DUPLICATE KEY UPDATE + state=VALUES(state), mode=VALUES(mode), halted=VALUES(halted), + halt_reason=VALUES(halt_reason), last_heartbeat=VALUES(last_heartbeat), + context_json=VALUES(context_json)`, state, mode, halted, nullableString(reason), contextJSON) + return err +} + +func (r *Repository) Unhalt(ctx context.Context, reason string) error { + return r.RunInTx(ctx, func(ctx context.Context, repo repository.Repository) error { + state, halted, haltReason, err := repo.GetSystemState(ctx) + if err != nil { + return err + } + if !halted && state != domain.StateHalted { + return fmt.Errorf("system is not halted") + } + if err := repo.InsertRiskEvent(ctx, domain.RiskEvent{ + TS: time.Now().UTC(), + Severity: domain.SeverityInfo, + EventType: "manual_unhalt", + Message: fmt.Sprintf("%s (previous halt: %s)", reason, haltReason), + }); err != nil { + return err + } + mode := domain.ModePaper + if txRepo, ok := repo.(*Repository); ok { + currentMode, err := txRepo.getSystemMode(ctx) + if err != nil { + return err + } + mode = currentMode + } + return repo.SaveSystemState(ctx, domain.StateInit, mode, false, "", `{"manual_unhalt":true}`) + }) +} + +func (r *Repository) getSystemMode(ctx context.Context) (domain.Mode, error) { + var raw string + if err := r.getContext(ctx, &raw, `SELECT mode FROM system_state WHERE id=1`); err != nil { + return "", err + } + mode, err := domain.ParseMode(raw) + if err != nil { + return "", err + } + return mode, nil +} + +func (r *Repository) WasDailyReportSent(ctx context.Context, reportDate time.Time, accountIDHash string) (bool, error) { + var count int + if err := r.getContext(ctx, &count, ` +SELECT COUNT(*) FROM daily_reports WHERE report_date=? AND account_id_hash=?`, dateOnly(reportDate), accountIDHash); err != nil { + return false, err + } + return count > 0, nil +} + +func (r *Repository) MarkDailyReportSent(ctx context.Context, reportDate time.Time, accountIDHash string) error { + _, err := r.execer().ExecContext(ctx, ` +INSERT INTO daily_reports (report_date, account_id_hash, sent_at) +VALUES (?, ?, UTC_TIMESTAMP(3)) +ON DUPLICATE KEY UPDATE sent_at=sent_at`, dateOnly(reportDate), accountIDHash) + return err +} + +func (r *Repository) InsertReconciliation(ctx context.Context, ts time.Time, diffJSON string, hasDiff bool) error { + if ts.IsZero() { + ts = time.Now().UTC() + } + if diffJSON == "" { + diffJSON = "[]" + } + _, err := r.execer().ExecContext(ctx, ` +INSERT INTO reconciliations (ts, has_diff, diff_json) +VALUES (?, ?, ?)`, ts, hasDiff, diffJSON) + return err +} + +func dateOnly(t time.Time) time.Time { + y, m, d := t.UTC().Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +func nullableString(s string) any { + if s == "" { + return nil + } + return s +} + +type instrumentRow struct { + InstrumentUID string `db:"instrument_uid"` + Figi sql.NullString `db:"figi"` + Ticker string `db:"ticker"` + ClassCode string `db:"class_code"` + Name string `db:"name"` + Lot int64 `db:"lot"` + MinPriceIncrement decimal.Decimal `db:"min_price_increment"` + Currency string `db:"currency"` + Enabled bool `db:"enabled"` + FundType string `db:"fund_type"` + ExpectedCommissionBpsPerSide decimal.Decimal `db:"expected_commission_bps_per_side"` + FreeOrderLimitPerDay int `db:"free_order_limit_per_day"` + Quarantine bool `db:"quarantine"` + QuarantineReason sql.NullString `db:"quarantine_reason"` + ExcludeReason sql.NullString `db:"exclude_reason"` + UpdatedAt time.Time `db:"updated_at"` +} + +func instrumentRowFromDomain(instrument domain.Instrument) instrumentRow { + return instrumentRow{ + InstrumentUID: instrument.InstrumentUID, + Figi: sql.NullString{String: instrument.Figi, Valid: instrument.Figi != ""}, + Ticker: instrument.Ticker, + ClassCode: instrument.ClassCode, + Name: instrument.Name, + Lot: instrument.Lot, + MinPriceIncrement: instrument.MinPriceIncrement, + Currency: instrument.Currency, + Enabled: instrument.Enabled, + FundType: instrument.FundType, + ExpectedCommissionBpsPerSide: instrument.ExpectedCommissionBpsPerSide, + FreeOrderLimitPerDay: instrument.FreeOrderLimitPerDay, + Quarantine: instrument.Quarantine, + QuarantineReason: sql.NullString{String: instrument.QuarantineReason, Valid: instrument.QuarantineReason != ""}, + ExcludeReason: sql.NullString{String: instrument.ExcludeReason, Valid: instrument.ExcludeReason != ""}, + UpdatedAt: instrument.UpdatedAt, + } +} + +func replaceInstrumentRowFromDomain(oldInstrumentUID string, instrument domain.Instrument) map[string]any { + row := instrumentRowFromDomain(instrument) + return map[string]any{ + "instrument_uid": row.InstrumentUID, + "figi": row.Figi, + "ticker": row.Ticker, + "class_code": row.ClassCode, + "name": row.Name, + "lot": row.Lot, + "min_price_increment": row.MinPriceIncrement, + "currency": row.Currency, + "enabled": row.Enabled, + "fund_type": row.FundType, + "expected_commission_bps_per_side": row.ExpectedCommissionBpsPerSide, + "free_order_limit_per_day": row.FreeOrderLimitPerDay, + "quarantine": row.Quarantine, + "quarantine_reason": row.QuarantineReason, + "exclude_reason": row.ExcludeReason, + "updated_at": row.UpdatedAt, + "old_instrument_uid": oldInstrumentUID, + } +} + +func (r instrumentRow) domain() domain.Instrument { + return domain.Instrument{ + InstrumentUID: r.InstrumentUID, + Figi: r.Figi.String, + Ticker: r.Ticker, + ClassCode: r.ClassCode, + Name: r.Name, + Lot: r.Lot, + MinPriceIncrement: r.MinPriceIncrement, + Currency: r.Currency, + Enabled: r.Enabled, + FundType: r.FundType, + ExpectedCommissionBpsPerSide: r.ExpectedCommissionBpsPerSide, + FreeOrderLimitPerDay: r.FreeOrderLimitPerDay, + Quarantine: r.Quarantine, + QuarantineReason: r.QuarantineReason.String, + ExcludeReason: r.ExcludeReason.String, + UpdatedAt: r.UpdatedAt, + } +} diff --git a/internal/repository/mysql/repository_integration_test.go b/internal/repository/mysql/repository_integration_test.go new file mode 100644 index 0000000..970741a --- /dev/null +++ b/internal/repository/mysql/repository_integration_test.go @@ -0,0 +1,114 @@ +//go:build integration + +package mysql + +import ( + "context" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mariadb" + + "overnight-trading-bot/internal/domain" +) + +func TestRepositoryMariaDBMigrationsAndRoundTrip(t *testing.T) { + ctx := context.Background() + container, err := mariadb.Run(ctx, + "mariadb:11.4", + mariadb.WithDatabase("overnight_bot"), + mariadb.WithUsername("bot"), + mariadb.WithPassword("bot"), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := testcontainers.TerminateContainer(container); err != nil { + t.Logf("terminate mariadb: %v", err) + } + }) + dsn, err := container.ConnectionString(ctx, "parseTime=true", "loc=UTC", "multiStatements=true") + if err != nil { + t.Fatal(err) + } + db, err := sqlx.Open("mysql", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = db.Close() + }) + if err := db.PingContext(ctx); err != nil { + t.Fatal(err) + } + if err := ApplyMigrations(ctx, db.DB); err != nil { + t.Fatal(err) + } + repo := NewRepository(db) + instrument := domain.Instrument{ + InstrumentUID: "uid-trur", + Ticker: "TRUR", + ClassCode: "TQTF", + Name: "TRUR", + Lot: 1, + MinPriceIncrement: decimal.NewFromFloat(0.0001), + Currency: "RUB", + Enabled: true, + } + if err := repo.ReplaceInstrument(ctx, "PENDING:TRUR", instrument); err != nil { + t.Fatal(err) + } + tradeDate := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC) + position := domain.Position{ + AccountIDHash: "hash", + InstrumentUID: "uid-trur", + OpenTradeDate: tradeDate, + Lots: 10, + AvgBuyPrice: decimal.NewFromInt(100), + Status: domain.PositionHoldingOvernight, + } + if err := repo.UpsertPosition(ctx, position); err != nil { + t.Fatal(err) + } + position.Lots = 8 + position.ExitFilledLots = 2 + if err := repo.UpsertPosition(ctx, position); err != nil { + t.Fatal(err) + } + var count int + if err := db.GetContext(ctx, &count, ` +SELECT COUNT(*) FROM positions WHERE account_id_hash='hash' AND instrument_uid='uid-trur' AND open_trade_date=?`, tradeDate); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("positions count=%d, want 1", count) + } + if err := repo.MarkDailyReportSent(ctx, tradeDate, "hash"); err != nil { + t.Fatal(err) + } + sent, err := repo.WasDailyReportSent(ctx, tradeDate, "hash") + if err != nil { + t.Fatal(err) + } + if !sent { + t.Fatalf("daily report marker was not persisted") + } + if err := repo.UpsertOrder(ctx, domain.Order{ + ClientOrderID: "bad", + AccountIDHash: "hash", + InstrumentUID: "missing", + TradeDate: tradeDate, + Side: domain.SideBuy, + OrderType: domain.OrderTypeLimit, + LimitPrice: decimal.NewFromInt(100), + QuantityLots: 1, + Status: domain.OrderStatusSent, + RawStateJSON: "{}", + }); err == nil { + t.Fatalf("expected FK failure for missing instrument") + } +} diff --git a/internal/repository/mysql/rows.go b/internal/repository/mysql/rows.go new file mode 100644 index 0000000..a5770d3 --- /dev/null +++ b/internal/repository/mysql/rows.go @@ -0,0 +1,338 @@ +package mysql + +import ( + "database/sql" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" +) + +type candleRow struct { + InstrumentUID string `db:"instrument_uid"` + TradeDate time.Time `db:"trade_date"` + Open decimal.Decimal `db:"open"` + High decimal.Decimal `db:"high"` + Low decimal.Decimal `db:"low"` + Close decimal.Decimal `db:"close"` + VolumeLots decimal.Decimal `db:"volume_lots"` + Source string `db:"source"` + LoadedAt time.Time `db:"loaded_at"` +} + +func candleRowFromDomain(candle domain.Candle) candleRow { + return candleRow{ + InstrumentUID: candle.InstrumentUID, + TradeDate: dateOnly(candle.TradeDate), + Open: candle.Open, + High: candle.High, + Low: candle.Low, + Close: candle.Close, + VolumeLots: candle.VolumeLots, + Source: candle.Source, + LoadedAt: candle.LoadedAt, + } +} + +func (r candleRow) domain() domain.Candle { + return domain.Candle{ + InstrumentUID: r.InstrumentUID, + TradeDate: r.TradeDate, + Open: r.Open, + High: r.High, + Low: r.Low, + Close: r.Close, + VolumeLots: r.VolumeLots, + Source: r.Source, + LoadedAt: r.LoadedAt, + } +} + +type featureRow struct { + InstrumentUID string `db:"instrument_uid"` + TradeDate time.Time `db:"trade_date"` + ROn decimal.Decimal `db:"r_on"` + RDay decimal.Decimal `db:"r_day"` + MuOn60 decimal.Decimal `db:"mu_on_60"` + MuOn252 decimal.Decimal `db:"mu_on_252"` + SigmaOn60 decimal.Decimal `db:"sigma_on_60"` + TStatOn60 decimal.Decimal `db:"tstat_on_60"` + WinOn60 decimal.Decimal `db:"win_on_60"` + EWMAOn decimal.Decimal `db:"ewma_on"` + SpreadBps decimal.Decimal `db:"spread_bps"` + HalfSpreadBps decimal.Decimal `db:"half_spread_bps"` + TickBps decimal.Decimal `db:"tick_bps"` + ADV20 decimal.Decimal `db:"adv_20"` + ExpectedCostBps decimal.Decimal `db:"expected_cost_bps"` + NetEdgeBps decimal.Decimal `db:"net_edge_bps"` + EntryIntervalVolume decimal.Decimal `db:"entry_interval_volume"` + ExitIntervalVolume decimal.Decimal `db:"exit_interval_volume"` + CalculatedAt time.Time `db:"calculated_at"` +} + +func featureRowFromDomain(feature domain.FeatureSet) featureRow { + return featureRow{ + InstrumentUID: feature.InstrumentUID, + TradeDate: dateOnly(feature.TradeDate), + ROn: feature.ROn, + RDay: feature.RDay, + MuOn60: feature.MuOn60, + MuOn252: feature.MuOn252, + SigmaOn60: feature.SigmaOn60, + TStatOn60: feature.TStatOn60, + WinOn60: feature.WinOn60, + EWMAOn: feature.EWMAOn, + SpreadBps: feature.SpreadBps, + HalfSpreadBps: feature.HalfSpreadBps, + TickBps: feature.TickBps, + ADV20: feature.ADV20, + ExpectedCostBps: feature.ExpectedCostBps, + NetEdgeBps: feature.NetEdgeBps, + EntryIntervalVolume: feature.EntryIntervalVolume, + ExitIntervalVolume: feature.ExitIntervalVolume, + CalculatedAt: feature.CalculatedAt, + } +} + +func (r featureRow) domain() domain.FeatureSet { + return domain.FeatureSet{ + InstrumentUID: r.InstrumentUID, + TradeDate: r.TradeDate, + ROn: r.ROn, + RDay: r.RDay, + MuOn60: r.MuOn60, + MuOn252: r.MuOn252, + SigmaOn60: r.SigmaOn60, + TStatOn60: r.TStatOn60, + WinOn60: r.WinOn60, + EWMAOn: r.EWMAOn, + SpreadBps: r.SpreadBps, + HalfSpreadBps: r.HalfSpreadBps, + TickBps: r.TickBps, + ADV20: r.ADV20, + ExpectedCostBps: r.ExpectedCostBps, + NetEdgeBps: r.NetEdgeBps, + EntryIntervalVolume: r.EntryIntervalVolume, + ExitIntervalVolume: r.ExitIntervalVolume, + CalculatedAt: r.CalculatedAt, + } +} + +type signalRow struct { + ID int64 `db:"id"` + TradeDate time.Time `db:"trade_date"` + InstrumentUID string `db:"instrument_uid"` + Decision string `db:"decision"` + Score decimal.Decimal `db:"score"` + NetEdgeBps decimal.Decimal `db:"net_edge_bps"` + TargetNotional decimal.Decimal `db:"target_notional"` + TargetLots int64 `db:"target_lots"` + RejectReason sql.NullString `db:"reject_reason"` + ContextJSON sql.NullString `db:"context_json"` + CreatedAt time.Time `db:"created_at"` +} + +func signalRowFromDomain(signal domain.Signal) signalRow { + return signalRow{ + ID: signal.ID, + TradeDate: dateOnly(signal.TradeDate), + InstrumentUID: signal.InstrumentUID, + Decision: string(signal.Decision), + Score: signal.Score, + NetEdgeBps: signal.NetEdgeBps, + TargetNotional: signal.TargetNotional, + TargetLots: signal.TargetLots, + RejectReason: sql.NullString{String: signal.RejectReason, Valid: signal.RejectReason != ""}, + ContextJSON: sql.NullString{String: signal.ContextJSON, Valid: signal.ContextJSON != ""}, + CreatedAt: signal.CreatedAt, + } +} + +func (r signalRow) domain() domain.Signal { + return domain.Signal{ + ID: r.ID, + TradeDate: r.TradeDate, + InstrumentUID: r.InstrumentUID, + Decision: domain.SignalDecision(r.Decision), + Score: r.Score, + NetEdgeBps: r.NetEdgeBps, + TargetNotional: r.TargetNotional, + TargetLots: r.TargetLots, + RejectReason: r.RejectReason.String, + ContextJSON: r.ContextJSON.String, + CreatedAt: r.CreatedAt, + } +} + +type orderRow struct { + ClientOrderID string `db:"client_order_id"` + BrokerOrderID sql.NullString `db:"broker_order_id"` + AccountIDHash string `db:"account_id_hash"` + InstrumentUID string `db:"instrument_uid"` + TradeDate time.Time `db:"trade_date"` + Side string `db:"side"` + OrderType string `db:"order_type"` + LimitPrice decimal.Decimal `db:"limit_price"` + QuantityLots int64 `db:"quantity_lots"` + FilledLots int64 `db:"filled_lots"` + AvgFillPrice decimal.Decimal `db:"avg_fill_price"` + Status string `db:"status"` + Commission decimal.Decimal `db:"commission"` + AttemptNo int `db:"attempt_no"` + RawStateJSON sql.NullString `db:"raw_state_json"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func orderRowFromDomain(order domain.Order) orderRow { + return orderRow{ + ClientOrderID: order.ClientOrderID, + BrokerOrderID: sql.NullString{ + String: order.BrokerOrderID, + Valid: order.BrokerOrderID != "", + }, + AccountIDHash: order.AccountIDHash, + InstrumentUID: order.InstrumentUID, + TradeDate: dateOnly(order.TradeDate), + Side: string(order.Side), + OrderType: string(order.OrderType), + LimitPrice: order.LimitPrice, + QuantityLots: order.QuantityLots, + FilledLots: order.FilledLots, + AvgFillPrice: order.AvgFillPrice, + Status: string(order.Status), + Commission: order.Commission, + AttemptNo: order.AttemptNo, + RawStateJSON: sql.NullString{ + String: order.RawStateJSON, + Valid: order.RawStateJSON != "", + }, + CreatedAt: order.CreatedAt, + UpdatedAt: order.UpdatedAt, + } +} + +func (r orderRow) domain() domain.Order { + return domain.Order{ + ClientOrderID: r.ClientOrderID, + BrokerOrderID: r.BrokerOrderID.String, + AccountIDHash: r.AccountIDHash, + InstrumentUID: r.InstrumentUID, + TradeDate: r.TradeDate, + Side: domain.Side(r.Side), + OrderType: domain.OrderType(r.OrderType), + LimitPrice: r.LimitPrice, + QuantityLots: r.QuantityLots, + FilledLots: r.FilledLots, + AvgFillPrice: r.AvgFillPrice, + Status: domain.OrderStatus(r.Status), + Commission: r.Commission, + AttemptNo: r.AttemptNo, + RawStateJSON: r.RawStateJSON.String, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +type positionRow struct { + ID int64 `db:"id"` + AccountIDHash string `db:"account_id_hash"` + InstrumentUID string `db:"instrument_uid"` + OpenTradeDate time.Time `db:"open_trade_date"` + Lots int64 `db:"lots"` + Lot int64 `db:"lot_size"` + ExitFilledLots int64 `db:"exit_filled_lots"` + AvgBuyPrice decimal.Decimal `db:"avg_buy_price"` + AvgSellPrice decimal.Decimal `db:"avg_sell_price"` + Status string `db:"status"` + GrossPnL decimal.Decimal `db:"gross_pnl"` + NetPnL decimal.Decimal `db:"net_pnl"` + CommissionTotal decimal.Decimal `db:"commission_total"` + RealizedEdgeBps decimal.Decimal `db:"realized_edge_bps"` + OpenedAt sql.NullTime `db:"opened_at"` + ClosedAt sql.NullTime `db:"closed_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func positionRowFromDomain(position domain.Position) positionRow { + lot := position.Lot + if lot <= 0 { + lot = 1 + } + return positionRow{ + ID: position.ID, + AccountIDHash: position.AccountIDHash, + InstrumentUID: position.InstrumentUID, + OpenTradeDate: dateOnly(position.OpenTradeDate), + Lots: position.Lots, + Lot: lot, + ExitFilledLots: position.ExitFilledLots, + AvgBuyPrice: position.AvgBuyPrice, + AvgSellPrice: position.AvgSellPrice, + Status: string(position.Status), + GrossPnL: position.GrossPnL, + NetPnL: position.NetPnL, + CommissionTotal: position.CommissionTotal, + RealizedEdgeBps: position.RealizedEdgeBps, + OpenedAt: nullableTime(position.OpenedAt), + ClosedAt: nullableTime(position.ClosedAt), + UpdatedAt: position.UpdatedAt, + } +} + +func (r positionRow) domain() domain.Position { + return domain.Position{ + ID: r.ID, + AccountIDHash: r.AccountIDHash, + InstrumentUID: r.InstrumentUID, + OpenTradeDate: r.OpenTradeDate, + Lots: r.Lots, + Lot: r.Lot, + ExitFilledLots: r.ExitFilledLots, + AvgBuyPrice: r.AvgBuyPrice, + AvgSellPrice: r.AvgSellPrice, + Status: domain.PositionStatus(r.Status), + GrossPnL: r.GrossPnL, + NetPnL: r.NetPnL, + CommissionTotal: r.CommissionTotal, + RealizedEdgeBps: r.RealizedEdgeBps, + OpenedAt: timePtr(r.OpenedAt), + ClosedAt: timePtr(r.ClosedAt), + UpdatedAt: r.UpdatedAt, + } +} + +type riskEventRow struct { + TS time.Time `db:"ts"` + Severity string `db:"severity"` + EventType string `db:"event_type"` + InstrumentUID sql.NullString `db:"instrument_uid"` + Message string `db:"message"` + ContextJSON string `db:"raw_context_json"` +} + +func riskEventRowFromDomain(event domain.RiskEvent) riskEventRow { + return riskEventRow{ + TS: event.TS, + Severity: string(event.Severity), + EventType: event.EventType, + InstrumentUID: sql.NullString{String: event.InstrumentUID, Valid: event.InstrumentUID != ""}, + Message: event.Message, + ContextJSON: event.ContextJSON, + } +} + +func nullableTime(t *time.Time) sql.NullTime { + if t == nil { + return sql.NullTime{} + } + return sql.NullTime{Time: *t, Valid: true} +} + +func timePtr(t sql.NullTime) *time.Time { + if !t.Valid { + return nil + } + return &t.Time +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go new file mode 100644 index 0000000..d90b173 --- /dev/null +++ b/internal/repository/repository.go @@ -0,0 +1,49 @@ +package repository + +import ( + "context" + "time" + + "overnight-trading-bot/internal/domain" +) + +type Repository interface { + RunInTx(ctx context.Context, fn func(ctx context.Context, repo Repository) error) error + + UpsertInstrument(ctx context.Context, instrument domain.Instrument) error + ReplaceInstrument(ctx context.Context, oldInstrumentUID string, instrument domain.Instrument) error + ListInstruments(ctx context.Context, includeDisabled bool) ([]domain.Instrument, error) + QuarantineInstrument(ctx context.Context, instrumentUID, reason string) error + + UpsertDailyCandles(ctx context.Context, candles []domain.Candle) error + ListDailyCandles(ctx context.Context, instrumentUID string, from, to time.Time) ([]domain.Candle, error) + UpsertMinuteCandles(ctx context.Context, candles []domain.Candle) error + ListMinuteCandles(ctx context.Context, instrumentUID string, from, to time.Time) ([]domain.Candle, error) + + UpsertFeature(ctx context.Context, feature domain.FeatureSet) error + GetFeature(ctx context.Context, instrumentUID string, tradeDate time.Time) (domain.FeatureSet, error) + + UpsertSignal(ctx context.Context, signal domain.Signal) error + ListSignals(ctx context.Context, tradeDate time.Time) ([]domain.Signal, error) + + UpsertOrder(ctx context.Context, order domain.Order) error + UpdateOrderStatus(ctx context.Context, clientOrderID string, status domain.OrderStatus, filledLots int64, rawJSON string) error + ListActiveOrders(ctx context.Context, accountIDHash string) ([]domain.Order, error) + ListOrders(ctx context.Context, accountIDHash string, from, to time.Time) ([]domain.Order, error) + + UpsertPosition(ctx context.Context, position domain.Position) error + ListOpenPositions(ctx context.Context, accountIDHash string) ([]domain.Position, error) + ListPositions(ctx context.Context, accountIDHash string, from, to time.Time) ([]domain.Position, error) + + InsertRiskEvent(ctx context.Context, event domain.RiskEvent) error + GetFreeOrdersSent(ctx context.Context, tradeDate time.Time, instrumentUID string) (int, error) + IncrementFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int) error + + GetSystemState(ctx context.Context) (domain.SystemState, bool, string, error) + SaveSystemState(ctx context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, contextJSON string) error + Unhalt(ctx context.Context, reason string) error + WasDailyReportSent(ctx context.Context, reportDate time.Time, accountIDHash string) (bool, error) + MarkDailyReportSent(ctx context.Context, reportDate time.Time, accountIDHash string) error + + InsertReconciliation(ctx context.Context, ts time.Time, diffJSON string, hasDiff bool) error +} diff --git a/internal/risk/freeorders_test.go b/internal/risk/freeorders_test.go new file mode 100644 index 0000000..c4f7204 --- /dev/null +++ b/internal/risk/freeorders_test.go @@ -0,0 +1,27 @@ +package risk + +import ( + "context" + "errors" + "testing" + "time" + + "overnight-trading-bot/internal/domain" +) + +func TestFreeOrderBudgetSubmittedPolicy(t *testing.T) { + ctx := context.Background() + store := NewMemoryFreeOrderStore() + budget := NewFreeOrderBudget(store) + instr := domain.Instrument{InstrumentUID: "uid", FreeOrderLimitPerDay: 2} + date := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) + if _, err := budget.Check(ctx, date, instr, 2); err != nil { + t.Fatal(err) + } + if err := budget.Submitted(ctx, date, instr.InstrumentUID); err != nil { + t.Fatal(err) + } + if _, err := budget.Check(ctx, date, instr, 2); !errors.Is(err, ErrFreeOrderBudget) { + t.Fatalf("expected ErrFreeOrderBudget, got %v", err) + } +} diff --git a/internal/risk/manager.go b/internal/risk/manager.go new file mode 100644 index 0000000..f253c51 --- /dev/null +++ b/internal/risk/manager.go @@ -0,0 +1,127 @@ +package risk + +import ( + "context" + "fmt" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" +) + +type EventSink interface { + InsertRiskEvent(ctx context.Context, event domain.RiskEvent) error + SaveSystemState(ctx context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, contextJSON string) error +} + +type Manager struct { + sink EventSink + cfg ManagerConfig +} + +type ManagerConfig struct { + MaxDailyLossPct decimal.Decimal + MaxWeeklyLossPct decimal.Decimal + MaxMonthlyDrawdownPct decimal.Decimal + MaxAvgSlippageBps10Trades decimal.Decimal + MaxOpenPositions int + MinTimeToClose time.Duration + MaxQuoteAge time.Duration +} + +type PreTradeInput struct { + Portfolio domain.Portfolio + OpenPositions int + DailyPnL decimal.Decimal + WeeklyPnL decimal.Decimal + MonthlyDrawdownPct decimal.Decimal + AvgSlippageBps10 decimal.Decimal + TradingStatus domain.TradingStatus + QuoteReceivedAt time.Time + Now time.Time + MarketClose time.Time + DatabaseUnavailable bool + UnknownBrokerOrder bool + UnknownBrokerHolding bool +} + +type PreTradeResult struct { + Allowed bool + Reason string +} + +func NewManager(sink EventSink, cfg ManagerConfig) Manager { + return Manager{sink: sink, cfg: cfg} +} + +func (m Manager) Halt(ctx context.Context, mode domain.Mode, eventType, reason string, instrumentUID string) error { + if m.sink == nil { + return nil + } + event := domain.RiskEvent{ + TS: time.Now().UTC(), + Severity: domain.SeverityCritical, + EventType: eventType, + InstrumentUID: instrumentUID, + Message: reason, + } + if err := m.sink.InsertRiskEvent(ctx, event); err != nil { + return fmt.Errorf("insert halt risk event: %w", err) + } + if err := m.sink.SaveSystemState(ctx, domain.StateHalted, mode, true, reason, "{}"); err != nil { + return fmt.Errorf("persist halt state: %w", err) + } + return nil +} + +func (m Manager) PreTradeCheck(input PreTradeInput) PreTradeResult { + now := input.Now + if now.IsZero() { + now = time.Now().UTC() + } + switch { + case input.DatabaseUnavailable: + return reject("database_unavailable") + case input.UnknownBrokerOrder: + return reject("unknown_broker_order") + case input.UnknownBrokerHolding: + return reject("unknown_broker_position") + case input.TradingStatus == domain.TradingStatusUnknown: + return reject("trading_status_unknown_before_order") + case input.TradingStatus != domain.TradingStatusNormal: + return reject("trading_status_not_normal") + case m.cfg.MaxOpenPositions > 0 && input.OpenPositions >= m.cfg.MaxOpenPositions: + return reject("max_open_positions") + case DailyLossBreached(input.DailyPnL, input.Portfolio.Equity, m.cfg.MaxDailyLossPct): + return reject("max_daily_loss") + case DailyLossBreached(input.WeeklyPnL, input.Portfolio.Equity, m.cfg.MaxWeeklyLossPct): + return reject("max_weekly_loss") + case m.cfg.MaxMonthlyDrawdownPct.IsPositive() && input.MonthlyDrawdownPct.GreaterThanOrEqual(m.cfg.MaxMonthlyDrawdownPct): + return reject("max_monthly_drawdown") + case m.cfg.MaxAvgSlippageBps10Trades.IsPositive() && input.AvgSlippageBps10.GreaterThan(m.cfg.MaxAvgSlippageBps10Trades): + return reject("max_avg_slippage_bps_10_trades") + case m.cfg.MaxQuoteAge > 0 && !input.QuoteReceivedAt.IsZero() && now.Sub(input.QuoteReceivedAt) > m.cfg.MaxQuoteAge: + return reject("quote_age_too_high") + case m.cfg.MinTimeToClose > 0 && !input.MarketClose.IsZero() && input.MarketClose.Sub(now) < m.cfg.MinTimeToClose: + return reject("min_time_to_close_sec") + default: + return PreTradeResult{Allowed: true} + } +} + +func DailyLossBreached(pnl, equity, maxLossPct decimal.Decimal) bool { + if !equity.IsPositive() || !maxLossPct.IsPositive() { + return false + } + limit := equity.Mul(maxLossPct).Neg() + return pnl.LessThanOrEqual(limit) +} + +func CommissionBreached(actualCommission decimal.Decimal, requireZero bool) bool { + return requireZero && actualCommission.IsPositive() +} + +func reject(reason string) PreTradeResult { + return PreTradeResult{Allowed: false, Reason: reason} +} diff --git a/internal/risk/sizing.go b/internal/risk/sizing.go new file mode 100644 index 0000000..242debf --- /dev/null +++ b/internal/risk/sizing.go @@ -0,0 +1,166 @@ +package risk + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/money" +) + +var ( + ErrNoSizingCapacity = errors.New("no sizing capacity") + ErrFreeOrderBudget = errors.New("free order budget is insufficient") +) + +type SizingConfig struct { + MaxPositionPct decimal.Decimal + MaxTotalExposurePct decimal.Decimal + MaxParticipationRate decimal.Decimal + CashUsageBuffer decimal.Decimal + RiskBudgetPerInstrumentPct decimal.Decimal + MinOrderNotionalRUB decimal.Decimal +} + +type SizingInput struct { + Portfolio domain.Portfolio + SelectedInstruments int + LimitPrice decimal.Decimal + Lot int64 + EntryIntervalVolume decimal.Decimal + ExitIntervalVolume decimal.Decimal + Q05OvernightAbs decimal.Decimal +} + +type SizingResult struct { + TargetNotional decimal.Decimal + Lots int64 + Reason string + Limits map[string]decimal.Decimal +} + +type Sizer struct { + cfg SizingConfig + sizeFactor decimal.Decimal +} + +func NewSizer(cfg SizingConfig) Sizer { + return Sizer{cfg: cfg, sizeFactor: decimal.NewFromInt(1)} +} + +func (s Sizer) WithSizeFactor(factor decimal.Decimal) Sizer { + if !factor.IsPositive() { + factor = decimal.NewFromInt(1) + } + s.sizeFactor = factor + return s +} + +func (s Sizer) Size(input SizingInput) SizingResult { + limits := make(map[string]decimal.Decimal, 6) + if input.SelectedInstruments <= 0 { + input.SelectedInstruments = 1 + } + capLimit := input.Portfolio.Equity.Mul(s.cfg.MaxPositionPct) + exposureLimit := input.Portfolio.Equity.Mul(s.cfg.MaxTotalExposurePct). + Div(decimal.NewFromInt(int64(input.SelectedInstruments))) + liquidityLimit := money.Min(input.EntryIntervalVolume, input.ExitIntervalVolume). + Mul(s.cfg.MaxParticipationRate) + cashLimit := input.Portfolio.Cash.Mul(s.cfg.CashUsageBuffer) + riskLimit := capLimit + if input.Q05OvernightAbs.IsPositive() { + riskBudget := input.Portfolio.Equity.Mul(s.cfg.RiskBudgetPerInstrumentPct) + riskLimit = riskBudget.Div(input.Q05OvernightAbs) + } + limits["cap"] = capLimit + limits["exposure"] = exposureLimit + limits["liquidity"] = liquidityLimit + limits["risk"] = riskLimit + limits["cash"] = cashLimit + + sizeFactor := s.effectiveSizeFactor() + limits["size_factor"] = sizeFactor + target := money.Min(capLimit, exposureLimit, liquidityLimit, riskLimit, cashLimit).Mul(sizeFactor) + if !target.IsPositive() || !input.LimitPrice.IsPositive() || input.Lot <= 0 { + return SizingResult{Reason: "non_positive_limit", Limits: limits} + } + lotNotional := input.LimitPrice.Mul(decimal.NewFromInt(input.Lot)) + lots := target.Div(lotNotional).Floor().IntPart() + notional := lotNotional.Mul(decimal.NewFromInt(lots)) + if lots < 1 { + return SizingResult{TargetNotional: notional, Lots: lots, Reason: "lots_below_one", Limits: limits} + } + if notional.LessThan(s.cfg.MinOrderNotionalRUB) { + return SizingResult{TargetNotional: notional, Lots: 0, Reason: "min_order_notional", Limits: limits} + } + return SizingResult{TargetNotional: notional, Lots: lots, Limits: limits} +} + +func (s Sizer) effectiveSizeFactor() decimal.Decimal { + if !s.sizeFactor.IsPositive() { + return decimal.NewFromInt(1) + } + return s.sizeFactor +} + +type FreeOrderStore interface { + GetFreeOrdersSent(ctx context.Context, tradeDate time.Time, instrumentUID string) (int, error) + IncrementFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int) error +} + +type FreeOrderBudget struct { + store FreeOrderStore +} + +func NewFreeOrderBudget(store FreeOrderStore) FreeOrderBudget { + return FreeOrderBudget{store: store} +} + +func (b FreeOrderBudget) Check(ctx context.Context, tradeDate time.Time, instr domain.Instrument, ordersNeeded int) (int, error) { + if instr.FreeOrderLimitPerDay <= 0 { + return 0, nil + } + sent, err := b.store.GetFreeOrdersSent(ctx, tradeDate, instr.InstrumentUID) + if err != nil { + return 0, err + } + remaining := instr.FreeOrderLimitPerDay - sent + if remaining < ordersNeeded { + return remaining, ErrFreeOrderBudget + } + return remaining, nil +} + +func (b FreeOrderBudget) Submitted(ctx context.Context, tradeDate time.Time, instrumentUID string) error { + return b.store.IncrementFreeOrders(ctx, tradeDate, instrumentUID, 1) +} + +type MemoryFreeOrderStore struct { + mu sync.Mutex + counts map[string]int +} + +func NewMemoryFreeOrderStore() *MemoryFreeOrderStore { + return &MemoryFreeOrderStore{counts: make(map[string]int)} +} + +func (s *MemoryFreeOrderStore) GetFreeOrdersSent(_ context.Context, tradeDate time.Time, instrumentUID string) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.counts[freeOrderKey(tradeDate, instrumentUID)], nil +} + +func (s *MemoryFreeOrderStore) IncrementFreeOrders(_ context.Context, tradeDate time.Time, instrumentUID string, delta int) error { + s.mu.Lock() + defer s.mu.Unlock() + s.counts[freeOrderKey(tradeDate, instrumentUID)] += delta + return nil +} + +func freeOrderKey(tradeDate time.Time, instrumentUID string) string { + return tradeDate.Format("2006-01-02") + "|" + instrumentUID +} diff --git a/internal/risk/sizing_test.go b/internal/risk/sizing_test.go new file mode 100644 index 0000000..68fa5e5 --- /dev/null +++ b/internal/risk/sizing_test.go @@ -0,0 +1,172 @@ +package risk + +import ( + "testing" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" +) + +func rd(raw string) decimal.Decimal { + v, err := decimal.NewFromString(raw) + if err != nil { + panic(err) + } + return v +} + +func TestSizerTakesMinimumOfLimits(t *testing.T) { + sizer := NewSizer(SizingConfig{ + MaxPositionPct: rd("0.10"), + MaxTotalExposurePct: rd("0.50"), + MaxParticipationRate: rd("0.01"), + CashUsageBuffer: rd("0.95"), + RiskBudgetPerInstrumentPct: rd("0.005"), + MinOrderNotionalRUB: rd("1000"), + }) + got := sizer.Size(SizingInput{ + Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("90000")}, + SelectedInstruments: 5, + LimitPrice: rd("100"), + Lot: 1, + EntryIntervalVolume: rd("1000000"), + ExitIntervalVolume: rd("1000000"), + Q05OvernightAbs: rd("0.05"), + }) + if got.Lots != 100 || !got.TargetNotional.Equal(rd("10000")) { + t.Fatalf("unexpected sizing: %+v", got) + } +} + +func TestSizerMinOrderGate(t *testing.T) { + sizer := NewSizer(SizingConfig{ + MaxPositionPct: rd("0.10"), + MaxTotalExposurePct: rd("0.50"), + MaxParticipationRate: rd("0.01"), + CashUsageBuffer: rd("0.95"), + RiskBudgetPerInstrumentPct: rd("0.005"), + MinOrderNotionalRUB: rd("1000"), + }) + got := sizer.Size(SizingInput{ + Portfolio: domain.Portfolio{Equity: rd("10000"), Cash: rd("10000")}, + SelectedInstruments: 1, + LimitPrice: rd("999"), + Lot: 1, + EntryIntervalVolume: rd("1000000"), + ExitIntervalVolume: rd("1000000"), + Q05OvernightAbs: rd("0.05"), + }) + if got.Lots != 0 || got.Reason != "min_order_notional" { + t.Fatalf("unexpected min order gate: %+v", got) + } +} + +func TestSizerBindingLimits(t *testing.T) { + sizer := NewSizer(SizingConfig{ + MaxPositionPct: rd("0.10"), + MaxTotalExposurePct: rd("0.50"), + MaxParticipationRate: rd("0.01"), + CashUsageBuffer: rd("0.95"), + RiskBudgetPerInstrumentPct: rd("0.005"), + MinOrderNotionalRUB: rd("1"), + }) + tests := []struct { + name string + input SizingInput + want decimal.Decimal + }{ + { + name: "cap", + input: SizingInput{ + Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("100000")}, + SelectedInstruments: 1, + LimitPrice: rd("100"), + Lot: 1, + EntryIntervalVolume: rd("5000000"), + ExitIntervalVolume: rd("5000000"), + }, + want: rd("10000"), + }, + { + name: "exposure", + input: SizingInput{ + Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("100000")}, + SelectedInstruments: 10, + LimitPrice: rd("100"), + Lot: 1, + EntryIntervalVolume: rd("5000000"), + ExitIntervalVolume: rd("5000000"), + }, + want: rd("5000"), + }, + { + name: "liquidity", + input: SizingInput{ + Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("100000")}, + SelectedInstruments: 1, + LimitPrice: rd("100"), + Lot: 1, + EntryIntervalVolume: rd("300000"), + ExitIntervalVolume: rd("500000"), + }, + want: rd("3000"), + }, + { + name: "risk", + input: SizingInput{ + Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("100000")}, + SelectedInstruments: 1, + LimitPrice: rd("100"), + Lot: 1, + EntryIntervalVolume: rd("5000000"), + ExitIntervalVolume: rd("5000000"), + Q05OvernightAbs: rd("0.10"), + }, + want: rd("5000"), + }, + { + name: "cash", + input: SizingInput{ + Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("2000")}, + SelectedInstruments: 1, + LimitPrice: rd("100"), + Lot: 1, + EntryIntervalVolume: rd("5000000"), + ExitIntervalVolume: rd("5000000"), + }, + want: rd("1900"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sizer.Size(tt.input) + if !got.TargetNotional.Equal(tt.want) { + t.Fatalf("target=%s, want %s limits=%v", got.TargetNotional, tt.want, got.Limits) + } + }) + } +} + +func TestSizerAppliesSizeReductionFactor(t *testing.T) { + sizer := NewSizer(SizingConfig{ + MaxPositionPct: rd("1"), + MaxTotalExposurePct: rd("1"), + MaxParticipationRate: rd("1"), + CashUsageBuffer: rd("1"), + RiskBudgetPerInstrumentPct: rd("1"), + MinOrderNotionalRUB: rd("1"), + }).WithSizeFactor(rd("0.5")) + got := sizer.Size(SizingInput{ + Portfolio: domain.Portfolio{Equity: rd("10000"), Cash: rd("10000")}, + SelectedInstruments: 1, + LimitPrice: rd("100"), + Lot: 1, + EntryIntervalVolume: rd("10000"), + ExitIntervalVolume: rd("10000"), + Q05OvernightAbs: rd("1"), + }) + if got.Lots != 50 || !got.TargetNotional.Equal(rd("5000")) { + t.Fatalf("unexpected reduced sizing: %+v", got) + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..0acee3f --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,905 @@ +package scheduler + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "sort" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/execution" + "overnight-trading-bot/internal/features" + "overnight-trading-bot/internal/instruments" + "overnight-trading-bot/internal/marketdata" + "overnight-trading-bot/internal/money" + "overnight-trading-bot/internal/notify" + "overnight-trading-bot/internal/position" + "overnight-trading-bot/internal/reconciliation" + "overnight-trading-bot/internal/report" + "overnight-trading-bot/internal/repository" + "overnight-trading-bot/internal/risk" + "overnight-trading-bot/internal/signal" + "overnight-trading-bot/internal/statemachine" + "overnight-trading-bot/internal/timeutil" + "overnight-trading-bot/internal/tinvest" +) + +const ( + sizeReductionWindowTrades = 20 + sizeReductionFactor = 0.5 +) + +type Config struct { + Mode domain.Mode + Location *time.Location + RollingLong int + TickInterval time.Duration + EntrySignalTime timeutil.TimeOfDay + EntryWindowStart timeutil.TimeOfDay + EntryWindowEnd timeutil.TimeOfDay + NoNewEntryAfter timeutil.TimeOfDay + ExitWatchStart timeutil.TimeOfDay + ExitWindowStart timeutil.TimeOfDay + ExitWindowEnd timeutil.TimeOfDay + HardExitDeadline timeutil.TimeOfDay + QuoteDepth int32 + MaxQuoteAge time.Duration + OrderPollInterval time.Duration + PassiveImproveTicks int + MaxEntryOrderAttempts int + MaxExitOrderAttempts int + MinTimeToClose time.Duration + MaxClockDrift time.Duration + APIOutageHalt time.Duration +} + +type Services struct { + Repo repository.Repository + Gateway tinvest.Gateway + Registry instruments.Registry + MarketData marketdata.Loader + Features features.Pipeline + Signals signal.Engine + Sizer risk.Sizer + FreeOrders risk.FreeOrderBudget + Risk risk.Manager + Execution *execution.Engine + Positions position.Manager + Reconcile reconciliation.Engine + Notifier notify.Notifier + AccountID string + AccountIDHash string + Log *slog.Logger +} + +type Scheduler struct { + clock timeutil.Clock + sm statemachine.System + cfg Config + svc Services + + infraFailedSince time.Time +} + +func New(clock timeutil.Clock, sm statemachine.System, cfg Config, svc Services) Scheduler { + if cfg.TickInterval <= 0 { + cfg.TickInterval = 30 * time.Second + } + if cfg.Location == nil { + cfg.Location = time.UTC + } + return Scheduler{clock: clock, sm: sm, cfg: cfg, svc: svc} +} + +func (s *Scheduler) Run(ctx context.Context) error { + for { + if err := s.Step(ctx); err != nil { + if errors.Is(err, statemachine.ErrSystemHalted) { + s.logWarn("scheduler paused in HALT", "err", err) + } else if err := s.halt(ctx, "scheduler_error", err.Error(), ""); err != nil { + return err + } + } + if !s.clock.Sleep(ctx.Done(), s.cfg.TickInterval) { + return ctx.Err() + } + } +} + +func (s *Scheduler) Step(ctx context.Context) error { + if err := s.checkInfrastructure(ctx); err != nil { + return err + } + now := s.clock.Now().In(s.cfg.Location) + phase := s.phase(now) + switch phase { + case domain.StateWaitExitWindow: + return s.waitExit(ctx, now) + case domain.StatePlaceExitOrders: + return s.placeExitOrders(ctx, now) + case domain.StateMonitorExitOrders: + return s.monitorExitOrders(ctx, now) + case domain.StateReconcile: + return s.failOpenPositionsAtHardDeadline(ctx) + case domain.StateGenerateSignals: + return s.prepareSignals(ctx, now) + case domain.StatePlaceEntryOrders: + return s.placeEntryOrders(ctx, now) + case domain.StateMonitorEntryOrders: + return s.monitorEntryOrders(ctx, now) + case domain.StateHoldOvernight: + return s.holdOvernight(ctx) + default: + return s.sm.Heartbeat(ctx, domain.StateSleep) + } +} + +func (s Scheduler) phase(now time.Time) domain.SystemState { + tod := sinceMidnight(now) + switch { + case tod >= s.cfg.ExitWatchStart.Duration && tod < s.cfg.ExitWindowStart.Duration: + return domain.StateWaitExitWindow + case tod >= s.cfg.ExitWindowStart.Duration && tod < s.cfg.ExitWindowEnd.Duration: + return domain.StatePlaceExitOrders + case tod >= s.cfg.ExitWindowEnd.Duration && tod < s.cfg.HardExitDeadline.Duration: + return domain.StateMonitorExitOrders + case tod >= s.cfg.HardExitDeadline.Duration && tod < s.cfg.EntrySignalTime.Duration: + return domain.StateReconcile + case tod >= s.cfg.EntrySignalTime.Duration && tod < s.cfg.EntryWindowStart.Duration: + return domain.StateGenerateSignals + case tod >= s.cfg.EntryWindowStart.Duration && tod < s.cfg.NoNewEntryAfter.Duration: + return domain.StatePlaceEntryOrders + case tod >= s.cfg.NoNewEntryAfter.Duration: + return domain.StateHoldOvernight + default: + return domain.StateSleep + } +} + +func (s *Scheduler) prepareSignals(ctx context.Context, now time.Time) error { + if err := s.transitionSequence(ctx, + domain.StateInit, + domain.StateSyncInstruments, + domain.StateSyncMarketData, + domain.StateGenerateSignals, + ); err != nil { + return err + } + if err := s.svc.Registry.SyncMetadata(ctx); err != nil { + return err + } + tradeDate := tradingDate(now) + instrumentsList, err := s.svc.Repo.ListInstruments(ctx, false) + if err != nil { + return err + } + if err := s.svc.MarketData.BackfillDaily(ctx, instrumentsList, tradeDate.AddDate(0, 0, -s.cfg.RollingLong-10), tradeDate); err != nil { + return err + } + minuteFrom := s.cfg.EntryWindowStart.On(tradeDate, s.cfg.Location) + minuteTo := s.cfg.ExitWindowEnd.On(tradeDate.AddDate(0, 0, 1), s.cfg.Location) + if err := s.svc.MarketData.BackfillMinute(ctx, instrumentsList, minuteFrom, minuteTo); err != nil { + s.logWarn("minute backfill failed; liquidity will fall back to ADV", "err", err) + } + if err := s.applySizeReductionRule(ctx, tradeDate, false); err != nil { + return err + } + portfolio, err := s.svc.Gateway.GetPortfolio(ctx, s.svc.AccountID) + if err != nil { + return err + } + openPositions, err := s.svc.Repo.ListOpenPositions(ctx, s.svc.AccountIDHash) + if err != nil { + return err + } + for _, instrument := range instrumentsList { + if err := s.generateInstrumentSignal(ctx, now, tradeDate, portfolio, len(openPositions), instrument); err != nil { + return err + } + } + return s.transitionTo(ctx, domain.StateWaitEntryWindow) +} + +func (s Scheduler) generateInstrumentSignal(ctx context.Context, now, tradeDate time.Time, portfolio domain.Portfolio, openPositionCount int, instrument domain.Instrument) error { + book, err := s.svc.MarketData.LatestQuote(ctx, instrument.InstrumentUID, s.cfg.QuoteDepth, s.cfg.MaxQuoteAge) + if err != nil { + return s.saveRejectedSignal(ctx, tradeDate, instrument, "quote_unavailable", err) + } + spread, err := spreadFromBook(book, instrument.MinPriceIncrement) + if err != nil { + return s.saveRejectedSignal(ctx, tradeDate, instrument, "spread_unavailable", err) + } + tradingStatus, err := s.svc.Gateway.GetTradingStatus(ctx, instrument.InstrumentUID) + if err != nil { + tradingStatus = domain.TradingStatusUnknown + } + feature, err := s.svc.Features.Recompute(ctx, instrument, tradeDate, spread) + if err != nil { + return s.saveRejectedSignal(ctx, tradeDate, instrument, "features_unavailable", err) + } + remaining, err := s.svc.FreeOrders.Check(ctx, tradeDate, instrument, 1) + freeOrderOK := err == nil + sig := s.svc.Signals.Evaluate(signal.Candidate{ + Instrument: instrument, + Features: feature, + TradingStatus: tradingStatus, + FreeOrderOK: freeOrderOK, + OpenPositions: openPositionCount, + TradeDate: tradeDate, + ExtraContext: map[string]any{ + "free_orders_remaining": remaining, + "quote_time": book.Time.Format(time.RFC3339), + }, + }) + if sig.Decision == domain.DecisionEnter { + sized, sizingErr := s.sizeSignal(ctx, portfolio, instrument, feature, book, 1) + switch { + case sizingErr != nil: + sig.Decision = domain.DecisionReject + sig.RejectReason = sizingErr.Error() + case sized.Lots <= 0: + sig.Decision = domain.DecisionReject + sig.RejectReason = sized.Reason + default: + sig.TargetLots = sized.Lots + sig.TargetNotional = sized.TargetNotional + } + } + if err := s.svc.Repo.UpsertSignal(ctx, sig); err != nil { + return err + } + return s.notifySignal(ctx, now, sig) +} + +func (s Scheduler) saveRejectedSignal(ctx context.Context, tradeDate time.Time, instrument domain.Instrument, reason string, cause error) error { + sig := domain.Signal{ + TradeDate: tradeDate, + InstrumentUID: instrument.InstrumentUID, + Decision: domain.DecisionReject, + RejectReason: reason, + ContextJSON: fmt.Sprintf(`{"error":%q}`, cause.Error()), + CreatedAt: s.nowUTC(), + } + return s.svc.Repo.UpsertSignal(ctx, sig) +} + +func (s Scheduler) sizeSignal(_ context.Context, portfolio domain.Portfolio, instrument domain.Instrument, feature domain.FeatureSet, book domain.OrderBook, selected int) (risk.SizingResult, error) { + bid, ask, err := bestBidAsk(book) + if err != nil { + return risk.SizingResult{}, err + } + price, err := execution.LimitBuyPrice(bid, ask, instrument.MinPriceIncrement, s.cfg.PassiveImproveTicks) + if err != nil { + return risk.SizingResult{}, err + } + return s.svc.Sizer.Size(risk.SizingInput{ + Portfolio: portfolio, + SelectedInstruments: selected, + LimitPrice: price, + Lot: instrument.Lot, + EntryIntervalVolume: feature.EntryIntervalVolume, + ExitIntervalVolume: feature.ExitIntervalVolume, + Q05OvernightAbs: money.Abs(feature.SigmaOn60).Mul(decimal.NewFromFloat(1.65)), + }), nil +} + +func (s Scheduler) placeEntryOrders(ctx context.Context, now time.Time) error { + if err := s.transitionTo(ctx, domain.StatePlaceEntryOrders); err != nil { + return err + } + tradeDate := tradingDate(now) + signals, err := s.svc.Repo.ListSignals(ctx, tradeDate) + if err != nil { + return err + } + existing, err := s.svc.Repo.ListOrders(ctx, s.svc.AccountIDHash, tradeDate, tradeDate) + if err != nil { + return err + } + openPositions, err := s.svc.Repo.ListOpenPositions(ctx, s.svc.AccountIDHash) + if err != nil { + return err + } + instrumentByUID, err := s.instrumentMap(ctx) + if err != nil { + return err + } + for _, sig := range signals { + if sig.Decision != domain.DecisionEnter || sig.TargetLots <= 0 || hasOrder(existing, sig.InstrumentUID, domain.SideBuy) { + continue + } + instrument, ok := instrumentByUID[sig.InstrumentUID] + if !ok { + return fmt.Errorf("instrument %s is not in registry", sig.InstrumentUID) + } + book, err := s.svc.MarketData.LatestQuote(ctx, sig.InstrumentUID, s.cfg.QuoteDepth, s.cfg.MaxQuoteAge) + if err != nil { + return err + } + tradingStatus, err := s.svc.Gateway.GetTradingStatus(ctx, sig.InstrumentUID) + if err != nil { + tradingStatus = domain.TradingStatusUnknown + } + portfolio, err := s.svc.Gateway.GetPortfolio(ctx, s.svc.AccountID) + if err != nil { + return err + } + pre := s.svc.Risk.PreTradeCheck(risk.PreTradeInput{ + Portfolio: portfolio, + OpenPositions: len(openPositions), + TradingStatus: tradingStatus, + QuoteReceivedAt: book.ReceivedAt, + Now: now.UTC(), + MarketClose: s.cfg.EntryWindowEnd.On(now, s.cfg.Location).UTC(), + }) + if !pre.Allowed { + if err := s.svc.Repo.InsertRiskEvent(ctx, domain.RiskEvent{ + Severity: domain.SeverityWarn, + EventType: "pre_trade_reject", + InstrumentUID: sig.InstrumentUID, + Message: pre.Reason, + ContextJSON: "{}", + }); err != nil { + return err + } + continue + } + placed, err := s.svc.Execution.PlaceEntry(ctx, s.svc.AccountIDHash, instrument, tradeDate, sig.TargetLots, book, s.cfg.PassiveImproveTicks, 1) + if err != nil && !errors.Is(err, execution.ErrBrokerOrdersDisabled) { + return err + } + _ = s.svc.Notifier.Info(ctx, fmt.Sprintf("entry order %s %s lots=%d status=%s", instrument.Ticker, placed.Side, placed.QuantityLots, placed.Status)) + existing = append(existing, placed) + } + return s.transitionTo(ctx, domain.StateMonitorEntryOrders) +} + +func (s Scheduler) monitorEntryOrders(ctx context.Context, now time.Time) error { + if err := s.transitionTo(ctx, domain.StateMonitorEntryOrders); err != nil { + return err + } + orders, err := s.svc.Repo.ListActiveOrders(ctx, s.svc.AccountIDHash) + if err != nil { + return err + } + instrumentByUID, err := s.instrumentMap(ctx) + if err != nil { + return err + } + deadline := s.cfg.NoNewEntryAfter.On(now, s.cfg.Location).UTC() + for _, order := range orders { + if order.Side != domain.SideBuy || order.BrokerOrderID == "" { + continue + } + instrument, ok := instrumentByUID[order.InstrumentUID] + if !ok { + return fmt.Errorf("instrument %s is not in registry", order.InstrumentUID) + } + monitored, err := s.svc.Execution.MonitorUntil(ctx, order, execution.MonitorConfig{ + Deadline: deadline, + PollInterval: s.cfg.OrderPollInterval, + MaxAttempts: s.cfg.MaxEntryOrderAttempts, + RepostAfter: repostAfter(now, deadline, s.cfg.MaxEntryOrderAttempts, s.cfg.OrderPollInterval), + Instrument: instrument, + ImproveTicks: s.cfg.PassiveImproveTicks, + Quote: func(ctx context.Context, instrumentUID string) (domain.OrderBook, error) { + return s.svc.MarketData.LatestQuote(ctx, instrumentUID, s.cfg.QuoteDepth, s.cfg.MaxQuoteAge) + }, + }) + if err != nil { + return err + } + if monitored.FilledLots > order.FilledLots || monitored.Commission.GreaterThan(order.Commission) { + pos, err := s.svc.Positions.OnEntryFill(ctx, s.svc.AccountIDHash, instrument, monitored) + if err != nil { + return err + } + _ = s.svc.Notifier.Info(ctx, fmt.Sprintf("entry fill %s lots=%d status=%s", monitored.InstrumentUID, monitored.FilledLots, pos.Status)) + } + } + if sinceMidnight(s.nowUTC().In(s.cfg.Location)) >= s.cfg.NoNewEntryAfter.Duration { + if err := s.cancelActiveOrders(ctx, domain.SideBuy, domain.OrderStatusCancelled, "entry_window_closed"); err != nil { + return err + } + return s.transitionTo(ctx, domain.StateHoldOvernight) + } + return nil +} + +func (s Scheduler) waitExit(ctx context.Context, _ time.Time) error { + return s.transitionTo(ctx, domain.StateWaitExitWindow) +} + +func (s Scheduler) holdOvernight(ctx context.Context) error { + if err := s.cancelActiveOrders(ctx, domain.SideBuy, domain.OrderStatusCancelled, "entry_window_closed"); err != nil { + return err + } + return s.transitionTo(ctx, domain.StateHoldOvernight) +} + +func (s Scheduler) placeExitOrders(ctx context.Context, now time.Time) error { + if err := s.transitionTo(ctx, domain.StatePlaceExitOrders); err != nil { + return err + } + positionsList, err := s.svc.Repo.ListOpenPositions(ctx, s.svc.AccountIDHash) + if err != nil { + return err + } + existing, err := s.svc.Repo.ListOrders(ctx, s.svc.AccountIDHash, tradingDate(now).AddDate(0, 0, -1), tradingDate(now)) + if err != nil { + return err + } + instrumentByUID, err := s.instrumentMap(ctx) + if err != nil { + return err + } + for _, pos := range positionsList { + if pos.Lots <= 0 || hasOrder(existing, pos.InstrumentUID, domain.SideSell) { + continue + } + instrument, ok := instrumentByUID[pos.InstrumentUID] + if !ok { + return fmt.Errorf("instrument %s is not in registry", pos.InstrumentUID) + } + book, err := s.svc.MarketData.LatestQuote(ctx, pos.InstrumentUID, s.cfg.QuoteDepth, s.cfg.MaxQuoteAge) + if err != nil { + return err + } + tradingStatus, err := s.svc.Gateway.GetTradingStatus(ctx, pos.InstrumentUID) + if err != nil { + tradingStatus = domain.TradingStatusUnknown + } + portfolio, err := s.svc.Gateway.GetPortfolio(ctx, s.svc.AccountID) + if err != nil { + return err + } + pre := s.svc.Risk.PreTradeCheck(risk.PreTradeInput{ + Portfolio: portfolio, + OpenPositions: len(positionsList), + TradingStatus: tradingStatus, + QuoteReceivedAt: book.ReceivedAt, + Now: now.UTC(), + MarketClose: s.cfg.HardExitDeadline.On(now, s.cfg.Location).UTC(), + }) + if !pre.Allowed { + return fmt.Errorf("exit pre-trade rejected: %s", pre.Reason) + } + placed, err := s.svc.Execution.PlaceExit(ctx, s.svc.AccountIDHash, instrument, pos.OpenTradeDate, pos.Lots, book, s.cfg.PassiveImproveTicks, 1) + if err != nil && !errors.Is(err, execution.ErrBrokerOrdersDisabled) { + return err + } + pos.Status = domain.PositionExitOrderSent + if err := s.svc.Repo.UpsertPosition(ctx, pos); err != nil { + return err + } + _ = s.svc.Notifier.Info(ctx, fmt.Sprintf("exit order %s lots=%d status=%s", instrument.Ticker, placed.QuantityLots, placed.Status)) + existing = append(existing, placed) + } + return s.transitionTo(ctx, domain.StateMonitorExitOrders) +} + +func (s Scheduler) monitorExitOrders(ctx context.Context, now time.Time) error { + if err := s.transitionTo(ctx, domain.StateMonitorExitOrders); err != nil { + return err + } + orders, err := s.svc.Repo.ListActiveOrders(ctx, s.svc.AccountIDHash) + if err != nil { + return err + } + openPositions, err := s.svc.Repo.ListOpenPositions(ctx, s.svc.AccountIDHash) + if err != nil { + return err + } + positionByInstrument := make(map[string]domain.Position, len(openPositions)) + for _, pos := range openPositions { + positionByInstrument[pos.InstrumentUID] = pos + } + instrumentByUID, err := s.instrumentMap(ctx) + if err != nil { + return err + } + deadline := s.cfg.HardExitDeadline.On(now, s.cfg.Location).UTC() + for _, order := range orders { + if order.Side != domain.SideSell || order.BrokerOrderID == "" { + continue + } + instrument, ok := instrumentByUID[order.InstrumentUID] + if !ok { + return fmt.Errorf("instrument %s is not in registry", order.InstrumentUID) + } + monitored, err := s.svc.Execution.MonitorUntil(ctx, order, execution.MonitorConfig{ + Deadline: deadline, + PollInterval: s.cfg.OrderPollInterval, + MaxAttempts: s.cfg.MaxExitOrderAttempts, + RepostAfter: repostAfter(now, deadline, s.cfg.MaxExitOrderAttempts, s.cfg.OrderPollInterval), + Instrument: instrument, + ImproveTicks: s.cfg.PassiveImproveTicks, + Quote: func(ctx context.Context, instrumentUID string) (domain.OrderBook, error) { + return s.svc.MarketData.LatestQuote(ctx, instrumentUID, s.cfg.QuoteDepth, s.cfg.MaxQuoteAge) + }, + }) + if err != nil { + return err + } + if monitored.FilledLots > order.FilledLots || monitored.Commission.GreaterThan(order.Commission) { + fill := exitFillDelta(order, monitored) + if fill.FilledLots <= 0 && fill.Commission.IsZero() { + continue + } + pos, ok := positionByInstrument[monitored.InstrumentUID] + if !ok { + return fmt.Errorf("exit fill for unknown local position %s", monitored.InstrumentUID) + } + updated, err := s.svc.Positions.OnExitFill(ctx, pos, fill) + if err != nil { + return err + } + positionByInstrument[monitored.InstrumentUID] = updated + _ = s.svc.Notifier.Info(ctx, fmt.Sprintf("exit fill %s lots=%d status=%s pnl=%s", monitored.InstrumentUID, monitored.FilledLots, updated.Status, updated.NetPnL.StringFixed(2))) + } + } + if sinceMidnight(s.nowUTC().In(s.cfg.Location)) >= s.cfg.HardExitDeadline.Duration { + return s.failOpenPositionsAtHardDeadline(ctx) + } + return nil +} + +func (s *Scheduler) reconcileAndReport(ctx context.Context, now time.Time) error { + if err := s.transitionTo(ctx, domain.StateReconcile); err != nil { + return err + } + diffs, err := s.svc.Reconcile.Run(ctx) + if err != nil { + return err + } + if reconciliation.HasCritical(diffs) { + return s.halt(ctx, "reconciliation_critical", "critical reconciliation diff", "") + } + tradeDate := tradingDate(now) + sent, err := s.svc.Repo.WasDailyReportSent(ctx, tradeDate, s.svc.AccountIDHash) + if err != nil { + return err + } + if sent { + s.logWarn("daily report already sent; skipping duplicate", "date", tradeDate.Format("2006-01-02")) + return s.transitionTo(ctx, domain.StateSleep) + } + signals, err := s.svc.Repo.ListSignals(ctx, tradeDate) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + positionsList, err := s.svc.Repo.ListPositions(ctx, s.svc.AccountIDHash, tradeDate.AddDate(0, 0, -1), tradeDate) + if err != nil { + return err + } + if err := s.applySizeReductionRule(ctx, tradeDate, true); err != nil { + return err + } + if err := s.transitionTo(ctx, domain.StateReport); err != nil { + return err + } + msg := report.ComposeDaily(report.DailyInput{ + Date: tradeDate, + Mode: s.cfg.Mode, + Signals: signals, + Positions: positionsList, + RiskStatus: "ok", + }) + if err := s.svc.Notifier.Report(ctx, msg); err != nil { + return err + } + if err := s.svc.Repo.MarkDailyReportSent(ctx, tradeDate, s.svc.AccountIDHash); err != nil { + return err + } + return s.transitionTo(ctx, domain.StateSleep) +} + +func (s *Scheduler) applySizeReductionRule(ctx context.Context, tradeDate time.Time, emitEvent bool) error { + averageError, count, ok, err := s.averageExpectedErrorBps(ctx, tradeDate, sizeReductionWindowTrades) + if err != nil { + return err + } + if !ok || count < sizeReductionWindowTrades || averageError.GreaterThanOrEqual(decimal.NewFromInt(-10)) { + s.svc.Sizer = s.svc.Sizer.WithSizeFactor(decimal.NewFromInt(1)) + return nil + } + factor := decimal.NewFromFloat(sizeReductionFactor) + s.svc.Sizer = s.svc.Sizer.WithSizeFactor(factor) + if !emitEvent { + return nil + } + return s.svc.Repo.InsertRiskEvent(ctx, domain.RiskEvent{ + Severity: domain.SeverityWarn, + EventType: "size_reduction_rule_triggered", + Message: fmt.Sprintf("average expected_error_bps over %d trades is %s; sizing factor set to %s", count, averageError.StringFixed(2), factor.String()), + ContextJSON: fmt.Sprintf(`{"average_expected_error_bps":%q,"trades":%d,"size_factor":%q}`, averageError.String(), count, factor.String()), + }) +} + +func (s Scheduler) averageExpectedErrorBps(ctx context.Context, tradeDate time.Time, limit int) (decimal.Decimal, int, bool, error) { + if limit <= 0 { + return decimal.Zero, 0, false, nil + } + positionsList, err := s.svc.Repo.ListPositions(ctx, s.svc.AccountIDHash, tradeDate.AddDate(0, 0, -120), tradeDate) + if err != nil { + return decimal.Zero, 0, false, err + } + sort.Slice(positionsList, func(i, j int) bool { + return positionsList[i].UpdatedAt.After(positionsList[j].UpdatedAt) + }) + signalsByDate := make(map[string][]domain.Signal) + var errorsBps []decimal.Decimal + for _, pos := range positionsList { + if pos.Status != domain.PositionExitFilled { + continue + } + key := tradingDate(pos.OpenTradeDate).Format("2006-01-02") + signals, ok := signalsByDate[key] + if !ok { + signals, err = s.svc.Repo.ListSignals(ctx, tradingDate(pos.OpenTradeDate)) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return decimal.Zero, 0, false, err + } + signalsByDate[key] = signals + } + for _, sig := range signals { + if sig.InstrumentUID != pos.InstrumentUID || sig.Decision != domain.DecisionEnter { + continue + } + errorsBps = append(errorsBps, pos.RealizedEdgeBps.Sub(sig.NetEdgeBps)) + break + } + if len(errorsBps) == limit { + break + } + } + if len(errorsBps) == 0 { + return decimal.Zero, 0, false, nil + } + sum := decimal.Zero + for _, value := range errorsBps { + sum = sum.Add(value) + } + return sum.Div(decimal.NewFromInt(int64(len(errorsBps)))), len(errorsBps), true, nil +} + +func (s *Scheduler) checkInfrastructure(ctx context.Context) error { + if s.cfg.MaxClockDrift <= 0 || s.svc.Gateway == nil { + return nil + } + serverTime, err := s.svc.Gateway.GetServerTime(ctx) + if err != nil { + if s.cfg.Mode == domain.ModePaper { + return nil + } + return s.recordInfrastructureFailure(fmt.Errorf("server_time_unavailable: %w", err)) + } + drift := timeutil.Drift(s.nowUTC(), serverTime) + if drift > s.cfg.MaxClockDrift { + return s.recordInfrastructureFailure(fmt.Errorf("server_clock_drift_too_high: %s > %s", drift, s.cfg.MaxClockDrift)) + } + s.infraFailedSince = time.Time{} + return nil +} + +func (s *Scheduler) recordInfrastructureFailure(err error) error { + now := s.nowUTC() + if s.infraFailedSince.IsZero() { + s.infraFailedSince = now + s.logWarn("infrastructure check failed; waiting for outage threshold", "err", err, "threshold", s.cfg.APIOutageHalt) + return nil + } + if s.cfg.APIOutageHalt <= 0 || now.Sub(s.infraFailedSince) >= s.cfg.APIOutageHalt { + return err + } + s.logWarn("infrastructure check still failing", "err", err, "elapsed", now.Sub(s.infraFailedSince), "threshold", s.cfg.APIOutageHalt) + return nil +} + +func (s Scheduler) cancelActiveOrders(ctx context.Context, side domain.Side, fallbackStatus domain.OrderStatus, reason string) error { + orders, err := s.svc.Repo.ListActiveOrders(ctx, s.svc.AccountIDHash) + if err != nil { + return err + } + cancelled := 0 + for _, order := range orders { + if order.Side != side { + continue + } + if order.BrokerOrderID != "" && s.cfg.Mode.AllowsBrokerOrders() { + if err := s.svc.Execution.Cancel(ctx, order); err != nil { + return fmt.Errorf("cancel %s order %s: %w", side, order.ClientOrderID, err) + } + cancelled++ + continue + } + if err := s.svc.Repo.UpdateOrderStatus(ctx, order.ClientOrderID, fallbackStatus, order.FilledLots, order.RawStateJSON); err != nil { + return fmt.Errorf("mark %s order %s %s: %w", side, order.ClientOrderID, fallbackStatus, err) + } + cancelled++ + } + if cancelled == 0 { + return nil + } + if err := s.svc.Repo.InsertRiskEvent(ctx, domain.RiskEvent{ + Severity: domain.SeverityWarn, + EventType: reason, + Message: fmt.Sprintf("cancelled %d active %s orders at window boundary", cancelled, side), + ContextJSON: "{}", + }); err != nil { + return err + } + return nil +} + +func (s Scheduler) failOpenPositionsAtHardDeadline(ctx context.Context) error { + if err := s.cancelActiveOrders(ctx, domain.SideSell, domain.OrderStatusExpired, "hard_exit_deadline_cancel"); err != nil { + return err + } + positionsList, err := s.svc.Repo.ListOpenPositions(ctx, s.svc.AccountIDHash) + if err != nil { + return err + } + var failed []domain.Position + now := s.nowUTC() + for _, pos := range positionsList { + switch pos.Status { + case domain.PositionHoldingOvernight, domain.PositionExitPartiallyFilled, domain.PositionExitOrderSent: + pos.Status = domain.PositionExitFailed + pos.UpdatedAt = now + if err := s.svc.Repo.UpsertPosition(ctx, pos); err != nil { + return err + } + failed = append(failed, pos) + _ = s.svc.Notifier.Alert(ctx, fmt.Sprintf("exit_failed: %s lots=%d", pos.InstrumentUID, pos.Lots)) + default: + } + } + if len(failed) == 0 { + return s.reconcileAndReport(ctx, s.nowUTC().In(s.cfg.Location)) + } + return s.svc.Risk.Halt(ctx, s.cfg.Mode, "hard_exit_deadline_missed", fmt.Sprintf("%d positions remain open after hard deadline", len(failed)), "") +} + +func (s Scheduler) nowUTC() time.Time { + if s.clock != nil { + return s.clock.Now().UTC() + } + return time.Now().UTC() +} + +func repostAfter(now, deadline time.Time, attempts int, poll time.Duration) time.Duration { + if attempts <= 1 { + return 0 + } + if poll <= 0 { + poll = 500 * time.Millisecond + } + remaining := deadline.Sub(now) + if remaining <= 0 { + return poll + } + after := remaining / time.Duration(attempts) + if after < poll { + return poll + } + return after +} + +func (s Scheduler) transitionSequence(ctx context.Context, states ...domain.SystemState) error { + for _, state := range states { + if err := s.transitionTo(ctx, state); err != nil { + return err + } + } + return nil +} + +func (s Scheduler) transitionTo(ctx context.Context, to domain.SystemState) error { + from, halted, reason, err := s.svc.Repo.GetSystemState(ctx) + if err != nil { + return err + } + if halted || from == domain.StateHalted { + return fmt.Errorf("%w: %s", statemachine.ErrSystemHalted, reason) + } + if from == to { + return s.sm.Heartbeat(ctx, to) + } + if err := s.sm.Transition(ctx, from, to); err != nil { + if errors.Is(err, statemachine.ErrIllegalTransition) { + return s.sm.Heartbeat(ctx, to) + } + return err + } + return nil +} + +func (s Scheduler) halt(ctx context.Context, eventType, reason, instrumentUID string) error { + _ = s.svc.Notifier.Alert(ctx, fmt.Sprintf("%s: %s", eventType, reason)) + return s.svc.Risk.Halt(ctx, s.cfg.Mode, eventType, reason, instrumentUID) +} + +func (s Scheduler) notifySignal(ctx context.Context, _ time.Time, sig domain.Signal) error { + return s.svc.Notifier.Info(ctx, fmt.Sprintf("signal %s decision=%s edge=%s reason=%s lots=%d", sig.InstrumentUID, sig.Decision, sig.NetEdgeBps.StringFixed(2), sig.RejectReason, sig.TargetLots)) +} + +func (s Scheduler) instrumentMap(ctx context.Context) (map[string]domain.Instrument, error) { + instrumentsList, err := s.svc.Repo.ListInstruments(ctx, false) + if err != nil { + return nil, err + } + out := make(map[string]domain.Instrument, len(instrumentsList)) + for _, instrument := range instrumentsList { + out[instrument.InstrumentUID] = instrument + } + return out, nil +} + +func (s Scheduler) logWarn(msg string, args ...any) { + if s.svc.Log != nil { + s.svc.Log.Warn(msg, args...) + } +} + +func exitFillDelta(previous, current domain.Order) domain.Order { + fill := current + fill.FilledLots = current.FilledLots - previous.FilledLots + if fill.FilledLots < 0 { + fill.FilledLots = 0 + } + fill.Commission = current.Commission.Sub(previous.Commission) + if fill.Commission.IsNegative() { + fill.Commission = decimal.Zero + } + if fill.FilledLots > 0 { + currentValue := current.AvgFillPrice.Mul(decimal.NewFromInt(current.FilledLots)) + previousValue := previous.AvgFillPrice.Mul(decimal.NewFromInt(previous.FilledLots)) + fill.AvgFillPrice = currentValue.Sub(previousValue).Div(decimal.NewFromInt(fill.FilledLots)) + } + return fill +} + +func spreadFromBook(book domain.OrderBook, tick decimal.Decimal) (features.SpreadResult, error) { + bid, ask, err := bestBidAsk(book) + if err != nil { + return features.SpreadResult{}, err + } + return features.Spread(bid, ask, tick) +} + +func bestBidAsk(book domain.OrderBook) (decimal.Decimal, decimal.Decimal, error) { + bid, ok := book.BestBid() + if !ok { + return decimal.Zero, decimal.Zero, execution.ErrEmptyOrderBook + } + ask, ok := book.BestAsk() + if !ok { + return decimal.Zero, decimal.Zero, execution.ErrEmptyOrderBook + } + return bid, ask, nil +} + +func hasOrder(orders []domain.Order, instrumentUID string, side domain.Side) bool { + for _, order := range orders { + if order.InstrumentUID == instrumentUID && order.Side == side && order.Status != domain.OrderStatusFailed && order.Status != domain.OrderStatusRejected { + return true + } + } + return false +} + +func sinceMidnight(t time.Time) time.Duration { + h, m, s := t.Clock() + return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second +} + +func tradingDate(t time.Time) time.Time { + y, m, d := t.Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..d3cfaf8 --- /dev/null +++ b/internal/scheduler/scheduler_test.go @@ -0,0 +1,287 @@ +package scheduler + +import ( + "context" + "testing" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/execution" + "overnight-trading-bot/internal/reconciliation" + "overnight-trading-bot/internal/risk" + "overnight-trading-bot/internal/statemachine" + "overnight-trading-bot/internal/testutil" + "overnight-trading-bot/internal/timeutil" + "overnight-trading-bot/internal/tinvest" +) + +func TestPhaseUsesMoscowWindows(t *testing.T) { + loc := time.FixedZone("MSK", 3*60*60) + s := Scheduler{cfg: Config{ + Location: loc, + EntrySignalTime: mustTOD("18:10:00"), + EntryWindowStart: mustTOD("18:20:00"), + NoNewEntryAfter: mustTOD("18:38:30"), + ExitWatchStart: mustTOD("09:50:00"), + ExitWindowStart: mustTOD("10:05:00"), + ExitWindowEnd: mustTOD("10:25:00"), + HardExitDeadline: mustTOD("10:45:00"), + }} + tests := []struct { + at string + want domain.SystemState + }{ + {"2026-06-06T09:55:00+03:00", domain.StateWaitExitWindow}, + {"2026-06-06T10:10:00+03:00", domain.StatePlaceExitOrders}, + {"2026-06-06T10:30:00+03:00", domain.StateMonitorExitOrders}, + {"2026-06-06T11:00:00+03:00", domain.StateReconcile}, + {"2026-06-06T18:15:00+03:00", domain.StateGenerateSignals}, + {"2026-06-06T18:25:00+03:00", domain.StatePlaceEntryOrders}, + {"2026-06-06T19:00:00+03:00", domain.StateHoldOvernight}, + } + for _, tt := range tests { + t.Run(tt.at, func(t *testing.T) { + at, err := time.Parse(time.RFC3339, tt.at) + if err != nil { + t.Fatal(err) + } + if got := s.phase(at.In(loc)); got != tt.want { + t.Fatalf("phase=%s, want %s", got, tt.want) + } + }) + } +} + +func TestInfrastructureOutageRequiresThreshold(t *testing.T) { + gateway := tinvest.NewFakeGateway() + gateway.ServerTime = time.Now().UTC().Add(-10 * time.Second) + s := &Scheduler{ + cfg: Config{ + Mode: domain.ModeSandbox, + MaxClockDrift: 2 * time.Second, + APIOutageHalt: 180 * time.Second, + }, + svc: Services{Gateway: gateway}, + } + if err := s.checkInfrastructure(context.Background()); err != nil { + t.Fatalf("first infrastructure failure should be tolerated: %v", err) + } + s.infraFailedSince = time.Now().UTC().Add(-181 * time.Second) + if err := s.checkInfrastructure(context.Background()); err == nil { + t.Fatalf("expected outage after threshold") + } +} + +func TestReconcileAndReportIsIdempotentPerDate(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + gateway := tinvest.NewFakeGateway() + notifier := &countNotifier{} + recon := reconciliation.New(repo, gateway, "account", "hash") + s := Scheduler{ + cfg: Config{Mode: domain.ModePaper, Location: time.UTC}, + sm: statemachine.New(repo, domain.ModePaper), + svc: Services{ + Repo: repo, + Gateway: gateway, + Reconcile: recon, + Notifier: notifier, + Risk: risk.NewManager(repo, risk.ManagerConfig{}), + AccountID: "account", + AccountIDHash: "hash", + }, + } + now := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC) + if err := s.reconcileAndReport(ctx, now); err != nil { + t.Fatal(err) + } + if err := s.reconcileAndReport(ctx, now); err != nil { + t.Fatal(err) + } + if notifier.reports != 1 { + t.Fatalf("reports sent=%d, want 1", notifier.reports) + } +} + +func TestExitFillDeltaUsesOnlyNewlyExecutedLots(t *testing.T) { + previous := domain.Order{ + FilledLots: 2, + AvgFillPrice: decimal.NewFromInt(100), + Commission: decimal.NewFromFloat(0.50), + } + current := domain.Order{ + FilledLots: 4, + AvgFillPrice: decimal.NewFromInt(110), + Commission: decimal.NewFromFloat(1.25), + } + fill := exitFillDelta(previous, current) + if fill.FilledLots != 2 { + t.Fatalf("delta filled lots=%d, want 2", fill.FilledLots) + } + if !fill.AvgFillPrice.Equal(decimal.NewFromInt(120)) { + t.Fatalf("delta avg fill price=%s, want 120", fill.AvgFillPrice) + } + if !fill.Commission.Equal(decimal.NewFromFloat(0.75)) { + t.Fatalf("delta commission=%s, want 0.75", fill.Commission) + } +} + +func TestHardDeadlineMarksOpenPositionFailedAndHalts(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + openDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) + if err := repo.UpsertPosition(ctx, domain.Position{ + AccountIDHash: "hash", + InstrumentUID: "uid", + OpenTradeDate: openDate, + Lots: 1, + Lot: 1, + Status: domain.PositionHoldingOvernight, + }); err != nil { + t.Fatal(err) + } + notifier := &countNotifier{} + s := Scheduler{ + cfg: Config{Mode: domain.ModePaper, Location: time.UTC}, + svc: Services{ + Repo: repo, + Risk: risk.NewManager(repo, risk.ManagerConfig{}), + Notifier: notifier, + AccountIDHash: "hash", + }, + } + if err := s.failOpenPositionsAtHardDeadline(ctx); err != nil { + t.Fatal(err) + } + if !repo.Halted || repo.State != domain.StateHalted { + t.Fatalf("system not halted: state=%s halted=%v", repo.State, repo.Halted) + } + positions, err := repo.ListOpenPositions(ctx, "hash") + if err != nil { + t.Fatal(err) + } + if len(positions) != 1 || positions[0].Status != domain.PositionExitFailed { + t.Fatalf("positions=%+v, want EXIT_FAILED", positions) + } + if notifier.alerts != 1 { + t.Fatalf("alerts=%d, want 1", notifier.alerts) + } +} + +func TestHoldOvernightCancelsActiveBuyOrders(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) + if err := repo.UpsertOrder(ctx, domain.Order{ + ClientOrderID: "buy", + AccountIDHash: "hash", + InstrumentUID: "uid", + TradeDate: tradeDate, + Side: domain.SideBuy, + OrderType: domain.OrderTypeLimit, + QuantityLots: 1, + Status: domain.OrderStatusNew, + }); err != nil { + t.Fatal(err) + } + s := Scheduler{ + cfg: Config{Mode: domain.ModePaper, Location: time.UTC}, + sm: statemachine.New(repo, domain.ModePaper), + svc: Services{ + Repo: repo, + Execution: &execution.Engine{}, + AccountIDHash: "hash", + }, + } + if err := s.holdOvernight(ctx); err != nil { + t.Fatal(err) + } + orders, err := repo.ListOrders(ctx, "hash", tradeDate, tradeDate) + if err != nil { + t.Fatal(err) + } + if len(orders) != 1 || orders[0].Status != domain.OrderStatusCancelled { + t.Fatalf("orders=%+v, want CANCELLED", orders) + } +} + +func TestSizeReductionRuleCutsSizerAfterBadExpectedErrors(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + tradeDate := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) + for i := 0; i < sizeReductionWindowTrades; i++ { + date := tradeDate.AddDate(0, 0, -i) + if err := repo.UpsertSignal(ctx, domain.Signal{ + TradeDate: date, + InstrumentUID: "uid", + Decision: domain.DecisionEnter, + NetEdgeBps: decimal.NewFromInt(20), + }); err != nil { + t.Fatal(err) + } + if err := repo.UpsertPosition(ctx, domain.Position{ + AccountIDHash: "hash", + InstrumentUID: "uid", + OpenTradeDate: date, + Lot: 1, + Status: domain.PositionExitFilled, + RealizedEdgeBps: decimal.Zero, + UpdatedAt: date.Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + } + s := Scheduler{ + svc: Services{ + Repo: repo, + AccountIDHash: "hash", + Sizer: risk.NewSizer(risk.SizingConfig{ + MaxPositionPct: decimal.NewFromInt(1), + MaxTotalExposurePct: decimal.NewFromInt(1), + MaxParticipationRate: decimal.NewFromInt(1), + CashUsageBuffer: decimal.NewFromInt(1), + RiskBudgetPerInstrumentPct: decimal.NewFromInt(1), + MinOrderNotionalRUB: decimal.NewFromInt(1), + }), + }, + } + if err := s.applySizeReductionRule(ctx, tradeDate, true); err != nil { + t.Fatal(err) + } + sized := s.svc.Sizer.Size(risk.SizingInput{ + Portfolio: domain.Portfolio{Equity: decimal.NewFromInt(10_000), Cash: decimal.NewFromInt(10_000)}, + SelectedInstruments: 1, + LimitPrice: decimal.NewFromInt(100), + Lot: 1, + EntryIntervalVolume: decimal.NewFromInt(10_000), + ExitIntervalVolume: decimal.NewFromInt(10_000), + Q05OvernightAbs: decimal.NewFromInt(1), + }) + if sized.Lots != 50 { + t.Fatalf("lots=%d, want reduced 50", sized.Lots) + } + if len(repo.RiskEvents) != 1 || repo.RiskEvents[0].EventType != "size_reduction_rule_triggered" { + t.Fatalf("risk events=%+v", repo.RiskEvents) + } +} + +func mustTOD(raw string) timeutil.TimeOfDay { + tod, err := timeutil.ParseTimeOfDay(raw) + if err != nil { + panic(err) + } + return tod +} + +type countNotifier struct { + reports int + alerts int +} + +func (n *countNotifier) Info(context.Context, string) error { return nil } +func (n *countNotifier) Warn(context.Context, string) error { return nil } +func (n *countNotifier) Alert(context.Context, string) error { n.alerts++; return nil } +func (n *countNotifier) Report(context.Context, string) error { n.reports++; return nil } +func (n *countNotifier) Close() error { return nil } diff --git a/internal/signal/engine.go b/internal/signal/engine.go new file mode 100644 index 0000000..30ec815 --- /dev/null +++ b/internal/signal/engine.go @@ -0,0 +1,152 @@ +package signal + +import ( + "encoding/json" + "strings" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" +) + +const ( + ReasonDisabled = "instrument_disabled" + ReasonQuarantine = "instrument_quarantine" + ReasonMetadataInvalid = "metadata_invalid" + ReasonTradingStatus = "trading_status_not_normal" + ReasonCommission = "commission_nonzero" + ReasonMuShort = "mu_on_60_non_positive" + ReasonMuLong = "mu_on_252_non_positive" + ReasonSigmaZero = "sigma_on_60_zero" + ReasonTStat = "tstat_on_60_below_threshold" + ReasonWinRate = "win_on_60_below_threshold" + ReasonNetEdge = "net_edge_bps_below_threshold" + ReasonSpread = "spread_bps_above_limit" + ReasonTick = "tick_bps_above_limit" + ReasonADV = "adv_20_below_limit" + ReasonFreeOrders = "free_order_budget_insufficient" + ReasonMaxPositions = "max_positions_reached" +) + +type Config struct { + MinTStat60 decimal.Decimal + MinWinRate60 decimal.Decimal + MinNetEdgeBps decimal.Decimal + MinADVRUB decimal.Decimal + MaxSpreadBpsDefault decimal.Decimal + MaxSpreadBpsMoneyMarket decimal.Decimal + MaxSpreadBpsBondFunds decimal.Decimal + MaxSpreadBpsEquityFunds decimal.Decimal + MaxTickBps decimal.Decimal + RequireZeroCommission bool + MaxPositions int +} + +type Candidate struct { + Instrument domain.Instrument + Features domain.FeatureSet + TradingStatus domain.TradingStatus + FreeOrderOK bool + OpenPositions int + TradeDate time.Time + ExtraContext map[string]any +} + +type Engine struct { + cfg Config +} + +func New(cfg Config) Engine { + return Engine{cfg: cfg} +} + +func (e Engine) Evaluate(c Candidate) domain.Signal { + reason := e.firstRejectReason(c) + decision := domain.DecisionEnter + if reason != "" { + decision = domain.DecisionReject + } + if isSkipReason(reason) { + decision = domain.DecisionSkip + } + context := map[string]any{ + "ticker": c.Instrument.Ticker, + "fund_type": c.Instrument.FundType, + "trading_status": c.TradingStatus, + "spread_limit": e.spreadLimit(c.Instrument).String(), + } + for k, v := range c.ExtraContext { + context[k] = v + } + raw, _ := json.Marshal(context) + return domain.Signal{ + TradeDate: c.TradeDate, + InstrumentUID: c.Instrument.InstrumentUID, + Decision: decision, + Score: c.Features.NetEdgeBps, + NetEdgeBps: c.Features.NetEdgeBps, + RejectReason: reason, + ContextJSON: string(raw), + CreatedAt: time.Now().UTC(), + } +} + +func isSkipReason(reason string) bool { + return reason == ReasonFreeOrders || reason == ReasonMaxPositions +} + +func (e Engine) firstRejectReason(c Candidate) string { + instr := c.Instrument + features := c.Features + switch { + case !instr.Enabled: + return ReasonDisabled + case instr.Quarantine: + return ReasonQuarantine + case !instr.MetadataValid(): + return ReasonMetadataInvalid + case c.TradingStatus != domain.TradingStatusNormal: + return ReasonTradingStatus + case e.cfg.RequireZeroCommission && instr.ExpectedCommissionBpsPerSide.IsPositive(): + return ReasonCommission + case !features.MuOn60.IsPositive(): + return ReasonMuShort + case !features.MuOn252.IsPositive(): + return ReasonMuLong + case !features.SigmaOn60.IsPositive(): + return ReasonSigmaZero + case features.TStatOn60.LessThan(e.cfg.MinTStat60): + return ReasonTStat + case features.WinOn60.LessThan(e.cfg.MinWinRate60): + return ReasonWinRate + case features.NetEdgeBps.LessThan(e.cfg.MinNetEdgeBps): + return ReasonNetEdge + case features.SpreadBps.GreaterThan(e.spreadLimit(instr)): + return ReasonSpread + case features.TickBps.GreaterThan(e.cfg.MaxTickBps): + return ReasonTick + case features.ADV20.LessThan(e.cfg.MinADVRUB): + return ReasonADV + case !c.FreeOrderOK: + return ReasonFreeOrders + case e.cfg.MaxPositions > 0 && c.OpenPositions >= e.cfg.MaxPositions: + return ReasonMaxPositions + default: + return "" + } +} + +func (e Engine) spreadLimit(instr domain.Instrument) decimal.Decimal { + fundType := strings.ToLower(instr.FundType) + switch { + case strings.Contains(fundType, "money"): + return e.cfg.MaxSpreadBpsMoneyMarket + case strings.Contains(fundType, "bond"): + return e.cfg.MaxSpreadBpsBondFunds + case strings.Contains(fundType, "equity"): + return e.cfg.MaxSpreadBpsEquityFunds + default: + return e.cfg.MaxSpreadBpsDefault + } +} diff --git a/internal/signal/engine_test.go b/internal/signal/engine_test.go new file mode 100644 index 0000000..0442d30 --- /dev/null +++ b/internal/signal/engine_test.go @@ -0,0 +1,87 @@ +package signal + +import ( + "testing" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" +) + +func sd(raw string) decimal.Decimal { + v, err := decimal.NewFromString(raw) + if err != nil { + panic(err) + } + return v +} + +func baseCandidate() Candidate { + return Candidate{ + TradeDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), + Instrument: domain.Instrument{ + InstrumentUID: "uid", + Ticker: "TRUR", + ClassCode: "TQTF", + Lot: 1, + MinPriceIncrement: sd("0.01"), + Currency: "RUB", + Enabled: true, + }, + Features: domain.FeatureSet{ + MuOn60: sd("0.002"), + MuOn252: sd("0.001"), + SigmaOn60: sd("0.01"), + TStatOn60: sd("2"), + WinOn60: sd("0.60"), + NetEdgeBps: sd("20"), + SpreadBps: sd("5"), + TickBps: sd("1"), + ADV20: sd("10000000"), + }, + TradingStatus: domain.TradingStatusNormal, + FreeOrderOK: true, + } +} + +func TestEngineEnter(t *testing.T) { + engine := New(Config{ + MinTStat60: sd("1.25"), + MinWinRate60: sd("0.55"), + MinNetEdgeBps: sd("10"), + MinADVRUB: sd("5000000"), + MaxSpreadBpsDefault: sd("20"), + MaxSpreadBpsMoneyMarket: sd("5"), + MaxSpreadBpsBondFunds: sd("10"), + MaxSpreadBpsEquityFunds: sd("25"), + MaxTickBps: sd("10"), + RequireZeroCommission: true, + MaxPositions: 5, + }) + sig := engine.Evaluate(baseCandidate()) + if sig.Decision != domain.DecisionEnter || sig.RejectReason != "" { + t.Fatalf("unexpected signal: %+v", sig) + } +} + +func TestEngineFirstRejectReason(t *testing.T) { + engine := New(Config{MinTStat60: sd("1.25"), MinWinRate60: sd("0.55"), MinNetEdgeBps: sd("10"), MinADVRUB: sd("5000000"), MaxSpreadBpsDefault: sd("20"), MaxTickBps: sd("10"), RequireZeroCommission: true}) + c := baseCandidate() + c.Features.MuOn60 = decimal.Zero + c.Features.NetEdgeBps = decimal.Zero + sig := engine.Evaluate(c) + if sig.RejectReason != ReasonMuShort { + t.Fatalf("reason=%s", sig.RejectReason) + } +} + +func TestEngineUsesSkipForCapacityReasons(t *testing.T) { + engine := New(Config{MinTStat60: sd("1.25"), MinWinRate60: sd("0.55"), MinNetEdgeBps: sd("10"), MinADVRUB: sd("5000000"), MaxSpreadBpsDefault: sd("20"), MaxTickBps: sd("10"), RequireZeroCommission: true, MaxPositions: 1}) + c := baseCandidate() + c.OpenPositions = 1 + sig := engine.Evaluate(c) + if sig.Decision != domain.DecisionSkip || sig.RejectReason != ReasonMaxPositions { + t.Fatalf("unexpected skip signal: %+v", sig) + } +} diff --git a/internal/statemachine/system.go b/internal/statemachine/system.go new file mode 100644 index 0000000..54aeaa4 --- /dev/null +++ b/internal/statemachine/system.go @@ -0,0 +1,120 @@ +package statemachine + +import ( + "context" + "errors" + "fmt" + "time" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/reconciliation" + "overnight-trading-bot/internal/repository" +) + +var ( + ErrIllegalTransition = errors.New("illegal system transition") + ErrSystemHalted = errors.New("system is halted") +) + +type System struct { + repo repository.Repository + mode domain.Mode +} + +func New(repo repository.Repository, mode domain.Mode) System { + return System{repo: repo, mode: mode} +} + +func (s System) Recover(ctx context.Context, reconcile reconciliation.Engine) (domain.SystemState, error) { + state, halted, reason, err := s.repo.GetSystemState(ctx) + if err != nil { + return "", err + } + if halted || state == domain.StateHalted { + return domain.StateHalted, fmt.Errorf("system halted: %s", reason) + } + switch state { + case domain.StatePlaceEntryOrders, domain.StateMonitorEntryOrders, + domain.StatePlaceExitOrders, domain.StateMonitorExitOrders, + domain.StateHoldOvernight: + diffs, err := reconcile.Run(ctx) + if err != nil { + return "", err + } + if reconciliation.HasCritical(diffs) { + if err := s.Halt(ctx, "critical reconciliation diff during recovery"); err != nil { + return "", err + } + return domain.StateHalted, errors.New("critical reconciliation diff during recovery") + } + return state, nil + case domain.StateInit, domain.StateSyncInstruments, domain.StateSyncMarketData, domain.StateGenerateSignals: + return domain.StateInit, s.persist(ctx, domain.StateInit, false, "") + default: + return state, nil + } +} + +func (s System) Transition(ctx context.Context, from, to domain.SystemState) error { + current, halted, reason, err := s.repo.GetSystemState(ctx) + if err != nil { + return err + } + if (halted || current == domain.StateHalted) && to != domain.StateHalted { + return fmt.Errorf("%w: %s", ErrSystemHalted, reason) + } + if !legalTransition(from, to) { + return fmt.Errorf("%w: %s -> %s", ErrIllegalTransition, from, to) + } + return s.persist(ctx, to, false, "") +} + +func (s System) Halt(ctx context.Context, reason string) error { + return s.persist(ctx, domain.StateHalted, true, reason) +} + +func (s System) Heartbeat(ctx context.Context, state domain.SystemState) error { + current, halted, reason, err := s.repo.GetSystemState(ctx) + if err != nil { + return err + } + if halted || current == domain.StateHalted { + return s.repo.SaveSystemState(ctx, domain.StateHalted, s.mode, true, reason, fmt.Sprintf(`{"heartbeat":"%s"}`, time.Now().UTC().Format(time.RFC3339Nano))) + } + return s.repo.SaveSystemState(ctx, state, s.mode, false, "", fmt.Sprintf(`{"heartbeat":"%s"}`, time.Now().UTC().Format(time.RFC3339Nano))) +} + +func (s System) persist(ctx context.Context, state domain.SystemState, halted bool, reason string) error { + return s.repo.SaveSystemState(ctx, state, s.mode, halted, reason, "{}") +} + +func legalTransition(from, to domain.SystemState) bool { + if from == to { + return true + } + if to == domain.StateHalted { + return true + } + allowed := map[domain.SystemState][]domain.SystemState{ + domain.StateInit: {domain.StateSyncInstruments, domain.StateWaitExitWindow}, + domain.StateSyncInstruments: {domain.StateSyncMarketData}, + domain.StateSyncMarketData: {domain.StateGenerateSignals}, + domain.StateGenerateSignals: {domain.StateWaitEntryWindow}, + domain.StateWaitEntryWindow: {domain.StatePlaceEntryOrders, domain.StateSleep}, + domain.StatePlaceEntryOrders: {domain.StateMonitorEntryOrders, domain.StateReconcile}, + domain.StateMonitorEntryOrders: {domain.StateHoldOvernight, domain.StateReconcile}, + domain.StateHoldOvernight: {domain.StateWaitExitWindow}, + domain.StateWaitExitWindow: {domain.StatePlaceExitOrders}, + domain.StatePlaceExitOrders: {domain.StateMonitorExitOrders, domain.StateReconcile}, + domain.StateMonitorExitOrders: {domain.StateReconcile}, + domain.StateReconcile: {domain.StateReport, domain.StateHalted}, + domain.StateReport: {domain.StateSleep}, + domain.StateSleep: {domain.StateInit, domain.StateWaitExitWindow, domain.StateGenerateSignals}, + } + for _, candidate := range allowed[from] { + if candidate == to { + return true + } + } + return false +} diff --git a/internal/statemachine/system_test.go b/internal/statemachine/system_test.go new file mode 100644 index 0000000..a745794 --- /dev/null +++ b/internal/statemachine/system_test.go @@ -0,0 +1,93 @@ +package statemachine + +import ( + "context" + "errors" + "testing" + "time" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/reconciliation" + "overnight-trading-bot/internal/testutil" + "overnight-trading-bot/internal/tinvest" +) + +func TestHeartbeatDoesNotClearHalt(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + system := New(repo, domain.ModeLiveTrade) + if err := system.Halt(ctx, "manual kill switch"); err != nil { + t.Fatal(err) + } + if err := system.Heartbeat(ctx, domain.StateSleep); err != nil { + t.Fatal(err) + } + state, halted, reason, err := repo.GetSystemState(ctx) + if err != nil { + t.Fatal(err) + } + if state != domain.StateHalted || !halted || reason != "manual kill switch" { + t.Fatalf("halt was not sticky: state=%s halted=%v reason=%q", state, halted, reason) + } +} + +func TestTransitionBlockedWhileHalted(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + system := New(repo, domain.ModePaper) + if err := system.Halt(ctx, "risk"); err != nil { + t.Fatal(err) + } + err := system.Transition(ctx, domain.StateHalted, domain.StateInit) + if !errors.Is(err, ErrSystemHalted) { + t.Fatalf("expected ErrSystemHalted, got %v", err) + } +} + +func TestUnhaltPreservesMode(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + if err := repo.SaveSystemState(ctx, domain.StateHalted, domain.ModeLiveTrade, true, "risk", "{}"); err != nil { + t.Fatal(err) + } + if err := repo.Unhalt(ctx, "checked"); err != nil { + t.Fatal(err) + } + _, halted, _, err := repo.GetSystemState(ctx) + if err != nil { + t.Fatal(err) + } + if halted || repo.Mode != domain.ModeLiveTrade { + t.Fatalf("unhalt did not preserve mode: halted=%v mode=%s", halted, repo.Mode) + } +} + +func TestRecoverFromMonitorEntryHaltsOnCriticalReconciliationDiff(t *testing.T) { + ctx := context.Background() + repo := testutil.NewMemoryRepository() + if err := repo.SaveSystemState(ctx, domain.StateMonitorEntryOrders, domain.ModePaper, false, "", "{}"); err != nil { + t.Fatal(err) + } + if err := repo.UpsertOrder(ctx, domain.Order{ + ClientOrderID: "local", + BrokerOrderID: "broker-missing", + AccountIDHash: "hash", + InstrumentUID: "uid", + TradeDate: time.Now().UTC(), + Side: domain.SideBuy, + OrderType: domain.OrderTypeLimit, + QuantityLots: 1, + Status: domain.OrderStatusSent, + CreatedAt: time.Now().UTC().Add(-time.Minute), + }); err != nil { + t.Fatal(err) + } + system := New(repo, domain.ModePaper) + state, err := system.Recover(ctx, reconciliation.New(repo, tinvest.NewFakeGateway(), "account", "hash")) + if err == nil { + t.Fatal("expected critical reconciliation error") + } + if state != domain.StateHalted || !repo.Halted { + t.Fatalf("state=%s halted=%v, want HALTED", state, repo.Halted) + } +} diff --git a/internal/testutil/memory_repository.go b/internal/testutil/memory_repository.go new file mode 100644 index 0000000..6297a3e --- /dev/null +++ b/internal/testutil/memory_repository.go @@ -0,0 +1,344 @@ +package testutil + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/repository" +) + +var _ repository.Repository = (*MemoryRepository)(nil) + +type MemoryRepository struct { + mu sync.Mutex + + Instruments map[string]domain.Instrument + Daily map[string][]domain.Candle + Minute map[string][]domain.Candle + Features map[string]domain.FeatureSet + Signals map[string]domain.Signal + Orders map[string]domain.Order + Positions map[int64]domain.Position + RiskEvents []domain.RiskEvent + FreeOrders map[string]int + Reports map[string]bool + + State domain.SystemState + Mode domain.Mode + Halted bool + HaltReason string + + nextPositionID int64 +} + +func NewMemoryRepository() *MemoryRepository { + return &MemoryRepository{ + Instruments: make(map[string]domain.Instrument), + Daily: make(map[string][]domain.Candle), + Minute: make(map[string][]domain.Candle), + Features: make(map[string]domain.FeatureSet), + Signals: make(map[string]domain.Signal), + Orders: make(map[string]domain.Order), + Positions: make(map[int64]domain.Position), + FreeOrders: make(map[string]int), + Reports: make(map[string]bool), + State: domain.StateInit, + Mode: domain.ModePaper, + nextPositionID: 1, + } +} + +func (r *MemoryRepository) RunInTx(ctx context.Context, fn func(context.Context, repository.Repository) error) error { + return fn(ctx, r) +} + +func (r *MemoryRepository) UpsertInstrument(_ context.Context, instrument domain.Instrument) error { + r.mu.Lock() + defer r.mu.Unlock() + r.Instruments[instrument.InstrumentUID] = instrument + return nil +} + +func (r *MemoryRepository) ReplaceInstrument(_ context.Context, oldInstrumentUID string, instrument domain.Instrument) error { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.Instruments, oldInstrumentUID) + r.Instruments[instrument.InstrumentUID] = instrument + return nil +} + +func (r *MemoryRepository) ListInstruments(_ context.Context, includeDisabled bool) ([]domain.Instrument, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]domain.Instrument, 0, len(r.Instruments)) + for _, instrument := range r.Instruments { + if includeDisabled || instrument.Enabled { + out = append(out, instrument) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Ticker < out[j].Ticker }) + return out, nil +} + +func (r *MemoryRepository) QuarantineInstrument(_ context.Context, instrumentUID, reason string) error { + r.mu.Lock() + defer r.mu.Unlock() + instrument := r.Instruments[instrumentUID] + instrument.Quarantine = true + instrument.QuarantineReason = reason + r.Instruments[instrumentUID] = instrument + return nil +} + +func (r *MemoryRepository) UpsertDailyCandles(_ context.Context, candles []domain.Candle) error { + r.mu.Lock() + defer r.mu.Unlock() + for _, candle := range candles { + r.Daily[candle.InstrumentUID] = upsertCandle(r.Daily[candle.InstrumentUID], candle, false) + } + return nil +} + +func (r *MemoryRepository) ListDailyCandles(_ context.Context, instrumentUID string, from, to time.Time) ([]domain.Candle, error) { + r.mu.Lock() + defer r.mu.Unlock() + return filterCandles(r.Daily[instrumentUID], from, to), nil +} + +func (r *MemoryRepository) UpsertMinuteCandles(_ context.Context, candles []domain.Candle) error { + r.mu.Lock() + defer r.mu.Unlock() + for _, candle := range candles { + r.Minute[candle.InstrumentUID] = upsertCandle(r.Minute[candle.InstrumentUID], candle, true) + } + return nil +} + +func (r *MemoryRepository) ListMinuteCandles(_ context.Context, instrumentUID string, from, to time.Time) ([]domain.Candle, error) { + r.mu.Lock() + defer r.mu.Unlock() + return filterCandles(r.Minute[instrumentUID], from, to), nil +} + +func (r *MemoryRepository) UpsertFeature(_ context.Context, feature domain.FeatureSet) error { + r.mu.Lock() + defer r.mu.Unlock() + r.Features[featureKey(feature.InstrumentUID, feature.TradeDate)] = feature + return nil +} + +func (r *MemoryRepository) GetFeature(_ context.Context, instrumentUID string, tradeDate time.Time) (domain.FeatureSet, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.Features[featureKey(instrumentUID, tradeDate)], nil +} + +func (r *MemoryRepository) UpsertSignal(_ context.Context, signal domain.Signal) error { + r.mu.Lock() + defer r.mu.Unlock() + r.Signals[featureKey(signal.InstrumentUID, signal.TradeDate)] = signal + return nil +} + +func (r *MemoryRepository) ListSignals(_ context.Context, tradeDate time.Time) ([]domain.Signal, error) { + r.mu.Lock() + defer r.mu.Unlock() + var out []domain.Signal + for _, signal := range r.Signals { + if sameDate(signal.TradeDate, tradeDate) { + out = append(out, signal) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].InstrumentUID < out[j].InstrumentUID }) + return out, nil +} + +func (r *MemoryRepository) UpsertOrder(_ context.Context, order domain.Order) error { + r.mu.Lock() + defer r.mu.Unlock() + r.Orders[order.ClientOrderID] = order + return nil +} + +func (r *MemoryRepository) UpdateOrderStatus(_ context.Context, clientOrderID string, status domain.OrderStatus, filledLots int64, rawJSON string) error { + r.mu.Lock() + defer r.mu.Unlock() + order := r.Orders[clientOrderID] + order.Status = status + order.FilledLots = filledLots + order.RawStateJSON = rawJSON + r.Orders[clientOrderID] = order + return nil +} + +func (r *MemoryRepository) ListActiveOrders(_ context.Context, accountIDHash string) ([]domain.Order, error) { + r.mu.Lock() + defer r.mu.Unlock() + var out []domain.Order + for _, order := range r.Orders { + if order.AccountIDHash == accountIDHash && (order.Status == domain.OrderStatusNew || order.Status == domain.OrderStatusSent || order.Status == domain.OrderStatusPartiallyFilled) { + out = append(out, order) + } + } + return out, nil +} + +func (r *MemoryRepository) ListOrders(_ context.Context, accountIDHash string, from, to time.Time) ([]domain.Order, error) { + r.mu.Lock() + defer r.mu.Unlock() + var out []domain.Order + for _, order := range r.Orders { + if order.AccountIDHash == accountIDHash && !order.TradeDate.Before(dateOnly(from)) && !order.TradeDate.After(dateOnly(to)) { + out = append(out, order) + } + } + return out, nil +} + +func (r *MemoryRepository) UpsertPosition(_ context.Context, position domain.Position) error { + r.mu.Lock() + defer r.mu.Unlock() + for id, existing := range r.Positions { + if existing.AccountIDHash == position.AccountIDHash && + existing.InstrumentUID == position.InstrumentUID && + sameDate(existing.OpenTradeDate, position.OpenTradeDate) { + position.ID = id + r.Positions[id] = position + return nil + } + } + if position.ID == 0 { + position.ID = r.nextPositionID + r.nextPositionID++ + } + r.Positions[position.ID] = position + return nil +} + +func (r *MemoryRepository) ListOpenPositions(_ context.Context, accountIDHash string) ([]domain.Position, error) { + r.mu.Lock() + defer r.mu.Unlock() + var out []domain.Position + for _, pos := range r.Positions { + if pos.AccountIDHash == accountIDHash && pos.Status != domain.PositionNoPosition && pos.Status != domain.PositionExitFilled && pos.Status != domain.PositionQuarantine { + out = append(out, pos) + } + } + return out, nil +} + +func (r *MemoryRepository) ListPositions(_ context.Context, accountIDHash string, from, to time.Time) ([]domain.Position, error) { + r.mu.Lock() + defer r.mu.Unlock() + var out []domain.Position + for _, pos := range r.Positions { + if pos.AccountIDHash == accountIDHash && !pos.OpenTradeDate.Before(dateOnly(from)) && !pos.OpenTradeDate.After(dateOnly(to)) { + out = append(out, pos) + } + } + return out, nil +} + +func (r *MemoryRepository) InsertRiskEvent(_ context.Context, event domain.RiskEvent) error { + r.mu.Lock() + defer r.mu.Unlock() + r.RiskEvents = append(r.RiskEvents, event) + return nil +} + +func (r *MemoryRepository) GetFreeOrdersSent(_ context.Context, tradeDate time.Time, instrumentUID string) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.FreeOrders[featureKey(instrumentUID, tradeDate)], nil +} + +func (r *MemoryRepository) IncrementFreeOrders(_ context.Context, tradeDate time.Time, instrumentUID string, delta int) error { + r.mu.Lock() + defer r.mu.Unlock() + r.FreeOrders[featureKey(instrumentUID, tradeDate)] += delta + return nil +} + +func (r *MemoryRepository) GetSystemState(_ context.Context) (domain.SystemState, bool, string, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.State, r.Halted, r.HaltReason, nil +} + +func (r *MemoryRepository) SaveSystemState(_ context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, _ string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.State = state + r.Mode = mode + r.Halted = halted + r.HaltReason = reason + return nil +} + +func (r *MemoryRepository) Unhalt(_ context.Context, reason string) error { + r.mu.Lock() + defer r.mu.Unlock() + if !r.Halted && r.State != domain.StateHalted { + return fmt.Errorf("system is not halted") + } + r.RiskEvents = append(r.RiskEvents, domain.RiskEvent{Severity: domain.SeverityInfo, EventType: "manual_unhalt", Message: reason}) + r.State = domain.StateInit + r.Halted = false + r.HaltReason = "" + return nil +} + +func (r *MemoryRepository) WasDailyReportSent(_ context.Context, reportDate time.Time, accountIDHash string) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.Reports[accountIDHash+"|"+dateOnly(reportDate).Format("2006-01-02")], nil +} + +func (r *MemoryRepository) MarkDailyReportSent(_ context.Context, reportDate time.Time, accountIDHash string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.Reports[accountIDHash+"|"+dateOnly(reportDate).Format("2006-01-02")] = true + return nil +} + +func (r *MemoryRepository) InsertReconciliation(_ context.Context, _ time.Time, _ string, _ bool) error { + return nil +} + +func upsertCandle(candles []domain.Candle, candle domain.Candle, minute bool) []domain.Candle { + for i, existing := range candles { + if (!minute && sameDate(existing.TradeDate, candle.TradeDate)) || (minute && existing.TradeDate.Equal(candle.TradeDate)) { + candles[i] = candle + return candles + } + } + return append(candles, candle) +} + +func filterCandles(candles []domain.Candle, from, to time.Time) []domain.Candle { + var out []domain.Candle + for _, candle := range candles { + if !candle.TradeDate.Before(from) && !candle.TradeDate.After(to) { + out = append(out, candle) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].TradeDate.Before(out[j].TradeDate) }) + return out +} + +func featureKey(instrumentUID string, date time.Time) string { + return instrumentUID + "|" + dateOnly(date).Format("2006-01-02") +} + +func sameDate(a, b time.Time) bool { + return dateOnly(a).Equal(dateOnly(b)) +} + +func dateOnly(t time.Time) time.Time { + y, m, d := t.UTC().Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} diff --git a/internal/timeutil/clock.go b/internal/timeutil/clock.go new file mode 100644 index 0000000..a113bd2 --- /dev/null +++ b/internal/timeutil/clock.go @@ -0,0 +1,93 @@ +package timeutil + +import ( + "fmt" + "time" +) + +type Clock interface { + Now() time.Time + Sleep(ctxDone <-chan struct{}, d time.Duration) bool +} + +type RealClock struct { + Loc *time.Location +} + +func (c RealClock) Now() time.Time { + now := time.Now() + if c.Loc != nil { + return now.In(c.Loc) + } + return now +} + +func (c RealClock) Sleep(ctxDone <-chan struct{}, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return true + case <-ctxDone: + return false + } +} + +type TimeOfDay struct { + Duration time.Duration +} + +func ParseTimeOfDay(raw string) (TimeOfDay, error) { + parsed, err := time.Parse("15:04:05", raw) + if err != nil { + return TimeOfDay{}, fmt.Errorf("parse time of day %q: %w", raw, err) + } + return TimeOfDay{ + Duration: time.Duration(parsed.Hour())*time.Hour + + time.Duration(parsed.Minute())*time.Minute + + time.Duration(parsed.Second())*time.Second, + }, nil +} + +func (t *TimeOfDay) UnmarshalText(text []byte) error { + parsed, err := ParseTimeOfDay(string(text)) + if err != nil { + return err + } + *t = parsed + return nil +} + +func (t TimeOfDay) String() string { + total := int64(t.Duration.Seconds()) + h := total / 3600 + m := (total % 3600) / 60 + s := total % 60 + return fmt.Sprintf("%02d:%02d:%02d", h, m, s) +} + +func (t TimeOfDay) On(date time.Time, loc *time.Location) time.Time { + local := date.In(loc) + y, m, d := local.Date() + midnight := time.Date(y, m, d, 0, 0, 0, 0, loc) + return midnight.Add(t.Duration) +} + +type Window struct { + Start TimeOfDay + End TimeOfDay +} + +func (w Window) Contains(now time.Time, loc *time.Location) bool { + start := w.Start.On(now, loc) + end := w.End.On(now, loc) + return !now.Before(start) && now.Before(end) +} + +func Drift(local, server time.Time) time.Duration { + d := local.Sub(server) + if d < 0 { + return -d + } + return d +} diff --git a/internal/tinvest/gateway.go b/internal/tinvest/gateway.go new file mode 100644 index 0000000..bc66d2b --- /dev/null +++ b/internal/tinvest/gateway.go @@ -0,0 +1,182 @@ +package tinvest + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/shopspring/decimal" + + "overnight-trading-bot/internal/domain" +) + +var ErrNotFound = errors.New("not found") + +type Gateway interface { + GetInstrument(ctx context.Context, ticker, classCode string) (domain.Instrument, error) + GetCandles(ctx context.Context, instrumentUID string, interval string, from, to time.Time) ([]domain.Candle, error) + GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, error) + GetTradingStatus(ctx context.Context, instrumentUID string) (domain.TradingStatus, error) + PostLimitOrder(ctx context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error) + CancelOrder(ctx context.Context, accountID, orderID string) error + GetOrderState(ctx context.Context, accountID, orderID string) (domain.Order, error) + GetActiveOrders(ctx context.Context, accountID string) ([]domain.Order, error) + GetPortfolio(ctx context.Context, accountID string) (domain.Portfolio, error) + GetOperations(ctx context.Context, accountID string, from, to time.Time) ([]domain.Operation, error) + GetServerTime(ctx context.Context) (time.Time, error) +} + +type FakeGateway struct { + mu sync.Mutex + Instruments map[string]domain.Instrument + Candles map[string][]domain.Candle + OrderBooks map[string]domain.OrderBook + Statuses map[string]domain.TradingStatus + Orders map[string]domain.Order + Portfolio domain.Portfolio + Operations []domain.Operation + ServerTime time.Time +} + +func NewFakeGateway() *FakeGateway { + return &FakeGateway{ + Instruments: make(map[string]domain.Instrument), + Candles: make(map[string][]domain.Candle), + OrderBooks: make(map[string]domain.OrderBook), + Statuses: make(map[string]domain.TradingStatus), + Orders: make(map[string]domain.Order), + Portfolio: domain.Portfolio{ + Equity: decimal.NewFromInt(100_000), + Cash: decimal.NewFromInt(100_000), + CheckedAt: time.Now().UTC(), + }, + } +} + +func (f *FakeGateway) GetInstrument(_ context.Context, ticker, classCode string) (domain.Instrument, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, instrument := range f.Instruments { + if instrument.Ticker == ticker && instrument.ClassCode == classCode { + return instrument, nil + } + } + return domain.Instrument{}, ErrNotFound +} + +func (f *FakeGateway) GetCandles(_ context.Context, instrumentUID string, _ string, from, to time.Time) ([]domain.Candle, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []domain.Candle + for _, candle := range f.Candles[instrumentUID] { + if !candle.TradeDate.Before(from) && !candle.TradeDate.After(to) { + out = append(out, candle) + } + } + return out, nil +} + +func (f *FakeGateway) GetOrderBook(_ context.Context, instrumentUID string, _ int32) (domain.OrderBook, error) { + f.mu.Lock() + defer f.mu.Unlock() + book, ok := f.OrderBooks[instrumentUID] + if !ok { + return domain.OrderBook{}, ErrNotFound + } + return book, nil +} + +func (f *FakeGateway) GetTradingStatus(_ context.Context, instrumentUID string) (domain.TradingStatus, error) { + f.mu.Lock() + defer f.mu.Unlock() + status, ok := f.Statuses[instrumentUID] + if !ok { + return domain.TradingStatusNormal, nil + } + return status, nil +} + +func (f *FakeGateway) PostLimitOrder(_ context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error) { + f.mu.Lock() + defer f.mu.Unlock() + order := domain.Order{ + ClientOrderID: clientOrderID, + BrokerOrderID: "fake-" + clientOrderID, + AccountIDHash: accountID, + InstrumentUID: instrumentUID, + Side: side, + OrderType: domain.OrderTypeLimit, + LimitPrice: price, + QuantityLots: lots, + Status: domain.OrderStatusSent, + RawStateJSON: "{}", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + f.Orders[order.BrokerOrderID] = order + return order, nil +} + +func (f *FakeGateway) CancelOrder(_ context.Context, _ string, orderID string) error { + f.mu.Lock() + defer f.mu.Unlock() + order, ok := f.Orders[orderID] + if !ok { + return ErrNotFound + } + order.Status = domain.OrderStatusCancelled + order.UpdatedAt = time.Now().UTC() + f.Orders[orderID] = order + return nil +} + +func (f *FakeGateway) GetOrderState(_ context.Context, _ string, orderID string) (domain.Order, error) { + f.mu.Lock() + defer f.mu.Unlock() + order, ok := f.Orders[orderID] + if !ok { + return domain.Order{}, ErrNotFound + } + return order, nil +} + +func (f *FakeGateway) GetActiveOrders(_ context.Context, _ string) ([]domain.Order, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]domain.Order, 0) + for _, order := range f.Orders { + if order.Status == domain.OrderStatusSent || order.Status == domain.OrderStatusPartiallyFilled { + out = append(out, order) + } + } + return out, nil +} + +func (f *FakeGateway) GetPortfolio(_ context.Context, _ string) (domain.Portfolio, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.Portfolio.CheckedAt = time.Now().UTC() + return f.Portfolio, nil +} + +func (f *FakeGateway) GetOperations(_ context.Context, _ string, from, to time.Time) ([]domain.Operation, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []domain.Operation + for _, op := range f.Operations { + if !op.ExecutedAt.Before(from) && !op.ExecutedAt.After(to) { + out = append(out, op) + } + } + return out, nil +} + +func (f *FakeGateway) GetServerTime(context.Context) (time.Time, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.ServerTime.IsZero() { + return time.Now().UTC(), nil + } + return f.ServerTime, nil +} diff --git a/internal/tinvest/real.go b/internal/tinvest/real.go new file mode 100644 index 0000000..157ff36 --- /dev/null +++ b/internal/tinvest/real.go @@ -0,0 +1,456 @@ +package tinvest + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/russianinvestments/invest-api-go-sdk/investgo" + pb "github.com/russianinvestments/invest-api-go-sdk/proto" + "github.com/shopspring/decimal" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "overnight-trading-bot/internal/domain" + "overnight-trading-bot/internal/logging" + "overnight-trading-bot/internal/money" +) + +type Options struct { + Token string + AccountID string + Endpoint string + AppName string + RetryCount int + RetryBackoff time.Duration + Logger *slog.Logger +} + +type RealGateway struct { + client *investgo.Client + instruments *investgo.InstrumentsServiceClient + marketData *investgo.MarketDataServiceClient + orders *investgo.OrdersServiceClient + operations *investgo.OperationsServiceClient + users *investgo.UsersServiceClient + retryAttempts int + retryBackoff time.Duration +} + +func NewRealGateway(ctx context.Context, opts Options) (*RealGateway, error) { + if opts.Token == "" { + return nil, fmt.Errorf("tinvest token is required") + } + client, err := investgo.NewClient(ctx, investgo.Config{ + EndPoint: opts.Endpoint, + Token: opts.Token, + AppName: opts.AppName, + AccountId: opts.AccountID, + MaxRetries: 0, + }, logging.SDKLogger{Logger: opts.Logger}) + if err != nil { + return nil, err + } + return &RealGateway{ + client: client, + instruments: client.NewInstrumentsServiceClient(), + marketData: client.NewMarketDataServiceClient(), + orders: client.NewOrdersServiceClient(), + operations: client.NewOperationsServiceClient(), + users: client.NewUsersServiceClient(), + retryAttempts: opts.RetryCount, + retryBackoff: opts.RetryBackoff, + }, nil +} + +func (g *RealGateway) Close() error { + if g.client == nil || g.client.Conn == nil { + return nil + } + return g.client.Conn.Close() +} + +func (g *RealGateway) GetInstrument(ctx context.Context, ticker, classCode string) (domain.Instrument, error) { + if err := ctx.Err(); err != nil { + return domain.Instrument{}, err + } + resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.EtfResponse, error) { + return g.instruments.EtfByTicker(ticker, classCode) + }) + if err != nil { + return domain.Instrument{}, err + } + etf := resp.GetInstrument() + if etf == nil { + return domain.Instrument{}, ErrNotFound + } + return domain.Instrument{ + InstrumentUID: etf.GetUid(), + Figi: etf.GetFigi(), + Ticker: etf.GetTicker(), + ClassCode: etf.GetClassCode(), + Name: etf.GetName(), + Lot: int64(etf.GetLot()), + MinPriceIncrement: money.QuotationToDecimal(etf.GetMinPriceIncrement()), + Currency: strings.ToUpper(etf.GetCurrency()), + Enabled: etf.GetApiTradeAvailableFlag() && etf.GetBuyAvailableFlag() && etf.GetSellAvailableFlag(), + UpdatedAt: time.Now().UTC(), + }, nil +} + +func (g *RealGateway) GetCandles(ctx context.Context, instrumentUID string, interval string, from, to time.Time) ([]domain.Candle, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetCandlesResponse, error) { + return g.marketData.GetCandles(instrumentUID, candleInterval(interval), from, to, pb.GetCandlesRequest_CANDLE_SOURCE_EXCHANGE, 0) + }) + if err != nil { + return nil, err + } + candles := resp.GetCandles() + out := make([]domain.Candle, 0, len(candles)) + for _, candle := range candles { + out = append(out, domain.Candle{ + InstrumentUID: instrumentUID, + TradeDate: candle.GetTime().AsTime().UTC(), + Open: money.QuotationToDecimal(candle.GetOpen()), + High: money.QuotationToDecimal(candle.GetHigh()), + Low: money.QuotationToDecimal(candle.GetLow()), + Close: money.QuotationToDecimal(candle.GetClose()), + VolumeLots: decimal.NewFromInt(candle.GetVolume()), + Source: "tinvest", + LoadedAt: time.Now().UTC(), + }) + } + return out, nil +} + +func (g *RealGateway) GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, error) { + if err := ctx.Err(); err != nil { + return domain.OrderBook{}, err + } + resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrderBookResponse, error) { + return g.marketData.GetOrderBook(instrumentUID, depth) + }) + if err != nil { + return domain.OrderBook{}, err + } + return domain.OrderBook{ + InstrumentUID: instrumentUID, + Bids: orderBookLevels(resp.GetBids()), + Asks: orderBookLevels(resp.GetAsks()), + Time: resp.GetOrderbookTs().AsTime().UTC(), + ReceivedAt: time.Now().UTC(), + }, nil +} + +func (g *RealGateway) GetTradingStatus(ctx context.Context, instrumentUID string) (domain.TradingStatus, error) { + if err := ctx.Err(); err != nil { + return domain.TradingStatusUnknown, err + } + resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetTradingStatusResponse, error) { + return g.marketData.GetTradingStatus(instrumentUID) + }) + if err != nil { + return domain.TradingStatusUnknown, err + } + if resp.GetTradingStatus() == pb.SecurityTradingStatus_SECURITY_TRADING_STATUS_NORMAL_TRADING && + resp.GetLimitOrderAvailableFlag() && + resp.GetApiTradeAvailableFlag() { + return domain.TradingStatusNormal, nil + } + return domain.TradingStatusClosed, nil +} + +func (g *RealGateway) PostLimitOrder(ctx context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error) { + if err := ctx.Err(); err != nil { + return domain.Order{}, err + } + direction := pb.OrderDirection_ORDER_DIRECTION_BUY + if side == domain.SideSell { + direction = pb.OrderDirection_ORDER_DIRECTION_SELL + } + resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PostOrderResponse, error) { + return g.orders.PostOrder(&investgo.PostOrderRequest{ + InstrumentId: instrumentUID, + Quantity: lots, + Price: money.DecimalToQuotation(price), + Direction: direction, + AccountId: accountID, + OrderType: pb.OrderType_ORDER_TYPE_LIMIT, + OrderId: clientOrderID, + TimeInForce: pb.TimeInForceType_TIME_IN_FORCE_DAY, + PriceType: pb.PriceType_PRICE_TYPE_CURRENCY, + }) + }) + if err != nil { + return domain.Order{}, err + } + return orderFromPostResponse(resp.PostOrderResponse, accountID, clientOrderID, side, price), nil +} + +func (g *RealGateway) CancelOrder(ctx context.Context, accountID, orderID string) error { + if err := ctx.Err(); err != nil { + return err + } + return withRetry(ctx, g.retryAttempts, g.retryBackoff, func() error { + _, err := g.orders.CancelOrder(accountID, orderID, nil) + return err + }) +} + +func (g *RealGateway) GetOrderState(ctx context.Context, accountID, orderID string) (domain.Order, error) { + if err := ctx.Err(); err != nil { + return domain.Order{}, err + } + resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrderStateResponse, error) { + return g.orders.GetOrderState(accountID, orderID, pb.PriceType_PRICE_TYPE_CURRENCY, nil) + }) + if err != nil { + return domain.Order{}, err + } + return orderFromState(resp.OrderState, accountID), nil +} + +func (g *RealGateway) GetActiveOrders(ctx context.Context, accountID string) ([]domain.Order, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrdersResponse, error) { + return g.orders.GetOrders(accountID, nil) + }) + if err != nil { + return nil, err + } + states := resp.GetOrders() + out := make([]domain.Order, 0, len(states)) + for _, state := range states { + out = append(out, orderFromState(state, accountID)) + } + return out, nil +} + +func (g *RealGateway) GetPortfolio(ctx context.Context, accountID string) (domain.Portfolio, error) { + if err := ctx.Err(); err != nil { + return domain.Portfolio{}, err + } + resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PortfolioResponse, error) { + return g.operations.GetPortfolio(accountID, pb.PortfolioRequest_RUB) + }) + if err != nil { + return domain.Portfolio{}, err + } + positions := resp.GetPositions() + holdings := make([]domain.Holding, 0, len(positions)) + for _, position := range positions { + holdings = append(holdings, domain.Holding{ + InstrumentUID: position.GetInstrumentUid(), + QuantityLots: money.QuotationToDecimal(position.GetQuantity()).IntPart(), + AveragePrice: money.MoneyValueToDecimal(position.GetAveragePositionPrice()), + MarketValue: money.MoneyValueToDecimal(position.GetCurrentPrice()).Mul(money.QuotationToDecimal(position.GetQuantity())), + }) + } + equity, err := rubMoneyValueToDecimal(resp.GetTotalAmountPortfolio()) + if err != nil { + return domain.Portfolio{}, err + } + cash, err := rubMoneyValueToDecimal(resp.GetTotalAmountCurrencies()) + if err != nil { + return domain.Portfolio{}, err + } + return domain.Portfolio{ + Equity: equity, + Cash: cash, + Holdings: holdings, + CheckedAt: time.Now().UTC(), + }, nil +} + +func (g *RealGateway) GetOperations(ctx context.Context, accountID string, from, to time.Time) ([]domain.Operation, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.OperationsResponse, error) { + return g.operations.GetOperations(&investgo.GetOperationsRequest{ + AccountId: accountID, + From: from, + To: to, + }) + }) + if err != nil { + return nil, err + } + ops := resp.GetOperations() + out := make([]domain.Operation, 0, len(ops)) + for _, op := range ops { + payment := money.MoneyValueToDecimal(op.GetPayment()) + out = append(out, domain.Operation{ + ID: op.GetId(), + InstrumentUID: op.GetInstrumentUid(), + Type: op.GetOperationType().String(), + Payment: payment, + Commission: operationCommission(op.GetOperationType(), payment), + ExecutedAt: op.GetDate().AsTime().UTC(), + }) + } + return out, nil +} + +func (g *RealGateway) GetServerTime(ctx context.Context) (time.Time, error) { + if err := ctx.Err(); err != nil { + return time.Time{}, err + } + resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetInfoResponse, error) { + return g.users.GetInfo() + }) + if err != nil { + return time.Time{}, err + } + if serverTime, ok := serverTimeFromHeader(resp.Header); ok { + return serverTime, nil + } + return time.Time{}, errors.New("server time is unavailable in response metadata") +} + +func operationCommission(operationType pb.OperationType, payment decimal.Decimal) decimal.Decimal { + if operationType != pb.OperationType_OPERATION_TYPE_BROKER_FEE && + operationType != pb.OperationType_OPERATION_TYPE_SERVICE_FEE && + operationType != pb.OperationType_OPERATION_TYPE_SUCCESS_FEE { + return decimal.Zero + } + return money.Abs(payment) +} + +func rubMoneyValueToDecimal(value *pb.MoneyValue) (decimal.Decimal, error) { + if value == nil { + return decimal.Zero, nil + } + if currency := strings.ToUpper(value.GetCurrency()); currency != "" && currency != "RUB" { + return decimal.Zero, fmt.Errorf("expected RUB money value, got %s", currency) + } + return money.MoneyValueToDecimal(value), nil +} + +func serverTimeFromHeader(header map[string][]string) (time.Time, bool) { + for _, key := range []string{"date", "Date"} { + values := header[key] + if len(values) == 0 { + continue + } + parsed, err := http.ParseTime(values[0]) + if err == nil { + return parsed.UTC(), true + } + } + return time.Time{}, false +} + +func candleInterval(interval string) pb.CandleInterval { + switch strings.ToLower(interval) { + case "minute", "1m", "1min": + return pb.CandleInterval_CANDLE_INTERVAL_1_MIN + default: + return pb.CandleInterval_CANDLE_INTERVAL_DAY + } +} + +func orderBookLevels(levels []*pb.Order) []domain.OrderBookLevel { + out := make([]domain.OrderBookLevel, 0, len(levels)) + for _, level := range levels { + out = append(out, domain.OrderBookLevel{ + Price: money.QuotationToDecimal(level.GetPrice()), + QuantityLots: level.GetQuantity(), + }) + } + return out +} + +func orderFromPostResponse(resp *pb.PostOrderResponse, accountID, clientOrderID string, side domain.Side, limitPrice decimal.Decimal) domain.Order { + if resp == nil { + return domain.Order{} + } + now := time.Now().UTC() + return domain.Order{ + ClientOrderID: clientOrderID, + BrokerOrderID: resp.GetOrderId(), + AccountIDHash: accountID, + InstrumentUID: resp.GetInstrumentUid(), + Side: side, + OrderType: domain.OrderTypeLimit, + LimitPrice: limitPrice, + QuantityLots: resp.GetLotsRequested(), + FilledLots: resp.GetLotsExecuted(), + AvgFillPrice: limitPrice, + Status: mapOrderStatus(resp.GetExecutionReportStatus()), + Commission: money.MoneyValueToDecimal(resp.GetExecutedCommission()), + RawStateJSON: marshalProto(resp), + CreatedAt: now, + UpdatedAt: now, + } +} + +func orderFromState(state *pb.OrderState, accountID string) domain.Order { + if state == nil { + return domain.Order{} + } + side := domain.SideBuy + if state.GetDirection() == pb.OrderDirection_ORDER_DIRECTION_SELL { + side = domain.SideSell + } + orderDate := time.Now().UTC() + if state.GetOrderDate() != nil { + orderDate = state.GetOrderDate().AsTime().UTC() + } + return domain.Order{ + ClientOrderID: state.GetOrderRequestId(), + BrokerOrderID: state.GetOrderId(), + AccountIDHash: accountID, + InstrumentUID: state.GetInstrumentUid(), + Side: side, + OrderType: domain.OrderTypeLimit, + LimitPrice: money.MoneyValueToDecimal(state.GetInitialSecurityPrice()), + QuantityLots: state.GetLotsRequested(), + FilledLots: state.GetLotsExecuted(), + AvgFillPrice: money.MoneyValueToDecimal(state.GetAveragePositionPrice()), + Status: mapOrderStatus(state.GetExecutionReportStatus()), + Commission: money.MoneyValueToDecimal(state.GetExecutedCommission()), + RawStateJSON: marshalProto(state), + CreatedAt: orderDate, + UpdatedAt: time.Now().UTC(), + } +} + +func mapOrderStatus(status pb.OrderExecutionReportStatus) domain.OrderStatus { + switch status { + case pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_FILL: + return domain.OrderStatusFilled + case pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_PARTIALLYFILL: + return domain.OrderStatusPartiallyFilled + case pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_CANCELLED: + return domain.OrderStatusCancelled + case pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_REJECTED: + return domain.OrderStatusRejected + case pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_NEW: + return domain.OrderStatusSent + default: + return domain.OrderStatusNew + } +} + +func marshalProto(msg proto.Message) string { + if msg == nil { + return "{}" + } + raw, err := protojson.Marshal(msg) + if err != nil { + fallback, _ := json.Marshal(map[string]string{"marshal_error": err.Error()}) + return string(fallback) + } + return string(raw) +} diff --git a/internal/tinvest/retry.go b/internal/tinvest/retry.go new file mode 100644 index 0000000..4bf2147 --- /dev/null +++ b/internal/tinvest/retry.go @@ -0,0 +1,64 @@ +package tinvest + +import ( + "context" + "time" + + backofflib "github.com/cenkalti/backoff/v4" +) + +func withRetry(ctx context.Context, attempts int, interval time.Duration, fn func() error) error { + if attempts <= 0 { + attempts = 1 + } + if interval < 0 { + interval = 0 + } + policy := backofflib.NewExponentialBackOff() + policy.InitialInterval = interval + policy.MaxInterval = interval * 8 + policy.Multiplier = 2 + policy.MaxElapsedTime = 0 + policy.Reset() + var lastErr error + for attempt := 0; attempt < attempts; attempt++ { + if err := ctx.Err(); err != nil { + return err + } + if err := fn(); err != nil { + lastErr = err + } else { + return nil + } + if attempt == attempts-1 || interval <= 0 { + continue + } + timer := time.NewTimer(policy.NextBackOff()) + select { + case <-timer.C: + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return ctx.Err() + } + } + return lastErr +} + +func retryValue[T any](ctx context.Context, attempts int, interval time.Duration, fn func() (T, error)) (T, error) { + var out T + err := withRetry(ctx, attempts, interval, func() error { + var err error + out, err = fn() + return err + }) + if err != nil { + var zero T + return zero, err + } + return out, nil +} diff --git a/internal/tinvest/retry_test.go b/internal/tinvest/retry_test.go new file mode 100644 index 0000000..709fc4e --- /dev/null +++ b/internal/tinvest/retry_test.go @@ -0,0 +1,41 @@ +package tinvest + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestWithRetryRetriesUntilSuccess(t *testing.T) { + attempts := 0 + err := withRetry(context.Background(), 3, 0, func() error { + attempts++ + if attempts < 3 { + return errors.New("temporary") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if attempts != 3 { + t.Fatalf("attempts=%d, want 3", attempts) + } +} + +func TestWithRetryStopsOnContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + attempts := 0 + err := withRetry(ctx, 3, time.Millisecond, func() error { + attempts++ + return errors.New("temporary") + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err=%v, want context.Canceled", err) + } + if attempts != 0 { + t.Fatalf("attempts=%d, want 0", attempts) + } +} diff --git a/internal/tinvest/sandbox.go b/internal/tinvest/sandbox.go new file mode 100644 index 0000000..b752d4a --- /dev/null +++ b/internal/tinvest/sandbox.go @@ -0,0 +1,10 @@ +package tinvest + +import "context" + +const sandboxEndpoint = "sandbox-invest-public-api.tinkoff.ru:443" + +func NewSandboxGateway(ctx context.Context, opts Options) (*RealGateway, error) { + opts.Endpoint = sandboxEndpoint + return NewRealGateway(ctx, opts) +}