Reviewed-on: #1
Overnight Trading Bot
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” (ssrn-4619084).
License: MIT.
Quick Start
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:SSand are interpreted inEurope/Moscow. - Percentages are decimal fractions:
0.10means 10%,0.005means 0.5%. bpsmeans basis points:10means 0.10%.- Boolean values are
trueorfalse. - Defaults below match
.env.exampleand 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
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:
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:
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:
journalctl -u overnight-trading-bot.service
Runbook
API unavailable in the morning:
- The bot retries requests with backoff.
- If the outage exceeds
RISK_API_OUTAGE_HALT_SEC, the system entersHALTED. - After recovery, run reconciliation first.
- Manual recovery uses
go run ./cmd/bot -unhalt -reason="...".
Position not closed before the hard deadline:
- The scheduler cancels active sell orders and marks unresolved positions as failed exit states.
- New entries are blocked through
HALTEDwithhard_exit_deadline_missed. - Manually reconcile broker portfolio, active orders, and the local database before unhalting.
Non-zero commission:
- Reconciliation records a critical commission mismatch.
- The instrument is quarantined when configured.
- The bot enters
HALTEDthrough the zero-commission policy.