4 Commits
Author SHA1 Message Date
renovate[bot] 59a667e4f0 Update actions/setup-go action to v7
Deploy / Test, build and deploy (push) Failing after 1m42s
2026-07-17 00:01:44 +00:00
renovate[bot] 8e0bb259d2 Update module google.golang.org/grpc to v1.82.1
Deploy / Test, build and deploy (push) Failing after 1m18s
2026-07-16 00:15:29 +00:00
Valentin Popov a7f3f65074 chore(deps): update go module dependencies
Deploy / Test, build and deploy (push) Failing after 1m14s
2026-07-08 01:01:16 +00:00
Valentin Popov b76fd546fe fix(telegram): add request timeout for notifications
Deploy / Test, build and deploy (push) Failing after 6m3s
2026-07-08 04:59:24 +04:00
11 changed files with 249 additions and 57 deletions
+2
View File
@@ -26,6 +26,8 @@ TELEGRAM_NOTIFY_INFO=true
TELEGRAM_NOTIFY_WARN=true TELEGRAM_NOTIFY_WARN=true
TELEGRAM_NOTIFY_ALERT=true TELEGRAM_NOTIFY_ALERT=true
TELEGRAM_NOTIFY_REPORT=true TELEGRAM_NOTIFY_REPORT=true
TELEGRAM_REQUEST_TIMEOUT_SEC=10
TELEGRAM_QUEUE_SIZE=256
STRATEGY_ROLLING_SHORT=60 STRATEGY_ROLLING_SHORT=60
STRATEGY_ROLLING_LONG=252 STRATEGY_ROLLING_LONG=252
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
+2
View File
@@ -86,6 +86,8 @@ Common formats:
| `TELEGRAM_NOTIFY_WARN` | `true` | Send warnings. | | `TELEGRAM_NOTIFY_WARN` | `true` | Send warnings. |
| `TELEGRAM_NOTIFY_ALERT` | `true` | Send critical alerts. | | `TELEGRAM_NOTIFY_ALERT` | `true` | Send critical alerts. |
| `TELEGRAM_NOTIFY_REPORT` | `true` | Send daily reports. | | `TELEGRAM_NOTIFY_REPORT` | `true` | Send daily reports. |
| `TELEGRAM_REQUEST_TIMEOUT_SEC` | `10` | HTTP timeout for Telegram API calls, including the startup `getMe` check and message sends. |
| `TELEGRAM_QUEUE_SIZE` | `256` | In-memory Telegram notification queue size. The queue smooths transient Telegram slowness but is not persisted across process restarts. |
### STRATEGY ### STRATEGY
+2
View File
@@ -77,6 +77,8 @@ APP_MODE=backtest go run ./cmd/bot
| `TELEGRAM_NOTIFY_WARN` | `true` или `false` | `true` | boolean | Включает предупреждения. | | `TELEGRAM_NOTIFY_WARN` | `true` или `false` | `true` | boolean | Включает предупреждения. |
| `TELEGRAM_NOTIFY_ALERT` | `true` или `false` | `true` | boolean | Включает alert-сообщения. Они считаются критичными для доставки и ждут место в очереди. | | `TELEGRAM_NOTIFY_ALERT` | `true` или `false` | `true` | boolean | Включает alert-сообщения. Они считаются критичными для доставки и ждут место в очереди. |
| `TELEGRAM_NOTIFY_REPORT` | `true` или `false` | `true` | boolean | Включает дневные отчёты. | | `TELEGRAM_NOTIFY_REPORT` | `true` или `false` | `true` | boolean | Включает дневные отчёты. |
| `TELEGRAM_REQUEST_TIMEOUT_SEC` | целое число секунд | `10` | должно быть `> 0` | Таймаут HTTP-запросов к Telegram API, включая стартовую проверку `getMe` и отправку сообщений. |
| `TELEGRAM_QUEUE_SIZE` | целое число | `256` | должно быть `> 0` | Размер in-memory очереди Telegram-уведомлений. Очередь сглаживает временную медленную доставку, но не сохраняется между рестартами процесса. |
### STRATEGY ### STRATEGY
+4 -4
View File
@@ -13,7 +13,7 @@ require (
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/testcontainers/testcontainers-go v0.43.0 github.com/testcontainers/testcontainers-go v0.43.0
github.com/testcontainers/testcontainers-go/modules/mariadb v0.43.0 github.com/testcontainers/testcontainers-go/modules/mariadb v0.43.0
google.golang.org/grpc v1.82.0 google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
) )
@@ -71,8 +71,8 @@ require (
golang.org/x/net v0.56.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect golang.org/x/text v0.39.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+8 -17
View File
@@ -15,7 +15,6 @@ github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSw
github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= 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 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v6 v6.0.1/go.mod h1:5WCmPelT2zwAaNETjGJVKHDnZvjQdPsGeHHwm5lIPPI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/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 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -91,8 +90,6 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 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 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= 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/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
@@ -123,8 +120,6 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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 h1:EZ9mA5fTlyspH8urdAMFXTzfUnMcVbCI3O0C88CrkUo=
github.com/russianinvestments/invest-api-go-sdk v1.40.1/go.mod h1:rOu2P3GMTQEkQxRpQfp+wK5k71c3SUDHIke3Ijr8cOU= 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/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
@@ -135,12 +130,8 @@ 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/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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A= github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo= github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
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/testcontainers/testcontainers-go/modules/mariadb v0.43.0 h1:cvZGnhieICwBODSeoPlqdpNrQpnFA8n0L5/4E591Az4= github.com/testcontainers/testcontainers-go/modules/mariadb v0.43.0 h1:cvZGnhieICwBODSeoPlqdpNrQpnFA8n0L5/4E591Az4=
github.com/testcontainers/testcontainers-go/modules/mariadb v0.43.0/go.mod h1:GIuI5uvfhjP44sScfPruPjundARJx1RwnzTBwGEI4qY= github.com/testcontainers/testcontainers-go/modules/mariadb v0.43.0/go.mod h1:GIuI5uvfhjP44sScfPruPjundARJx1RwnzTBwGEI4qY=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
@@ -176,19 +167,19 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM= google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 h1:admdQBe8jR3VWhBsUrAOaF2Qw6K/+p5pSm1GN8+6Fw4=
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800/go.mod h1:FPk7EXUKMtImne7AmknoYjT4QXqKIzzRbeQIXzLk6fQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A= google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+9 -7
View File
@@ -172,13 +172,15 @@ func Run(ctx context.Context, opts Options) error {
return err return err
} }
notifier, err := notify.NewTelegram(notify.TelegramConfig{ notifier, err := notify.NewTelegram(notify.TelegramConfig{
BotToken: cfg.Telegram.BotToken, BotToken: cfg.Telegram.BotToken,
ChatID: cfg.Telegram.ChatID, ChatID: cfg.Telegram.ChatID,
NotifyInfo: cfg.Telegram.NotifyInfo, NotifyInfo: cfg.Telegram.NotifyInfo,
NotifyWarn: cfg.Telegram.NotifyWarn, NotifyWarn: cfg.Telegram.NotifyWarn,
NotifyAlert: cfg.Telegram.NotifyAlert, NotifyAlert: cfg.Telegram.NotifyAlert,
NotifyReport: cfg.Telegram.NotifyReport, NotifyReport: cfg.Telegram.NotifyReport,
AuditSink: repo, RequestTimeout: time.Duration(cfg.Telegram.RequestTimeoutSec) * time.Second,
QueueSize: cfg.Telegram.QueueSize,
AuditSink: repo,
}, log) }, log)
if err != nil { if err != nil {
return fmt.Errorf("create notifier: %w", err) return fmt.Errorf("create notifier: %w", err)
+14 -6
View File
@@ -66,12 +66,14 @@ type DBConfig struct {
} }
type TelegramConfig struct { type TelegramConfig struct {
BotToken string `env:"BOT_TOKEN"` BotToken string `env:"BOT_TOKEN"`
ChatID int64 `env:"CHAT_ID"` ChatID int64 `env:"CHAT_ID"`
NotifyInfo bool `env:"NOTIFY_INFO" envDefault:"true"` NotifyInfo bool `env:"NOTIFY_INFO" envDefault:"true"`
NotifyWarn bool `env:"NOTIFY_WARN" envDefault:"true"` NotifyWarn bool `env:"NOTIFY_WARN" envDefault:"true"`
NotifyAlert bool `env:"NOTIFY_ALERT" envDefault:"true"` NotifyAlert bool `env:"NOTIFY_ALERT" envDefault:"true"`
NotifyReport bool `env:"NOTIFY_REPORT" envDefault:"true"` NotifyReport bool `env:"NOTIFY_REPORT" envDefault:"true"`
RequestTimeoutSec int `env:"REQUEST_TIMEOUT_SEC" envDefault:"10"`
QueueSize int `env:"QUEUE_SIZE" envDefault:"256"`
} }
type StrategyConfig struct { type StrategyConfig struct {
@@ -201,6 +203,12 @@ func (c *Config) Validate() error {
if c.TInvest.RequestTimeoutSec <= 0 { if c.TInvest.RequestTimeoutSec <= 0 {
return errors.New("TINVEST_REQUEST_TIMEOUT_SEC must be positive") return errors.New("TINVEST_REQUEST_TIMEOUT_SEC must be positive")
} }
if c.Telegram.RequestTimeoutSec <= 0 {
return errors.New("TELEGRAM_REQUEST_TIMEOUT_SEC must be positive")
}
if c.Telegram.QueueSize <= 0 {
return errors.New("TELEGRAM_QUEUE_SIZE must be positive")
}
if c.TInvest.TradingCalendarExchange == "" { if c.TInvest.TradingCalendarExchange == "" {
c.TInvest.TradingCalendarExchange = "MOEX" c.TInvest.TradingCalendarExchange = "MOEX"
} }
+37
View File
@@ -86,6 +86,39 @@ func TestLoadSchedulerKnobsFromEnv(t *testing.T) {
} }
} }
func TestLoadTelegramQueueDefaults(t *testing.T) {
t.Setenv("APP_MODE", "backtest")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.Telegram.RequestTimeoutSec != 10 {
t.Fatalf("telegram request timeout=%d, want 10", cfg.Telegram.RequestTimeoutSec)
}
if cfg.Telegram.QueueSize != 256 {
t.Fatalf("telegram queue size=%d, want 256", cfg.Telegram.QueueSize)
}
}
func TestValidateRejectsInvalidTelegramRequestTimeout(t *testing.T) {
cfg := minimalBrokerConfig(domain.ModeSandbox)
cfg.Telegram.RequestTimeoutSec = 0
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "TELEGRAM_REQUEST_TIMEOUT_SEC") {
t.Fatalf("Validate err=%v, want TELEGRAM_REQUEST_TIMEOUT_SEC requirement", err)
}
}
func TestValidateRejectsInvalidTelegramQueueSize(t *testing.T) {
cfg := minimalBrokerConfig(domain.ModeSandbox)
cfg.Telegram.QueueSize = 0
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "TELEGRAM_QUEUE_SIZE") {
t.Fatalf("Validate err=%v, want TELEGRAM_QUEUE_SIZE requirement", err)
}
}
func minimalBrokerConfig(mode domain.Mode) Config { func minimalBrokerConfig(mode domain.Mode) Config {
return Config{ return Config{
App: AppConfig{ App: AppConfig{
@@ -99,6 +132,10 @@ func minimalBrokerConfig(mode domain.Mode) Config {
RequestTimeoutSec: 10, RequestTimeoutSec: 10,
}, },
DB: DBConfig{DSN: "user:pass@tcp(localhost:3306)/bot"}, DB: DBConfig{DSN: "user:pass@tcp(localhost:3306)/bot"},
Telegram: TelegramConfig{
RequestTimeoutSec: 10,
QueueSize: 256,
},
Execution: ExecutionConfig{ Execution: ExecutionConfig{
EntrySignalTime: mustTOD("18:10:00"), EntrySignalTime: mustTOD("18:10:00"),
EntryWindowStart: mustTOD("18:20:00"), EntryWindowStart: mustTOD("18:20:00"),
+41 -22
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"strings" "strings"
"time" "time"
@@ -13,7 +14,11 @@ import (
"overnight-trading-bot/internal/domain" "overnight-trading-bot/internal/domain"
) )
const mustDeliverEnqueueTimeout = 2 * time.Second const (
defaultQueueSize = 256
defaultRequestTimeout = 10 * time.Second
mustDeliverEnqueueTimeout = 2 * time.Second
)
type Notifier interface { type Notifier interface {
Info(ctx context.Context, msg string) error Info(ctx context.Context, msg string) error
@@ -32,13 +37,15 @@ func (Noop) Report(context.Context, string) error { return nil }
func (Noop) Close() error { return nil } func (Noop) Close() error { return nil }
type TelegramConfig struct { type TelegramConfig struct {
BotToken string BotToken string
ChatID int64 ChatID int64
NotifyInfo bool NotifyInfo bool
NotifyWarn bool NotifyWarn bool
NotifyAlert bool NotifyAlert bool
NotifyReport bool NotifyReport bool
AuditSink AuditSink RequestTimeout time.Duration
QueueSize int
AuditSink AuditSink
} }
type AuditSink interface { type AuditSink interface {
@@ -46,12 +53,13 @@ type AuditSink interface {
} }
type Telegram struct { type Telegram struct {
cfg TelegramConfig cfg TelegramConfig
bot *tgbotapi.BotAPI bot *tgbotapi.BotAPI
log *slog.Logger log *slog.Logger
queue chan outbound queue chan outbound
done chan struct{} done chan struct{}
closed chan struct{} closed chan struct{}
retryDelay func(error, int) time.Duration
} }
type outbound struct { type outbound struct {
@@ -61,23 +69,34 @@ type outbound struct {
} }
func NewTelegram(cfg TelegramConfig, log *slog.Logger) (Notifier, error) { func NewTelegram(cfg TelegramConfig, log *slog.Logger) (Notifier, error) {
return newTelegramWithEndpoint(cfg, log, tgbotapi.APIEndpoint)
}
func newTelegramWithEndpoint(cfg TelegramConfig, log *slog.Logger, apiEndpoint string) (Notifier, error) {
if cfg.BotToken == "" || cfg.ChatID == 0 { if cfg.BotToken == "" || cfg.ChatID == 0 {
if log != nil { if log != nil {
log.Warn("telegram notifier disabled; TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is empty") log.Warn("telegram notifier disabled; TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is empty")
} }
return Noop{}, nil return Noop{}, nil
} }
bot, err := tgbotapi.NewBotAPI(cfg.BotToken) if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = defaultRequestTimeout
}
if cfg.QueueSize <= 0 {
cfg.QueueSize = defaultQueueSize
}
bot, err := tgbotapi.NewBotAPIWithClient(cfg.BotToken, apiEndpoint, &http.Client{Timeout: cfg.RequestTimeout})
if err != nil { if err != nil {
return nil, err return nil, err
} }
t := &Telegram{ t := &Telegram{
cfg: cfg, cfg: cfg,
bot: bot, bot: bot,
log: log, log: log,
queue: make(chan outbound, 256), queue: make(chan outbound, cfg.QueueSize),
done: make(chan struct{}), done: make(chan struct{}),
closed: make(chan struct{}), closed: make(chan struct{}),
retryDelay: telegramRetryDelay,
} }
go t.dispatch() go t.dispatch()
return t, nil return t, nil
@@ -181,7 +200,7 @@ func (t *Telegram) send(item outbound) {
for attempt := 0; attempt < 3; attempt++ { for attempt := 0; attempt < 3; attempt++ {
if _, err := t.bot.Send(msg); err != nil { if _, err := t.bot.Send(msg); err != nil {
lastErr = err lastErr = err
delay := telegramRetryDelay(err, attempt) delay := t.retryDelay(err, attempt)
if t.log != nil { if t.log != nil {
t.log.Warn("telegram send failed", "attempt", attempt+1, "err", err, "retry_in", delay) t.log.Warn("telegram send failed", "attempt", attempt+1, "err", err, "retry_in", delay)
} }
+129
View File
@@ -1,6 +1,10 @@
package notify package notify
import ( import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
@@ -44,6 +48,131 @@ func TestTelegramRetryDelayUsesRetryAfter(t *testing.T) {
} }
} }
func TestNewTelegramUsesRequestTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
}))
defer server.Close()
start := time.Now()
_, err := newTelegramWithEndpoint(TelegramConfig{
BotToken: "token",
ChatID: 123,
RequestTimeout: 20 * time.Millisecond,
QueueSize: 1,
}, nil, telegramTestEndpoint(server))
elapsed := time.Since(start)
if err == nil {
t.Fatal("NewTelegram err=nil, want timeout")
}
if elapsed > 150*time.Millisecond {
t.Fatalf("NewTelegram took %s, want request timeout to fire quickly", elapsed)
}
}
func TestNewTelegramUsesConfiguredQueueSize(t *testing.T) {
server := telegramTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeTelegramOK(w, r)
})
defer server.Close()
notifier, err := newTelegramWithEndpoint(TelegramConfig{
BotToken: "token",
ChatID: 123,
RequestTimeout: time.Second,
QueueSize: 3,
}, nil, telegramTestEndpoint(server))
if err != nil {
t.Fatal(err)
}
defer notifier.Close()
telegram, ok := notifier.(*Telegram)
if !ok {
t.Fatalf("notifier type=%T, want *Telegram", notifier)
}
if got := cap(telegram.queue); got != 3 {
t.Fatalf("queue cap=%d, want 3", got)
}
}
func TestTelegramSendTimeoutAuditsMustDeliverFailure(t *testing.T) {
audit := &recordingAuditSink{events: make(chan domain.RiskEvent, 1)}
server := telegramTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/sendMessage") {
time.Sleep(50 * time.Millisecond)
writeTelegramOK(w, r)
return
}
writeTelegramOK(w, r)
})
defer server.Close()
notifier, err := newTelegramWithEndpoint(TelegramConfig{
BotToken: "token",
ChatID: 123,
NotifyAlert: true,
RequestTimeout: 10 * time.Millisecond,
QueueSize: 1,
AuditSink: audit,
}, nil, telegramTestEndpoint(server))
if err != nil {
t.Fatal(err)
}
telegram := notifier.(*Telegram)
telegram.retryDelay = func(error, int) time.Duration { return time.Millisecond }
defer notifier.Close()
if err := notifier.Alert(context.Background(), "critical"); err != nil {
t.Fatal(err)
}
select {
case event := <-audit.events:
if event.EventType != "notification_undeliverable" {
t.Fatalf("event type=%s, want notification_undeliverable", event.EventType)
}
if event.Severity != domain.SeverityCritical {
t.Fatalf("event severity=%s, want critical", event.Severity)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for must-deliver audit event")
}
}
type assertErr struct{} type assertErr struct{}
func (assertErr) Error() string { return "boom" } func (assertErr) Error() string { return "boom" }
type recordingAuditSink struct {
events chan domain.RiskEvent
}
func (s *recordingAuditSink) InsertRiskEvent(ctx context.Context, event domain.RiskEvent) error {
select {
case s.events <- event:
case <-ctx.Done():
return ctx.Err()
}
return nil
}
func telegramTestEndpoint(server *httptest.Server) string {
return server.URL + "/bot%s/%s"
}
func telegramTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func writeTelegramOK(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/getMe"):
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"test","username":"test_bot"}}`))
default:
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":123,"type":"private"},"text":"ok"}}`))
}
}