285 lines
15 KiB
Markdown
285 lines
15 KiB
Markdown
# Overnight Trading Bot
|
|
|
|
[English](README.md) / [Русский](README.ru.md)
|
|
|
|
A Go research bot for studying a `close -> next open` overnight strategy on T-Capital funds through the T-Invest API.
|
|
|
|
The project is intended for statistical research, backtesting, paper trading, sandbox checks, and tightly controlled live-readonly/live-trade experiments. It is not designed for market manipulation, price impact, or evading legal requirements. Orders must have a genuine execution intent, use limit-only execution, and pass liquidity, spread, commission, reconciliation, and risk controls.
|
|
|
|
One research thread is the overnight/intraday returns anomaly discussed by Bruce Knuteson in [“Nothing to See Here: How to Say It When You Need to”](https://ssrn.com/abstract=4619084) (`ssrn-4619084`).
|
|
|
|
License: [MIT](LICENSE).
|
|
|
|
## Quick Start
|
|
|
|
```sh
|
|
cp .env.example .env
|
|
make test
|
|
APP_MODE=backtest go run ./cmd/bot
|
|
```
|
|
|
|
Daemon modes (`paper`, `sandbox`, `live_readonly`, `live_trade`) require a MariaDB/MySQL `DB_DSN`. `live_trade` also requires `LIVE_TRADE_ACK=I_ACCEPT_RISK` and the live pre-flight checks listed below.
|
|
|
|
## Modes
|
|
|
|
| Mode | Purpose |
|
|
| --- | --- |
|
|
| `backtest` | Offline research mode. Does not require a database or T-Invest credentials when run through `cmd/bot`. |
|
|
| `paper` | Simulated orders. Without `TINVEST_TOKEN`, uses a fake gateway; with a token, uses real market data/status and simulated execution. |
|
|
| `sandbox` | T-Invest sandbox API. Requires token and account id. |
|
|
| `live_readonly` | Live API access without broker order placement. Used for observation and reconciliation. |
|
|
| `live_trade` | Real limit-order trading. Guarded by explicit risk acknowledgement and pre-flight requirements. |
|
|
|
|
## Configuration
|
|
|
|
Configuration is read from environment variables, usually through `.env`. If a value cannot be parsed, startup fails with `load ENV config`.
|
|
|
|
Common formats:
|
|
|
|
- Times use `HH:MM:SS` and are interpreted in `Europe/Moscow`.
|
|
- Percentages are decimal fractions: `0.10` means 10%, `0.005` means 0.5%.
|
|
- `bps` means basis points: `10` means 0.10%.
|
|
- Boolean values are `true` or `false`.
|
|
- Defaults below match `.env.example` and the code defaults.
|
|
|
|
### APP
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `APP_MODE` | `paper` | One of `backtest`, `paper`, `sandbox`, `live_readonly`, `live_trade`; required by the code. |
|
|
| `APP_TIMEZONE` | `Europe/Moscow` | Trading schedule timezone; validation currently allows only `Europe/Moscow`. |
|
|
| `APP_LOG_LEVEL` | `info` | JSON log level: `debug`, `info`, `warn`, `warning`, `error`. |
|
|
| `APP_HEALTHCHECK_ADDR` | `:3300` | HTTP address for `/health` and `/ready`. |
|
|
| `APP_SHUTDOWN_TIMEOUT_SEC` | `30` | Graceful shutdown timeout in seconds. |
|
|
|
|
### TINVEST
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `TINVEST_TOKEN` | empty | API token. Required for `sandbox`, `live_readonly`, and `live_trade`; optional in `paper`. |
|
|
| `TINVEST_ACCOUNT_ID` | empty | Broker account id. Required for API-backed modes. |
|
|
| `TINVEST_ENDPOINT` | `invest-public-api.tinkoff.ru:443` | T-Invest gRPC endpoint; sandbox mode overrides this where needed. |
|
|
| `TINVEST_APP_NAME` | `overnight-trading-bot` | Application/client name passed to the SDK. |
|
|
| `TINVEST_REQUEST_TIMEOUT_SEC` | `10` | API request timeout, including retry sequences. |
|
|
| `TINVEST_RETRY_COUNT` | `3` | Number of T-Invest SDK attempts. |
|
|
| `TINVEST_RETRY_BACKOFF_SEC` | `2` | Initial exponential backoff in seconds. |
|
|
| `TINVEST_USE_SANDBOX` | `false` | Compatibility guard; valid only with `APP_MODE=sandbox`. |
|
|
| `TINVEST_TRADING_CALENDAR_EXCHANGE` | `MOEX` | Exchange calendar used to load trading days. |
|
|
|
|
### DB
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `DB_DSN` | example DSN | MySQL/MariaDB DSN. Required outside `backtest`. Stores instruments, candles, signals, orders, positions, risk events, and reports. |
|
|
| `DB_MAX_OPEN_CONNS` | `20` | Maximum open database connections. |
|
|
| `DB_MAX_IDLE_CONNS` | `5` | Idle connection pool size. |
|
|
| `DB_CONN_MAX_LIFETIME_MIN` | `30` | Connection lifetime in minutes. |
|
|
| `DB_MIGRATIONS_AUTO_APPLY` | `true` | Apply migrations automatically at daemon startup. |
|
|
|
|
### TELEGRAM
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `TELEGRAM_BOT_TOKEN` | empty | Telegram bot token. Empty token or chat id disables notifications. |
|
|
| `TELEGRAM_CHAT_ID` | `0` | Telegram chat id; `0` disables Telegram. |
|
|
| `TELEGRAM_NOTIFY_INFO` | `true` | Send informational messages. |
|
|
| `TELEGRAM_NOTIFY_WARN` | `true` | Send warnings. |
|
|
| `TELEGRAM_NOTIFY_ALERT` | `true` | Send critical alerts. |
|
|
| `TELEGRAM_NOTIFY_REPORT` | `true` | Send daily reports. |
|
|
|
|
### STRATEGY
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `STRATEGY_ROLLING_SHORT` | `60` | Short rolling window for overnight-return statistics. |
|
|
| `STRATEGY_ROLLING_LONG` | `252` | Long rolling window for persistent edge checks and backfill depth. |
|
|
| `STRATEGY_EWMA_LAMBDA` | `0.08` | EWMA weight for fresh overnight observations. |
|
|
| `STRATEGY_ALLOCATION_METHOD` | `equal_weight` | Capital allocation method; only `equal_weight` is currently supported. |
|
|
| `STRATEGY_MIN_TSTAT_60` | `1.25` | Minimum short-window t-statistic. |
|
|
| `STRATEGY_MIN_WIN_RATE_60` | `0.55` | Minimum positive overnight observation share. |
|
|
| `STRATEGY_MIN_NET_EDGE_BPS` | `10` | Minimum expected net edge after costs. |
|
|
| `STRATEGY_RISK_BUFFER_BPS` | `5` | Extra cost buffer subtracted from expected edge. |
|
|
| `STRATEGY_EXPECTED_ENTRY_SLIPPAGE_BPS` | `8` | Expected entry slippage used in signal costs and app-level backtest config. |
|
|
| `STRATEGY_EXPECTED_EXIT_SLIPPAGE_BPS` | `8` | Expected exit slippage used in signal costs and app-level backtest config. |
|
|
| `STRATEGY_INTERVAL_VOLUME_LOOKBACK_DAYS` | `20` | Lookback for entry/exit interval volume used by participation sizing. |
|
|
| `STRATEGY_MAX_POSITIONS` | `5` | Maximum selected/open positions at signal level. |
|
|
|
|
### EXEC
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `EXEC_ENTRY_SIGNAL_TIME` | `18:10:00` | Time to prepare data and generate entry signals. |
|
|
| `EXEC_ENTRY_WINDOW_START` | `18:20:00` | Start of the entry order window. |
|
|
| `EXEC_ENTRY_WINDOW_END` | `18:38:30` | End of active entry order placement. |
|
|
| `EXEC_NO_NEW_ENTRY_AFTER` | `18:38:30` | No new entry orders after this time. |
|
|
| `EXEC_EXIT_WATCH_START` | `09:50:00` | Morning watch start before exit. |
|
|
| `EXEC_EXIT_NOT_BEFORE` | `10:03:00` | Lower bound for exit timing validation. |
|
|
| `EXEC_EXIT_WINDOW_START` | `10:05:00` | Start of exit order placement. |
|
|
| `EXEC_EXIT_WINDOW_END` | `10:25:00` | End of new exit order placement. |
|
|
| `EXEC_HARD_EXIT_DEADLINE` | `10:45:00` | Final exit deadline before reconciliation/reporting and HALT handling. |
|
|
| `EXEC_MARKET_CLOSE` | `18:50:00` | Market close reference for pre-trade time-to-close checks. |
|
|
| `EXEC_MIN_TIME_TO_CLOSE_SEC` | `90` | Minimum remaining time before close required for pre-trade checks. |
|
|
| `EXEC_ALLOW_MARKET_ORDERS` | `false` | Must remain `false`; the strategy is limit-only. |
|
|
| `EXEC_MAX_ENTRY_ORDER_ATTEMPTS` | `3` | Maximum entry repost attempts. |
|
|
| `EXEC_MAX_EXIT_ORDER_ATTEMPTS` | `3` | Maximum exit repost attempts. |
|
|
| `EXEC_PASSIVE_IMPROVE_TICKS` | `1` | Tick improvement from best bid/ask when pricing passive limits. |
|
|
| `EXEC_QUOTE_DEPTH` | `20` | Order-book depth, validated in `1..50`. |
|
|
| `EXEC_MAX_QUOTE_AGE_SEC` | `3` | Maximum acceptable quote age. |
|
|
| `EXEC_ORDER_POLL_INTERVAL_MS` | `500` | Order-status polling interval. |
|
|
|
|
### RISK
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `RISK_USE_MARGIN` | `false` | Must remain `false`; margin is disabled. |
|
|
| `RISK_ALLOW_SHORT` | `false` | Must remain `false`; short positions are disabled. |
|
|
| `RISK_MAX_TOTAL_EXPOSURE_PCT` | `0.50` | Total exposure cap as a fraction of equity. |
|
|
| `RISK_MAX_POSITION_PCT` | `0.10` | Per-position exposure cap as a fraction of equity. |
|
|
| `RISK_MAX_DAILY_LOSS_PCT` | `0.01` | Daily loss stop. |
|
|
| `RISK_MAX_WEEKLY_LOSS_PCT` | `0.03` | Weekly loss stop. |
|
|
| `RISK_MAX_MONTHLY_DRAWDOWN_PCT` | `0.07` | Monthly drawdown stop. |
|
|
| `RISK_MAX_OPEN_POSITIONS` | `5` | Risk-level open-position cap. |
|
|
| `RISK_MAX_AVG_SLIPPAGE_BPS_10_TRADES` | `15` | Blocks new orders after excessive average slippage over 10 trades. |
|
|
| `RISK_API_OUTAGE_HALT_SEC` | `180` | API/infrastructure outage duration before HALT. |
|
|
| `RISK_MAX_CLOCK_DRIFT_SEC` | `2` | Maximum local/API server time drift accepted by readiness checks. |
|
|
| `RISK_RECONCILIATION_WINDOW_HOURS` | `72` | Broker/local reconciliation window. |
|
|
| `RISK_RECONCILIATION_SKEW_SEC` | `10` | Grace period for fresh in-flight orders during reconciliation. |
|
|
| `RISK_COMMISSION_TOLERANCE_RUB` | `0.01` | Commission comparison tolerance. Non-zero broker commission still violates zero-commission policy when required. |
|
|
| `RISK_CASH_USAGE_BUFFER` | `0.95` | Fraction of available cash usable for sizing. |
|
|
| `RISK_RISK_BUDGET_PER_INSTRUMENT_PCT` | `0.005` | Per-instrument risk budget used with adverse overnight move estimates. |
|
|
| `RISK_MIN_ORDER_NOTIONAL_RUB` | `1000` | Minimum order notional. |
|
|
| `RISK_SIZE_REDUCTION_WINDOW_TRADES` | `20` | Closed-trade window for realized-vs-expected edge checks. |
|
|
| `RISK_SIZE_REDUCTION_FACTOR` | `0.5` | Sizing multiplier applied after sustained edge deterioration. |
|
|
| `RISK_SIZE_REDUCTION_TRIGGER_BPS` | `-10` | Average error threshold that triggers size reduction. |
|
|
|
|
If the average `realized_edge_bps - expected_net_edge_bps` over the configured closed-trade window is below the trigger, the scheduler emits a risk event and reduces sizing. Repeated deterioration in `live_trade` can switch the runtime mode to `live_readonly`.
|
|
|
|
### LIQ
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `LIQ_MIN_ADV_RUB` | `5000000` | Minimum 20-day average daily RUB volume. |
|
|
| `LIQ_MAX_PARTICIPATION_RATE` | `0.01` | Maximum share of entry/exit interval volume usable by the bot. |
|
|
| `LIQ_MAX_SPREAD_BPS_DEFAULT` | `20` | Default spread cap. |
|
|
| `LIQ_MAX_SPREAD_BPS_MONEY_MARKET` | `5` | Money-market fund spread cap. |
|
|
| `LIQ_MAX_SPREAD_BPS_BOND_FUNDS` | `10` | Bond fund spread cap. |
|
|
| `LIQ_MAX_SPREAD_BPS_EQUITY_FUNDS` | `25` | Equity fund spread cap. |
|
|
| `LIQ_MAX_TICK_BPS` | `10` | Maximum tick size relative to price. |
|
|
|
|
### COMM
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `COMM_REQUIRE_ZERO_COMMISSION` | `true` | Rejects signals with expected commission above zero. |
|
|
| `COMM_QUARANTINE_ON_NONZERO` | `true` | Quarantines instruments and halts on actual non-zero broker commission. |
|
|
| `COMM_FREE_ORDER_COUNT_POLICY` | `submitted` | Free-order accounting policy: `submitted` or `cancel_counts`. |
|
|
|
|
For instruments, `free_order_limit_per_day=0` means the free-order policy is not configured and new entries are blocked; `-1` means the absence of a daily free-order limit has been explicitly confirmed.
|
|
|
|
### BT
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `BT_DATE_FROM` | empty | Reserved period filter. |
|
|
| `BT_DATE_TO` | empty | Reserved period filter. |
|
|
| `BT_ENTRY_SLIPPAGE_BPS` | `8` | Backtest entry slippage. |
|
|
| `BT_EXIT_SLIPPAGE_BPS` | `8` | Backtest exit slippage. |
|
|
| `BT_COMMISSION_ROUNDTRIP_BPS` | `0` | Backtest round-trip commission. |
|
|
| `BT_USE_MINUTE_MODEL` | `false` | Enables conservative minute-candle execution modeling. |
|
|
| `BT_OUTPUT_DIR` | `./backtest_out` | Reserved output directory; the CLI currently uses `-out`. |
|
|
|
|
### LIVE
|
|
|
|
| Variable | Default | Description |
|
|
| --- | --- | --- |
|
|
| `LIVE_TRADE_ACK` | empty | Must be exactly `I_ACCEPT_RISK` for `APP_MODE=live_trade`. |
|
|
| `LIVE_READONLY_DAYS` | `0` | Must be at least `20` for `live_trade`. |
|
|
| `LIVE_PAPER_DAYS` | `0` | Must be at least `20` for `live_trade`. |
|
|
| `LIVE_SANDBOX_DAYS` | `0` | Must be at least `10` for `live_trade`. |
|
|
| `LIVE_COMMISSION_WHITELIST_CHECKED` | `false` | Must be `true` for `live_trade`. |
|
|
| `LIVE_TELEGRAM_TESTED` | `false` | Must be `true` for `live_trade`. |
|
|
| `LIVE_KILL_SWITCH_TESTED` | `false` | Must be `true` for `live_trade`. |
|
|
| `LIVE_SERVER_TIME_CHECKED` | `false` | Must be `true` for `live_trade`. |
|
|
| `LIVE_SMALL_CAPITAL` | `false` | Must be `true` for `live_trade`. |
|
|
|
|
## Commands
|
|
|
|
```sh
|
|
make fmt
|
|
make vet
|
|
make lint
|
|
make test
|
|
make race
|
|
make build
|
|
|
|
go run ./cmd/migrate -direction=up
|
|
go run ./cmd/migrate up
|
|
go run ./cmd/mode-days -check=true
|
|
|
|
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 -halt -reason="manual kill switch"
|
|
go run ./cmd/bot -unhalt -reason="manual reconciliation complete"
|
|
go run ./cmd/bot -healthcheck
|
|
```
|
|
|
|
Backtest CSV columns:
|
|
|
|
```csv
|
|
instrument_uid,trade_date,open,high,low,close,volume_lots,lot,min_price_increment
|
|
TRUR,2024-01-09,100,101,99,100.5,10000,10,0.01
|
|
```
|
|
|
|
The minute model uses the same format, but `trade_date` may be a timestamp such as `2024-01-09T18:25:00Z` or `2024-01-09 18:25:00`. The backtest CLI requires `lot` and `min_price_increment` for every `instrument_uid`; metadata may come from either the daily CSV or the minute CSV.
|
|
|
|
`cmd/mode-days` counts distinct days from `system_state_history` and checks the `live_readonly >= 20`, `paper >= 20`, and `sandbox >= 10` live-trade gates. The history table is written after migration `0010`.
|
|
|
|
`ClientOrderID` is deterministic by `(date, instrument_uid, side, attempt)`, fits the T-Invest `order_id <= 36` limit, and contains a SHA-256 suffix to suppress duplicate broker orders after restarts.
|
|
|
|
## Deploy
|
|
|
|
The Gitea workflow builds static Linux binaries for `cmd/bot`, `cmd/migrate`, and `cmd/backtest`, ships them to the target host, installs the systemd unit from `deploy/systemd/overnight-trading-bot.service`, restarts the service, and verifies health with `overnight-trading-bot -healthcheck`.
|
|
|
|
Required Gitea secrets:
|
|
|
|
| Secret | Description |
|
|
| --- | --- |
|
|
| `secrets.DEPLOY_HOST` | Target host IP or DNS name. |
|
|
| `secrets.DEPLOY_SSH_PRIVATE_KEY_BASE64` | Root deployment SSH private key encoded with `base64 -w0 < id_ed25519`. |
|
|
|
|
Before the first deployment, create the production env file on the server:
|
|
|
|
```sh
|
|
install -d -m 0750 /etc/overnight-trading-bot
|
|
install -m 0640 .env.example /etc/overnight-trading-bot/overnight-trading-bot.env
|
|
```
|
|
|
|
Then replace `DB_DSN` with the external database address and fill T-Invest/Telegram secrets. The service runs as the unprivileged `overnight-bot` user with basic systemd hardening. Logs are available through:
|
|
|
|
```sh
|
|
journalctl -u overnight-trading-bot.service
|
|
```
|
|
|
|
## Runbook
|
|
|
|
API unavailable in the morning:
|
|
|
|
1. The bot retries requests with backoff.
|
|
2. If the outage exceeds `RISK_API_OUTAGE_HALT_SEC`, the system enters `HALTED`.
|
|
3. After recovery, run reconciliation first.
|
|
4. Manual recovery uses `go run ./cmd/bot -unhalt -reason="..."`.
|
|
|
|
Position not closed before the hard deadline:
|
|
|
|
1. The scheduler cancels active sell orders and marks unresolved positions as failed exit states.
|
|
2. New entries are blocked through `HALTED` with `hard_exit_deadline_missed`.
|
|
3. Manually reconcile broker portfolio, active orders, and the local database before unhalting.
|
|
|
|
Non-zero commission:
|
|
|
|
1. Reconciliation records a critical commission mismatch.
|
|
2. The instrument is quarantined when configured.
|
|
3. The bot enters `HALTED` through the zero-commission policy.
|