first version
This commit is contained in:
@@ -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=
|
||||||
@@ -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
|
||||||
+7
-14
@@ -1,15 +1,8 @@
|
|||||||
.DS_Store
|
|
||||||
|
|
||||||
# Go build artifacts
|
|
||||||
/bin/
|
|
||||||
/dist/
|
|
||||||
*.test
|
|
||||||
*.out
|
|
||||||
|
|
||||||
# Local configuration and secrets
|
|
||||||
.env
|
.env
|
||||||
config.yaml
|
bin/
|
||||||
|
backtest_out/
|
||||||
# Editor folders
|
.cache/
|
||||||
.idea/
|
.tmp/
|
||||||
.vscode/
|
.DS_Store
|
||||||
|
coverage.out
|
||||||
|
*.test
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
version: "2"
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- bodyclose
|
||||||
|
- errcheck
|
||||||
|
- gocritic
|
||||||
|
- gosec
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
- misspell
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
@@ -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 := go
|
||||||
go fmt ./...
|
|
||||||
|
|
||||||
test:
|
.PHONY: cache fmt vet lint test race integration sandbox tidy run migrate build backtest
|
||||||
go test ./...
|
|
||||||
|
|
||||||
run:
|
cache:
|
||||||
go run ./cmd/bot
|
mkdir -p $(GOCACHE) $(GOMODCACHE) $(GOLANGCI_LINT_CACHE) $(TMPDIR) bin
|
||||||
|
|
||||||
tidy:
|
fmt: cache
|
||||||
go mod tidy
|
$(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}"
|
||||||
|
|||||||
@@ -1,21 +1,261 @@
|
|||||||
# Overnight Trading Bot
|
# Overnight Trading Bot
|
||||||
|
|
||||||
Go-проект для overnight-бота по фондам T-Капитала через T-Invest API.
|
Go-бот для overnight-стратегии `close -> next open` на фондах T-Капитала через T-Invest API.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp config.example.yaml config.yaml
|
cp .env.example .env
|
||||||
go test ./...
|
make test
|
||||||
go run ./cmd/bot -config config.yaml
|
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
|
```sh
|
||||||
make fmt
|
make fmt
|
||||||
|
make vet
|
||||||
|
make lint
|
||||||
make test
|
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 и малый стартовый капитал.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+12
-3
@@ -10,12 +10,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
if err := app.Run(context.Background(), app.Options{
|
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 {
|
}); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "bot failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "bot failed: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
||||||
@@ -1,3 +1,78 @@
|
|||||||
module overnight-trading-bot
|
module overnight-trading-bot
|
||||||
|
|
||||||
go 1.26.2
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
+328
-13
@@ -2,38 +2,353 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"os"
|
"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 {
|
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 {
|
func Run(ctx context.Context, opts Options) error {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if opts.Healthcheck {
|
||||||
if opts.ConfigPath == "" {
|
target := opts.HealthcheckURL
|
||||||
return errors.New("config path is required")
|
if target == "" {
|
||||||
|
target = "http://127.0.0.1:3300/health"
|
||||||
|
}
|
||||||
|
return healthcheck.CheckEndpoint(ctx, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Stdout == nil {
|
if opts.Stdout == nil {
|
||||||
opts.Stdout = io.Discard
|
opts.Stdout = io.Discard
|
||||||
}
|
}
|
||||||
|
if opts.Stderr == nil {
|
||||||
if _, err := os.Stat(opts.ConfigPath); err != nil {
|
opts.Stderr = io.Discard
|
||||||
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)
|
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)
|
db, err := openDB(ctx, cfg)
|
||||||
return nil
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,49 +3,29 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunRequiresConfigPath(t *testing.T) {
|
func TestRunRequiresAppMode(t *testing.T) {
|
||||||
err := Run(context.Background(), Options{})
|
t.Setenv("APP_MODE", "")
|
||||||
|
err := Run(context.Background(), Options{RunOnce: true})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(err.Error(), "APP_MODE") && !strings.Contains(err.Error(), "MODE") {
|
||||||
if !strings.Contains(err.Error(), "config path is required") {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunReportsMissingConfig(t *testing.T) {
|
func TestRunBacktestModeWithoutDB(t *testing.T) {
|
||||||
err := Run(context.Background(), Options{ConfigPath: "missing.yaml"})
|
t.Setenv("APP_MODE", "backtest")
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
err := Run(context.Background(), Options{
|
err := Run(context.Background(), Options{Stdout: &stdout, RunOnce: true})
|
||||||
ConfigPath: touch,
|
|
||||||
Stdout: &stdout,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "backtest") {
|
||||||
if !strings.Contains(stdout.String(), "initialized") {
|
t.Fatalf("unexpected stdout: %s", stdout.String())
|
||||||
t.Fatalf("unexpected stdout: %q", stdout.String())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE positions DROP COLUMN lot_size;
|
||||||
|
|
||||||
|
UPDATE schema_meta SET meta_value='0005' WHERE meta_key='schema_version';
|
||||||
@@ -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';
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user