Compare commits

..

39 Commits

Author SHA1 Message Date
Codex 7f1ac694e4 Improve Windows training agent progress 2026-07-03 20:44:09 +03:00
Codex 33c3831bf2 Require minimum net profit for forecast exits 2026-07-02 15:35:29 +03:00
Codex f0c70e2e1f Run Windows training agent without console 2026-07-01 13:43:41 +03:00
Codex 4a4039c03d Improve Android retrain start feedback 2026-06-30 21:37:08 +03:00
Codex 2e2978d7d9 Fix Android retrain status label 2026-06-30 20:07:00 +03:00
Codex 6c92c6e955 Avoid fee-negative ATR exits 2026-06-30 14:40:26 +03:00
Codex 56701d02f1 Prevent torch forecast fee churn exits 2026-06-30 14:37:00 +03:00
Codex 0cfd89980e Clarify transient training progress upload errors 2026-06-29 21:21:09 +03:00
Codex 9f47c04235 Show realized trade PnL in Android app 2026-06-29 21:06:51 +03:00
Codex fe276d95ff Keep Windows training agent alive 2026-06-29 20:05:58 +03:00
Codex 710312ba8a Set paper exposure limit to 100 USDT 2026-06-29 16:49:34 +03:00
Codex 4bc8a4c4a0 Allow disabling stop loss exits 2026-06-29 16:39:36 +03:00
Codex d58e20aa4d Improve training agent progress reporting 2026-06-27 17:52:49 +03:00
Codex 57d9514eec Make symbol risk guard configurable 2026-06-27 17:28:52 +03:00
Codex 7d84bf38cc Allow exchange-minimum Kelly layers 2026-06-27 16:00:31 +03:00
Codex 07d132632d Fix exchange minimum sizing for buys 2026-06-27 15:41:08 +03:00
Codex baf307041a Clarify retrain gate status in Android app 2026-06-27 12:42:38 +03:00
Codex 83bffadc0a Fix training agent heartbeat status 2026-06-27 11:02:41 +03:00
Codex b6d616ec2a Show retrain progress in Android 2026-06-27 09:18:18 +03:00
Codex f8611a1c3f Add global Windows training agent queue 2026-06-27 08:56:54 +03:00
Codex 98d6277d65 Fix Android refresh flicker and pin training host 2026-06-27 08:38:45 +03:00
Codex f1f1cbb1e0 Handle Android API authorization setup 2026-06-27 08:12:54 +03:00
Codex ab2557ea26 Ship Android AI console and remove web UI 2026-06-26 22:56:42 +03:00
Codex 4e811daaf2 Fix Android monitor scroll stability 2026-06-26 22:21:24 +03:00
Codex 9c983ac0bb Point Android monitor to public bot API 2026-06-26 21:52:17 +03:00
Codex 2706242fc7 Add Android TradeBot monitor app 2026-06-26 21:09:44 +03:00
Codex 93c51070c5 Redesign Russian trading dashboard 2026-06-26 20:42:01 +03:00
Codex 61a063cda7 Use Kelly allocation for Torch position scaling 2026-06-26 20:20:23 +03:00
Курнат Андрей 87cb7e8fe3 Train Torch model for 12 spot pairs 2026-06-25 22:39:25 +03:00
Codex 27205af73e Accept Torch candidate passing honest gate 2026-06-25 08:13:40 +03:00
Codex 4a24419a30 Add honest Torch validation gate 2026-06-25 06:19:14 +03:00
Codex bb3b4070f6 Prefer replay-qualified Torch calibration 2026-06-24 23:52:46 +03:00
Курнат Андрей cb8efb7cd7 Add Torch probe entries and Pi artifact sync 2026-06-24 21:31:05 +03:00
Codex 15a50fb575 Lower Torch confidence threshold 2026-06-24 05:51:55 +03:00
Codex a6c94b1555 Make risk guard symbol aware 2026-06-24 05:30:12 +03:00
Курнат Андрей 4112a17bc5 Add local runtime artifacts 2026-06-23 22:20:13 +03:00
Codex 4d02ff3806 Add analytics risk guard and redesigned dashboard 2026-06-23 17:20:44 +03:00
Codex 13de641fe3 Calibrate Torch forecast thresholds 2026-06-23 16:35:24 +03:00
Codex 12f470e0a3 Explain Torch forecast inputs in dashboard 2026-06-23 07:44:15 +03:00
70 changed files with 2340322 additions and 947 deletions
+93
View File
@@ -0,0 +1,93 @@
TRADING_MODE=paper
HOST=127.0.0.1
PORT=8787
BYBIT_TESTNET=false
BYBIT_API_KEY=
BYBIT_API_SECRET=
STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false
TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60
KLINE_LIMIT=240
TREND_INTERVAL=D
TREND_KLINE_LIMIT=260
LOOP_INTERVAL_SECONDS=5
FAST_TRADING_ENABLED=false
FAST_LOOP_INTERVAL_SECONDS=1
FAST_ENTRY_COOLDOWN_SECONDS=20
MAX_ENTRIES_PER_MINUTE=12
WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64
MAX_SPREAD_PERCENT=0.18
MIN_24H_TURNOVER_USDT=1000000
PATTERN_ANALYSIS_ENABLED=true
PATTERN_SCORE_WEIGHT=0.18
LEARNING_ENABLED=true
LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12
LEARNING_MAX_POSITION_MULTIPLIER=1.6
MIN_POSITION_USDT=1
MAX_POSITION_USDT=8
MAX_SYMBOL_EXPOSURE_USDT=25
MAX_TOTAL_EXPOSURE_USDT=100
MAX_OPEN_POSITIONS=24
MAX_POSITIONS_PER_SYMBOL=6
GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8
REBOUND_TRADING_ENABLED=true
REBOUND_ENTRY_CONFIDENCE=0.55
REBOUND_MIN_PROBABILITY=0.55
REBOUND_MAX_POSITION_USDT=6
KELLY_SIZING_ENABLED=true
KELLY_FRACTION=0.25
KELLY_MAX_FRACTION=0.20
RISK_PER_TRADE_PERCENT=0.01
RISK_GUARD_ENABLED=true
RISK_SYMBOL_GUARD_ENABLED=false
RISK_RECENT_TRADE_WINDOW=20
RISK_MAX_CONSECUTIVE_LOSSES=4
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
RISK_REDUCE_MULTIPLIER=1.0
ATR_TRAILING_MULTIPLIER=2.2
TREND_RSI_MIN=45
TREND_RSI_MAX=65
TIME_SERIES_FORECAST_ENABLED=true
TIME_SERIES_MIN_CANDLES=120
TIME_SERIES_FORECAST_HORIZON=3
TIME_SERIES_MIN_EDGE_PERCENT=0.10
TIME_SERIES_MIN_PROBABILITY_UP=0.47
TIME_SERIES_MIN_CONFIDENCE=0.4
TIME_SERIES_MAX_ADJUSTMENT=0.08
TIME_SERIES_LSTM_ENABLED=true
TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json
TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
STOP_LOSS_PERCENT=0.04
STOP_LOSS_EXIT_ENABLED=false
TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015
MIN_HOLD_SECONDS=180
ENTRY_COOLDOWN_SECONDS=180
MAX_DAILY_DRAWDOWN_USDT=6
MIN_CASH_RESERVE_USDT=5
TAKER_FEE_RATE=0.001
SLIPPAGE_RATE=0.0003
# Real trading is locked unless all three values are set explicitly.
ENABLE_LIVE_TRADING=false
LIVE_TRADING_CONFIRM=
LIVE_ORDER_MAX_USDT=10
DATABASE_PATH=runtime/tradebot.sqlite3
LOG_PATH=runtime/tradebot.log
+24 -12
View File
@@ -8,8 +8,8 @@ BYBIT_API_SECRET=
STARTING_BALANCE_USDT=100 STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false AUTO_SELECT_SYMBOLS=false
TOP_SYMBOLS_COUNT=4 TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
STRATEGY_MODE=torch_forecast STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60 BASE_INTERVAL=60
@@ -25,41 +25,53 @@ WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64 MIN_SIGNAL_CONFIDENCE=0.64
MAX_SPREAD_PERCENT=0.18 MAX_SPREAD_PERCENT=0.18
MIN_24H_TURNOVER_USDT=1000000 MIN_24H_TURNOVER_USDT=1000000
PATTERN_ANALYSIS_ENABLED=false PATTERN_ANALYSIS_ENABLED=true
PATTERN_SCORE_WEIGHT=0.18 PATTERN_SCORE_WEIGHT=0.18
LEARNING_ENABLED=false LEARNING_ENABLED=true
LEARNING_LOOKBACK_TRADES=120 LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3 LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12 LEARNING_MAX_ADJUSTMENT=0.12
LEARNING_MAX_POSITION_MULTIPLIER=1.6 LEARNING_MAX_POSITION_MULTIPLIER=1.6
MIN_POSITION_USDT=1 MIN_POSITION_USDT=1
MAX_POSITION_USDT=25 MAX_POSITION_USDT=8
MAX_SYMBOL_EXPOSURE_USDT=25 MAX_SYMBOL_EXPOSURE_USDT=25
MAX_TOTAL_EXPOSURE_USDT=75 MAX_TOTAL_EXPOSURE_USDT=75
MAX_OPEN_POSITIONS=4 MAX_OPEN_POSITIONS=24
MAX_POSITIONS_PER_SYMBOL=1 MAX_POSITIONS_PER_SYMBOL=6
GRID_TRADING_ENABLED=false GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58 GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45 GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8 GRID_MAX_POSITION_USDT=8
REBOUND_TRADING_ENABLED=false REBOUND_TRADING_ENABLED=true
REBOUND_ENTRY_CONFIDENCE=0.58 REBOUND_ENTRY_CONFIDENCE=0.55
REBOUND_MIN_PROBABILITY=0.58 REBOUND_MIN_PROBABILITY=0.55
REBOUND_MAX_POSITION_USDT=6 REBOUND_MAX_POSITION_USDT=6
KELLY_SIZING_ENABLED=false KELLY_SIZING_ENABLED=true
KELLY_FRACTION=0.25 KELLY_FRACTION=0.25
KELLY_MAX_FRACTION=0.20 KELLY_MAX_FRACTION=0.20
RISK_PER_TRADE_PERCENT=0.01 RISK_PER_TRADE_PERCENT=0.01
RISK_GUARD_ENABLED=true
RISK_RECENT_TRADE_WINDOW=20
RISK_MAX_CONSECUTIVE_LOSSES=4
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
RISK_REDUCE_MULTIPLIER=0.50
ATR_TRAILING_MULTIPLIER=2.2 ATR_TRAILING_MULTIPLIER=2.2
TREND_RSI_MIN=45 TREND_RSI_MIN=45
TREND_RSI_MAX=65 TREND_RSI_MAX=65
TIME_SERIES_FORECAST_ENABLED=true TIME_SERIES_FORECAST_ENABLED=true
TIME_SERIES_MIN_CANDLES=120 TIME_SERIES_MIN_CANDLES=120
TIME_SERIES_FORECAST_HORIZON=3 TIME_SERIES_FORECAST_HORIZON=3
TIME_SERIES_MIN_EDGE_PERCENT=0.04 TIME_SERIES_MIN_EDGE_PERCENT=0.10
TIME_SERIES_MIN_PROBABILITY_UP=0.47
TIME_SERIES_MIN_CONFIDENCE=0.4
TIME_SERIES_MAX_ADJUSTMENT=0.08 TIME_SERIES_MAX_ADJUSTMENT=0.08
TIME_SERIES_LSTM_ENABLED=true TIME_SERIES_LSTM_ENABLED=true
TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json
TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
STOP_LOSS_PERCENT=0.04 STOP_LOSS_PERCENT=0.04
TAKE_PROFIT_PERCENT=0.035 TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015 TRAILING_STOP_PERCENT=0.015
+4
View File
@@ -7,3 +7,7 @@ venv/
.env .env
runtime/ runtime/
*.log *.log
android/**/.gradle/
android/**/build/
android/**/*.apk
android/**/*.aab
+42 -14
View File
@@ -5,7 +5,7 @@ Spot-бот для демо-торговли криптовалютой на р
## Что реализовано ## Что реализовано
- Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления. - Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления.
- Фиксированный набор USDT spot-пар для основной стратегии: `BTCUSDT`, `ETHUSDT`, `SOLUSDT`, `LTCUSDT`. - Фиксированный набор 12 USDT spot-пар для основной стратегии: `BTCUSDT`, `ETHUSDT`, `HYPEUSDT`, `SOLUSDT`, `XRPUSDT`, `XPLUSDT`, `WLDUSDT`, `MNTUSDT`, `HUSDT`, `XAUTUSDT`, `IPUSDT`, `AAVEUSDT`.
- Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop. - Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop.
- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча. - Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча.
- Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`. - Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`.
@@ -18,6 +18,7 @@ Spot-бот для демо-торговли криптовалютой на р
- Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`. - Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`.
- Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются. - Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются.
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения. - Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения.
- Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для просмотра 12 пар, свечей, Torch/Kelly параметров, расписания удалённого retrain и live-чеклиста.
- SQLite runtime-хранилище в `runtime/tradebot.sqlite3`. - SQLite runtime-хранилище в `runtime/tradebot.sqlite3`.
- Health endpoint `/api/health` и Prometheus-compatible `/metrics`. - Health endpoint `/api/health` и Prometheus-compatible `/metrics`.
- Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост. - Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост.
@@ -88,7 +89,21 @@ powershell -ExecutionPolicy Bypass -File tools\run_torch_retrain.ps1
powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.ps1 powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.ps1
``` ```
По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 3000` на парах `BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`. Для удалённого запуска с телефона или с бота используется Windows training agent. Бот на `tb.kusoft.xyz` хранит очередь заданий, а Windows-машина сама подключается к интернету, забирает задания, обучает модель и загружает артефакты обратно:
```powershell
powershell -ExecutionPolicy Bypass -File tools\install_windows_training_agent.ps1 -ApiAuth "login:password" -StartNow
```
Установщик регистрирует Scheduled Task `TradeBot Windows Training Agent` при входе в Windows и удаляет старые локальные retrain-задачи, чтобы обучение запускалось через очередь, а не двумя независимыми механизмами.
По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 3000` на 12 spot-парах из `SYMBOLS`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_SEED`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`.
Если retrain запускается с `-DeployToPi`, после успешного guard он синхронизирует `runtime/lstm_forecaster.json`, `runtime/torch_retrain_guard.json` и `runtime/torch_threshold_calibration.json` на Raspberry Pi через SSH-ключ и перезапускает сервис `tradebot`. Отдельный запуск sync:
```powershell
powershell -ExecutionPolicy Bypass -File tools\sync_torch_artifacts_to_pi.ps1 -RemoteHost 192.168.0.185 -RemoteUser sevenhill -RemoteRoot /mnt/data/tradebot
```
Внутри recurrent модели используются exportable attention pooling и LayerNorm перед forecast-head; Raspberry Pi по-прежнему исполняет модель из JSON без PyTorch runtime. Внутри recurrent модели используются exportable attention pooling и LayerNorm перед forecast-head; Raspberry Pi по-прежнему исполняет модель из JSON без PyTorch runtime.
@@ -110,8 +125,8 @@ Dashboard: `http://<host>:8787/`
TRADING_MODE=paper TRADING_MODE=paper
STARTING_BALANCE_USDT=100 STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false AUTO_SELECT_SYMBOLS=false
TOP_SYMBOLS_COUNT=4 TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
STRATEGY_MODE=torch_forecast STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60 BASE_INTERVAL=60
TREND_INTERVAL=D TREND_INTERVAL=D
@@ -123,41 +138,54 @@ FAST_ENTRY_COOLDOWN_SECONDS=20
MAX_ENTRIES_PER_MINUTE=12 MAX_ENTRIES_PER_MINUTE=12
WEBSOCKET_ENABLED=true WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64 MIN_SIGNAL_CONFIDENCE=0.64
PATTERN_ANALYSIS_ENABLED=false PATTERN_ANALYSIS_ENABLED=true
PATTERN_SCORE_WEIGHT=0.18 PATTERN_SCORE_WEIGHT=0.18
LEARNING_ENABLED=false LEARNING_ENABLED=true
LEARNING_LOOKBACK_TRADES=120 LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3 LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12 LEARNING_MAX_ADJUSTMENT=0.12
LEARNING_MAX_POSITION_MULTIPLIER=1.6 LEARNING_MAX_POSITION_MULTIPLIER=1.6
MIN_POSITION_USDT=1 MIN_POSITION_USDT=1
MAX_POSITION_USDT=25 MAX_POSITION_USDT=8
MAX_SYMBOL_EXPOSURE_USDT=25 MAX_SYMBOL_EXPOSURE_USDT=25
MAX_TOTAL_EXPOSURE_USDT=75 MAX_TOTAL_EXPOSURE_USDT=75
MAX_OPEN_POSITIONS=4 MAX_OPEN_POSITIONS=24
MAX_POSITIONS_PER_SYMBOL=1 MAX_POSITIONS_PER_SYMBOL=6
GRID_TRADING_ENABLED=false GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58 GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45 GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8 GRID_MAX_POSITION_USDT=8
REBOUND_TRADING_ENABLED=false REBOUND_TRADING_ENABLED=true
REBOUND_ENTRY_CONFIDENCE=0.58 REBOUND_ENTRY_CONFIDENCE=0.55
REBOUND_MIN_PROBABILITY=0.58 REBOUND_MIN_PROBABILITY=0.55
REBOUND_MAX_POSITION_USDT=6 REBOUND_MAX_POSITION_USDT=6
KELLY_SIZING_ENABLED=false KELLY_SIZING_ENABLED=true
KELLY_FRACTION=0.25 KELLY_FRACTION=0.25
KELLY_MAX_FRACTION=0.20 KELLY_MAX_FRACTION=0.20
RISK_PER_TRADE_PERCENT=0.01 RISK_PER_TRADE_PERCENT=0.01
RISK_GUARD_ENABLED=true
RISK_SYMBOL_GUARD_ENABLED=false
RISK_RECENT_TRADE_WINDOW=20
RISK_MAX_CONSECUTIVE_LOSSES=4
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
RISK_REDUCE_MULTIPLIER=0.50
ATR_TRAILING_MULTIPLIER=2.2 ATR_TRAILING_MULTIPLIER=2.2
TREND_RSI_MIN=45 TREND_RSI_MIN=45
TREND_RSI_MAX=65 TREND_RSI_MAX=65
TIME_SERIES_FORECAST_ENABLED=true TIME_SERIES_FORECAST_ENABLED=true
TIME_SERIES_MIN_CANDLES=120 TIME_SERIES_MIN_CANDLES=120
TIME_SERIES_FORECAST_HORIZON=3 TIME_SERIES_FORECAST_HORIZON=3
TIME_SERIES_MIN_EDGE_PERCENT=0.04 TIME_SERIES_MIN_EDGE_PERCENT=0.10
TIME_SERIES_MIN_PROBABILITY_UP=0.47
TIME_SERIES_MIN_CONFIDENCE=0.4
TIME_SERIES_MAX_ADJUSTMENT=0.08 TIME_SERIES_MAX_ADJUSTMENT=0.08
TIME_SERIES_LSTM_ENABLED=true TIME_SERIES_LSTM_ENABLED=true
TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json
TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
STOP_LOSS_PERCENT=0.04 STOP_LOSS_PERCENT=0.04
TAKE_PROFIT_PERCENT=0.035 TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015 TRAILING_STOP_PERCENT=0.015
+63
View File
@@ -0,0 +1,63 @@
# TradeBot Monitor для Android
Нативное Android-приложение для наблюдения за ботом и удалённого управления переобучением.
## Что есть в приложении
- Русский интерфейс без bubble/pill-оформления.
- Современная биржевая компоновка: список пар, один выбранный график, компактные параметры ниже.
- Свечной график 1h: тела свечей, фитили, объём, EMA50, EMA200, последняя цена.
- Параметры Torch: edge, P(up), confidence, skill, quantiles, gate, причина решения.
- Kelly/размер позиции: текущий размер, Kelly-цель, занятая экспозиция, остаток, множители edge/P(up)/skill.
- Обзор equity/cash/exposure/PnL и последних решений.
- Удалённый запуск retrain через очередь заданий на боте и закреплённый Windows-компьютер обучения.
- Расписание retrain на телефоне: Android отправляет команду по расписанию, но обучение идёт на Windows-машине.
- Настройки API, токена команд, тёмной/светлой темы.
- Live-чеклист: приложение показывает, готов ли сервер к реальной торговле, и не включает live одной опасной кнопкой.
## Сборка
```powershell
cd C:\Repos\TradeBot\android\TradeBotMonitor
gradle :app:assembleDebug
```
APK появится в:
```text
android/TradeBotMonitor/app/build/outputs/apk/debug/app-debug.apk
```
## Подключение к боту
В настройках приложения укажи адрес API, например:
```text
https://tb.kusoft.xyz
```
Этот адрес установлен в приложении по умолчанию. Если в настройках ввести просто `tb.kusoft.xyz`, приложение само добавит `https://`.
Если домен защищён авторизацией, в поле `API auth` можно указать:
- `login:password` — приложение отправит HTTP Basic;
- `Basic ...` — готовый Basic header;
- `Bearer ...` или просто токен — приложение отправит Bearer.
## Переобучение
Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на закреплённой машине `DESKTOP-TMFDL0H` сам выходит в интернет, забирает задание, обучает модель и отправляет артефакты обратно боту. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере даже если он находится в другой сети.
## Live-торговля
Вкладка `Настройки` показывает live-чеклист:
- бот остановлен перед переключением;
- на сервере выставлен `TRADING_MODE=live`;
- используется mainnet, не testnet;
- задано `ENABLE_LIVE_TRADING=true`;
- задано `LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK`;
- Bybit API key/secret настроены на сервере;
- лимиты live-ордера, risk и Kelly проверены.
Приложение может отправить команды остановки/запуска цикла, но не включает реальные ордера без серверного `.env` и подтверждения риска.
@@ -0,0 +1,16 @@
plugins {
id("com.android.application")
}
android {
namespace = "xyz.kusoft.tradebotmonitor"
compileSdk = 36
defaultConfig {
applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26
targetSdk = 36
versionCode = 12
versionName = "0.2.9"
}
}
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="TradeBot AI"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".RetrainAlarmReceiver"
android:exported="false" />
<receiver
android:name=".BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,53 @@
package xyz.kusoft.tradebotmonitor
import android.graphics.Color
data class AppPalette(
val isDark: Boolean,
val page: Int,
val panel: Int,
val panelAlt: Int,
val line: Int,
val text: Int,
val muted: Int,
val green: Int,
val red: Int,
val amber: Int,
val blue: Int,
val chartBg: Int,
val chartGrid: Int,
) {
companion object {
fun dark() = AppPalette(
isDark = true,
page = Color.rgb(7, 8, 12),
panel = Color.rgb(17, 20, 26),
panelAlt = Color.rgb(21, 25, 34),
line = Color.rgb(36, 42, 54),
text = Color.rgb(244, 246, 251),
muted = Color.rgb(119, 127, 142),
green = Color.rgb(22, 199, 132),
red = Color.rgb(255, 80, 102),
amber = Color.rgb(245, 166, 35),
blue = Color.rgb(109, 131, 255),
chartBg = Color.rgb(8, 10, 15),
chartGrid = Color.rgb(27, 32, 41),
)
fun light() = AppPalette(
isDark = false,
page = Color.rgb(243, 245, 247),
panel = Color.WHITE,
panelAlt = Color.rgb(247, 249, 251),
line = Color.rgb(216, 224, 232),
text = Color.rgb(20, 27, 36),
muted = Color.rgb(92, 106, 122),
green = Color.rgb(18, 134, 88),
red = Color.rgb(194, 65, 63),
amber = Color.rgb(173, 116, 24),
blue = Color.rgb(37, 99, 235),
chartBg = Color.rgb(13, 18, 25),
chartGrid = Color.rgb(44, 56, 70),
)
}
}
@@ -0,0 +1,75 @@
package xyz.kusoft.tradebotmonitor
import android.content.Context
class AppPrefs(context: Context) {
private val prefs = context.getSharedPreferences("tradebot_monitor", Context.MODE_PRIVATE)
init {
val saved = prefs.getString("api_base_url", null)?.trim()
if (saved.isNullOrBlank() || saved == LEGACY_PI_API_BASE_URL) {
prefs.edit().putString("api_base_url", DEFAULT_API_BASE_URL).apply()
}
if (prefs.getString("training_computer_name", null).isNullOrBlank()) {
pinDefaultTrainingComputer()
}
}
var apiBaseUrl: String
get() = normalizeBaseUrl(prefs.getString("api_base_url", DEFAULT_API_BASE_URL) ?: DEFAULT_API_BASE_URL)
set(value) = prefs.edit().putString("api_base_url", normalizeBaseUrl(value)).apply()
var commandToken: String
get() = prefs.getString("command_token", "") ?: ""
set(value) = prefs.edit().putString("command_token", value.trim()).apply()
var selectedSymbol: String
get() = prefs.getString("selected_symbol", "BTCUSDT") ?: "BTCUSDT"
set(value) = prefs.edit().putString("selected_symbol", value).apply()
var themeMode: String
get() = prefs.getString("theme_mode", "dark") ?: "dark"
set(value) = prefs.edit().putString("theme_mode", value).apply()
var retrainScheduleEnabled: Boolean
get() = prefs.getBoolean("retrain_schedule_enabled", false)
set(value) = prefs.edit().putBoolean("retrain_schedule_enabled", value).apply()
var retrainIntervalHours: Int
get() = prefs.getInt("retrain_interval_hours", 6)
set(value) = prefs.edit().putInt("retrain_interval_hours", value.coerceAtLeast(1)).apply()
val trainingComputerName: String
get() = prefs.getString("training_computer_name", DEFAULT_TRAINING_COMPUTER_NAME) ?: DEFAULT_TRAINING_COMPUTER_NAME
val trainingComputerPath: String
get() = prefs.getString("training_computer_path", DEFAULT_TRAINING_COMPUTER_PATH) ?: DEFAULT_TRAINING_COMPUTER_PATH
val trainingComputerPinned: Boolean
get() = prefs.getBoolean("training_computer_pinned", true)
fun pinDefaultTrainingComputer() {
prefs.edit()
.putString("training_computer_name", DEFAULT_TRAINING_COMPUTER_NAME)
.putString("training_computer_path", DEFAULT_TRAINING_COMPUTER_PATH)
.putBoolean("training_computer_pinned", true)
.apply()
}
private fun normalizeBaseUrl(value: String): String {
val trimmed = value.trim().trimEnd('/')
if (trimmed.isBlank()) return DEFAULT_API_BASE_URL
return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
"https://$trimmed"
}
}
private companion object {
const val DEFAULT_API_BASE_URL = "https://tb.kusoft.xyz"
const val LEGACY_PI_API_BASE_URL = "http://192.168.0.185:8787"
const val DEFAULT_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H"
const val DEFAULT_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot"
}
}
@@ -0,0 +1,230 @@
package xyz.kusoft.tradebotmonitor
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
class CandleChartView(context: Context) : View(context) {
var candles: List<Candle> = emptyList()
set(value) {
field = value.takeLast(90)
invalidate()
}
var palette: AppPalette = AppPalette.dark()
set(value) {
field = value
invalidate()
}
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 11f * resources.displayMetrics.scaledDensity
typeface = android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL)
}
private val dateFormat = SimpleDateFormat("dd.MM HH:mm", Locale("ru", "RU"))
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(palette.chartBg)
val rows = candles.filter { it.high > 0.0 && it.low > 0.0 }
if (rows.size < 2) {
drawEmpty(canvas)
return
}
val left = dp(8).toFloat()
val right = width - dp(62).toFloat()
val top = dp(12).toFloat()
val bottom = height - dp(24).toFloat()
val volumeHeight = max(dp(44).toFloat(), height * 0.17f)
val chartBottom = bottom - volumeHeight - dp(8)
val chartHeight = max(dp(120).toFloat(), chartBottom - top)
val plotWidth = right - left
val values = mutableListOf<Double>()
rows.forEach {
values += it.high
values += it.low
it.ema50?.let(values::add)
it.ema200?.let(values::add)
}
var minPrice = values.minOrNull() ?: 0.0
var maxPrice = values.maxOrNull() ?: 1.0
val rawRange = max(maxPrice - minPrice, maxPrice * 0.0001)
minPrice -= rawRange * 0.08
maxPrice += rawRange * 0.08
val range = max(maxPrice - minPrice, 1e-9)
fun y(value: Double): Float = (top + ((maxPrice - value) / range).toFloat() * chartHeight)
fun x(index: Int): Float {
val step = plotWidth / rows.size
return left + index * step + step / 2f
}
drawGrid(canvas, left, right, top, chartHeight, minPrice, maxPrice)
drawVolumes(canvas, rows, left, plotWidth, chartBottom + dp(8), volumeHeight - dp(4))
val bodyWidth = (plotWidth / rows.size * 0.62f).coerceIn(dp(3).toFloat(), dp(10).toFloat())
rows.forEachIndexed { index, candle -> drawCandle(canvas, candle, x(index), bodyWidth, ::y) }
drawAverageLine(canvas, rows, { it.ema50 }, ::x, ::y, android.graphics.Color.rgb(147, 197, 253))
drawAverageLine(canvas, rows, { it.ema200 }, ::x, ::y, android.graphics.Color.rgb(245, 158, 11))
drawLastPrice(canvas, rows.last(), left, right, ::y)
drawTimeAxis(canvas, rows, left, plotWidth, height - dp(7).toFloat())
}
private fun drawEmpty(canvas: Canvas) {
textPaint.color = android.graphics.Color.rgb(148, 163, 184)
textPaint.textAlign = Paint.Align.LEFT
canvas.drawText("Нет свечей для графика", dp(14).toFloat(), dp(30).toFloat(), textPaint)
}
private fun drawGrid(
canvas: Canvas,
left: Float,
right: Float,
top: Float,
chartHeight: Float,
minPrice: Double,
maxPrice: Double,
) {
paint.style = Paint.Style.STROKE
paint.strokeWidth = 1f
paint.color = palette.chartGrid
textPaint.color = android.graphics.Color.rgb(154, 167, 183)
textPaint.textAlign = Paint.Align.RIGHT
for (i in 0..5) {
val ratio = i / 5f
val yy = top + chartHeight * ratio
canvas.drawLine(left, yy, right + dp(4), yy, paint)
val price = maxPrice - (maxPrice - minPrice) * ratio
canvas.drawText(compactPrice(price), width - dp(5).toFloat(), yy + dp(4), textPaint)
}
}
private fun drawVolumes(
canvas: Canvas,
rows: List<Candle>,
left: Float,
plotWidth: Float,
top: Float,
height: Float,
) {
val maxVolume = max(rows.maxOf { it.volume }, 1.0)
val step = plotWidth / rows.size
val barWidth = (step * 0.56f).coerceIn(dp(2).toFloat(), dp(8).toFloat())
paint.style = Paint.Style.FILL
rows.forEachIndexed { index, candle ->
val up = candle.close >= candle.open
paint.color = if (up) withAlpha(palette.green, 90) else withAlpha(palette.red, 90)
val barHeight = max(1f, (candle.volume / maxVolume).toFloat() * height)
val x = left + index * step + step / 2f - barWidth / 2f
canvas.drawRect(x, top + height - barHeight, x + barWidth, top + height, paint)
}
}
private fun drawCandle(
canvas: Canvas,
candle: Candle,
x: Float,
bodyWidth: Float,
y: (Double) -> Float,
) {
val up = candle.close >= candle.open
val color = if (up) palette.green else palette.red
paint.color = color
paint.strokeWidth = 1.1f
paint.style = Paint.Style.STROKE
canvas.drawLine(x, y(candle.high), x, y(candle.low), paint)
val top = y(max(candle.open, candle.close))
val bottom = y(min(candle.open, candle.close))
paint.style = Paint.Style.FILL
val bodyHeight = max(1.5f, bottom - top)
canvas.drawRect(RectF(x - bodyWidth / 2f, top, x + bodyWidth / 2f, top + bodyHeight), paint)
}
private fun drawAverageLine(
canvas: Canvas,
rows: List<Candle>,
getter: (Candle) -> Double?,
x: (Int) -> Float,
y: (Double) -> Float,
color: Int,
) {
paint.color = color
paint.strokeWidth = 1.35f
paint.style = Paint.Style.STROKE
var started = false
rows.forEachIndexed { index, candle ->
val value = getter(candle) ?: return@forEachIndexed
if (value <= 0.0) return@forEachIndexed
if (!started) {
canvas.save()
paint.pathEffect = null
canvas.restore()
started = true
val xx = x(index)
val yy = y(value)
canvas.drawPoint(xx, yy, paint)
paint.strokeWidth = 1.35f
android.graphics.Path().also { path ->
path.moveTo(xx, yy)
for (next in index + 1 until rows.size) {
val nextValue = getter(rows[next]) ?: continue
if (nextValue > 0.0) path.lineTo(x(next), y(nextValue))
}
canvas.drawPath(path, paint)
}
return
}
}
}
private fun drawLastPrice(canvas: Canvas, candle: Candle, left: Float, right: Float, y: (Double) -> Float) {
val yy = y(candle.close)
paint.style = Paint.Style.STROKE
paint.strokeWidth = 1f
paint.color = android.graphics.Color.rgb(229, 231, 235)
paint.pathEffect = android.graphics.DashPathEffect(floatArrayOf(8f, 6f), 0f)
canvas.drawLine(left, yy, right + dp(4), yy, paint)
paint.pathEffect = null
textPaint.color = android.graphics.Color.rgb(229, 231, 235)
textPaint.textAlign = Paint.Align.RIGHT
canvas.drawText(compactPrice(candle.close), width - dp(5).toFloat(), yy - dp(3), textPaint)
}
private fun drawTimeAxis(canvas: Canvas, rows: List<Candle>, left: Float, plotWidth: Float, baseline: Float) {
textPaint.color = android.graphics.Color.rgb(154, 167, 183)
textPaint.textAlign = Paint.Align.CENTER
val ticks = min(5, rows.size)
for (i in 0 until ticks) {
val index = ((rows.size - 1) * (i / max(1f, (ticks - 1).toFloat()))).toInt().coerceIn(0, rows.lastIndex)
val step = plotWidth / rows.size
val xx = left + index * step + step / 2f
val timestamp = rows[index].timestamp
val date = if (timestamp > 100_000_000_000L) Date(timestamp) else Date(timestamp * 1000L)
canvas.drawText(dateFormat.format(date), xx, baseline, textPaint)
}
}
private fun compactPrice(value: Double): String =
when {
value >= 1000.0 -> "%,.0f".format(Locale.US, value)
value >= 1.0 -> "%,.3f".format(Locale.US, value)
else -> "%.8f".format(Locale.US, value).trimEnd('0').trimEnd('.')
}
private fun withAlpha(color: Int, alpha: Int): Int =
android.graphics.Color.argb(alpha, android.graphics.Color.red(color), android.graphics.Color.green(color), android.graphics.Color.blue(color))
private fun dp(value: Int): Int =
(value * resources.displayMetrics.density).toInt()
}
@@ -0,0 +1,157 @@
package xyz.kusoft.tradebotmonitor
import org.json.JSONObject
data class Candle(
val timestamp: Long,
val open: Double,
val high: Double,
val low: Double,
val close: Double,
val volume: Double,
val ema50: Double?,
val ema200: Double?,
val rsi14: Double?,
val atr14: Double?,
val macd: Double?,
val macdSignal: Double?,
)
data class TickerData(
val symbol: String,
val lastPrice: Double,
val bid: Double,
val ask: Double,
val turnover24h: Double,
val volume24h: Double,
val change24h: Double,
val spreadPercent: Double,
)
data class FeatureItem(
val group: String,
val label: String,
val rawDisplay: String,
val modelDisplay: String,
val interpretation: String,
)
data class ForecastData(
val model: String,
val expectedReturnPercent: Double,
val probabilityUp: Double,
val skill: Double,
val volatilityPercent: Double,
val horizon: Int,
val q10Percent: Double,
val q50Percent: Double,
val q90Percent: Double,
val qualityGatePassed: Boolean?,
val reason: String,
val features: List<FeatureItem>,
)
data class MarketItem(
val symbol: String,
val ticker: TickerData?,
val candles: List<Candle>,
val forecast: ForecastData?,
val qualityStatus: String,
val qualityScore: Double,
)
data class SignalData(
val symbol: String,
val action: String,
val confidence: Double,
val reason: String,
val diagnostics: JSONObject,
) {
val expectedReturnPercent: Double
get() = diagnostics.optDoubleOrNull("expected_return_percent")
?: diagnostics.optJSONObject("forecast")?.optDoubleOrNull("expected_return_percent")
?: 0.0
val probabilityUp: Double
get() = diagnostics.optDoubleOrNull("probability_up")
?: diagnostics.optJSONObject("forecast")?.optDoubleOrNull("probability_up")
?: 0.0
val positionNotionalUsdt: Double
get() = diagnostics.optDoubleOrNull("position_notional_usdt")
?: diagnostics.optJSONObject("position_sizing")?.optDoubleOrNull("notional_usdt")
?: 0.0
}
data class PositionData(
val symbol: String,
val qty: Double,
val entryPrice: Double,
val markPrice: Double,
val notionalUsdt: Double,
val marketValue: Double,
val unrealizedPnl: Double,
val unrealizedPnlPercent: Double,
val stopLoss: Double?,
val takeProfit: Double?,
val highestPrice: Double?,
val trailingStop: Double?,
val atrTrailingStop: Double?,
val exitAction: String,
val exitReason: String,
val stopLossExitEnabled: Boolean,
)
data class AccountData(
val equity: Double,
val cash: Double,
val exposure: Double,
)
data class ClosedTradeData(
val symbol: String,
val qty: Double,
val entryPrice: Double?,
val exitPrice: Double?,
val grossPnl: Double,
val feeUsdt: Double,
val netPnl: Double,
val reason: String,
val openedAt: String,
val closedAt: String,
)
data class ClosedTradesSummary(
val trades: Int,
val netPnl: Double,
val grossPnl: Double,
val feeUsdt: Double,
val wins: Int,
val losses: Int,
val winRate: Double,
)
data class BotSnapshot(
val ok: Boolean,
val running: Boolean,
val mode: String,
val account: AccountData,
val positions: List<PositionData>,
val closedTrades: List<ClosedTradeData>,
val closedTradesSummary: ClosedTradesSummary,
val markets: List<MarketItem>,
val signalsBySymbol: Map<String, SignalData>,
val config: JSONObject,
val retrain: JSONObject,
val backtest: JSONObject,
val fetchedAtMillis: Long,
)
fun JSONObject.optStringClean(name: String): String =
if (isNull(name)) "" else optString(name, "")
fun JSONObject.optDoubleOrNull(name: String): Double? =
if (has(name) && !isNull(name)) optDouble(name) else null
fun JSONObject.optBooleanOrNull(name: String): Boolean? =
if (has(name) && !isNull(name)) optBoolean(name) else null
@@ -0,0 +1,63 @@
package xyz.kusoft.tradebotmonitor
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.util.concurrent.Executors
object RetrainScheduler {
private const val ACTION_RETRAIN = "xyz.kusoft.tradebotmonitor.RETRAIN"
private const val REQUEST_CODE = 6406
fun schedule(context: Context, hours: Int) {
val interval = hours.coerceAtLeast(1) * 60L * 60L * 1000L
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
manager.setInexactRepeating(
AlarmManager.RTC_WAKEUP,
System.currentTimeMillis() + interval,
interval,
pendingIntent(context),
)
}
fun cancel(context: Context) {
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
manager.cancel(pendingIntent(context))
}
private fun pendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
REQUEST_CODE,
Intent(context, RetrainAlarmReceiver::class.java).setAction(ACTION_RETRAIN),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
class RetrainAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
Executors.newSingleThreadExecutor().execute {
try {
val prefs = AppPrefs(context)
if (prefs.retrainScheduleEnabled) {
TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
}
} finally {
pending.finish()
}
}
}
}
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val prefs = AppPrefs(context)
if (prefs.retrainScheduleEnabled) {
RetrainScheduler.schedule(context, prefs.retrainIntervalHours)
}
}
}
@@ -0,0 +1,287 @@
package xyz.kusoft.tradebotmonitor
import android.util.Base64
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
class TradeBotApi(
private val baseUrl: String,
private val token: String,
) {
fun fetchSnapshot(): BotSnapshot {
val health = getJson("/api/health")
val status = getJson("/api/status")
val markets = getJson("/api/markets")
val signals = getJson("/api/signals?limit=220")
val config = getJson("/api/config")
val trades = getJson("/api/trades?limit=10")
val retrain = getJson("/api/retrain")
val backtest = getJson("/api/backtest")
val accountJson = status.optJSONObject("account") ?: JSONObject()
val account = AccountData(
equity = accountJson.optDouble("equity", 0.0),
cash = accountJson.optDouble("cash", 0.0),
exposure = accountJson.optDouble("exposure", 0.0),
)
return BotSnapshot(
ok = health.optBoolean("ok", false),
running = health.optBoolean("running", false),
mode = health.optStringClean("mode"),
account = account,
positions = parsePositions(status.optJSONArray("positions") ?: JSONArray()),
closedTrades = parseClosedTrades(trades.optJSONArray("closed_items") ?: JSONArray()),
closedTradesSummary = parseClosedTradesSummary(trades.optJSONObject("closed_summary") ?: JSONObject()),
markets = parseMarkets(markets.optJSONArray("markets") ?: JSONArray()),
signalsBySymbol = parseLatestSignals(signals.optJSONArray("items") ?: JSONArray()),
config = config,
retrain = retrain,
backtest = backtest,
fetchedAtMillis = System.currentTimeMillis(),
)
}
fun startBot() {
postJson("/api/control/start")
}
fun stopBot() {
postJson("/api/control/stop")
}
fun requestRetrain(): String {
val response = postJson("/api/training/retrain", "{\"source\":\"android\"}")
if (response.optBoolean("queued", false)) {
return "Задание переобучения отправлено на закрепленный компьютер"
}
return when (response.optStringClean("reason")) {
"active_job_exists" -> "Задание переобучения уже выполняется или ждет агента"
else -> "Задание переобучения принято ботом"
}
}
private fun getJson(path: String): JSONObject = request(path, "GET")
private fun postJson(path: String, body: String = "{}"): JSONObject = request(path, "POST", body)
private fun request(path: String, method: String, body: String = "{}"): JSONObject {
val root = baseUrl.trim().trimEnd('/')
val url = URL(root + path)
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 6000
readTimeout = 9000
setRequestProperty("Accept", "application/json")
applyAuthHeaders(this, token)
if (method == "POST") {
doOutput = true
setRequestProperty("Content-Type", "application/json")
outputStream.use { stream -> stream.write(body.toByteArray(StandardCharsets.UTF_8)) }
}
}
val code = connection.responseCode
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader(StandardCharsets.UTF_8)?.use(BufferedReader::readText).orEmpty()
connection.disconnect()
if (code !in 200..299) {
if (code == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw IllegalStateException("HTTP 401: сервер требует логин и пароль")
}
throw IllegalStateException("HTTP $code: ${text.take(240)}")
}
return if (text.isBlank()) JSONObject() else JSONObject(text)
}
private fun parsePositions(items: JSONArray): List<PositionData> {
val output = mutableListOf<PositionData>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
val exitPlan = row.optJSONObject("exit_plan") ?: JSONObject()
output += PositionData(
symbol = row.optStringClean("symbol"),
qty = row.optDouble("qty", 0.0),
entryPrice = row.optDouble("entry_price", 0.0),
markPrice = row.optDouble("mark_price", 0.0),
notionalUsdt = row.optDouble("notional_usdt", 0.0),
marketValue = row.optDouble("market_value", 0.0),
unrealizedPnl = row.optDouble("unrealized_pnl", 0.0),
unrealizedPnlPercent = row.optDouble("unrealized_pnl_percent", 0.0),
stopLoss = exitPlan.optDoubleOrNull("stop_loss") ?: row.optDoubleOrNull("stop_loss"),
takeProfit = exitPlan.optDoubleOrNull("take_profit") ?: row.optDoubleOrNull("take_profit"),
highestPrice = exitPlan.optDoubleOrNull("highest_price") ?: row.optDoubleOrNull("highest_price"),
trailingStop = exitPlan.optDoubleOrNull("trailing_stop"),
atrTrailingStop = exitPlan.optDoubleOrNull("atr_trailing_stop"),
exitAction = exitPlan.optStringClean("action"),
exitReason = exitPlan.optStringClean("reason"),
stopLossExitEnabled = exitPlan.optBooleanOrNull("stop_loss_exit_enabled") ?: true,
)
}
return output
}
private fun parseClosedTrades(items: JSONArray): List<ClosedTradeData> {
val output = mutableListOf<ClosedTradeData>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
output += ClosedTradeData(
symbol = row.optStringClean("symbol"),
qty = row.optDouble("qty", 0.0),
entryPrice = row.optDoubleOrNull("entry_price"),
exitPrice = row.optDoubleOrNull("exit_price"),
grossPnl = row.optDouble("gross_pnl", 0.0),
feeUsdt = row.optDouble("fee_usdt", 0.0),
netPnl = row.optDouble("net_pnl", 0.0),
reason = row.optStringClean("reason"),
openedAt = row.optStringClean("opened_at"),
closedAt = row.optStringClean("closed_at"),
)
}
return output
}
private fun parseClosedTradesSummary(row: JSONObject): ClosedTradesSummary =
ClosedTradesSummary(
trades = row.optInt("trades", 0),
netPnl = row.optDouble("net_pnl", 0.0),
grossPnl = row.optDouble("gross_pnl", 0.0),
feeUsdt = row.optDouble("fee_usdt", 0.0),
wins = row.optInt("wins", 0),
losses = row.optInt("losses", 0),
winRate = row.optDouble("win_rate", 0.0),
)
private fun parseMarkets(items: JSONArray): List<MarketItem> {
val output = mutableListOf<MarketItem>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
val tickerJson = row.optJSONObject("ticker")
val instrument = row.optJSONObject("instrument") ?: JSONObject()
val symbol = tickerJson?.optStringClean("symbol").orEmpty().ifBlank {
instrument.optStringClean("symbol")
}
val quality = row.optJSONObject("quality") ?: JSONObject()
output += MarketItem(
symbol = symbol,
ticker = tickerJson?.let(::parseTicker),
candles = parseCandles(row.optJSONArray("candles") ?: JSONArray()),
forecast = row.optJSONObject("forecast")?.let(::parseForecast),
qualityStatus = quality.optStringClean("status"),
qualityScore = quality.optDouble("score", 0.0),
)
}
return output.sortedBy { it.symbol }
}
private fun parseTicker(row: JSONObject): TickerData =
TickerData(
symbol = row.optStringClean("symbol"),
lastPrice = row.optDouble("last_price", 0.0),
bid = row.optDouble("bid", 0.0),
ask = row.optDouble("ask", 0.0),
turnover24h = row.optDouble("turnover_24h", 0.0),
volume24h = row.optDouble("volume_24h", 0.0),
change24h = row.optDouble("change_24h", 0.0),
spreadPercent = row.optDouble("spread_percent", 0.0),
)
private fun parseCandles(items: JSONArray): List<Candle> {
val output = mutableListOf<Candle>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
output += Candle(
timestamp = row.optLong("timestamp", 0L),
open = row.optDouble("open", 0.0),
high = row.optDouble("high", 0.0),
low = row.optDouble("low", 0.0),
close = row.optDouble("close", 0.0),
volume = row.optDouble("volume", 0.0),
ema50 = row.optDoubleOrNull("ema_50"),
ema200 = row.optDoubleOrNull("ema_200"),
rsi14 = row.optDoubleOrNull("rsi_14"),
atr14 = row.optDoubleOrNull("atr_14"),
macd = row.optDoubleOrNull("macd"),
macdSignal = row.optDoubleOrNull("macd_signal"),
)
}
return output
}
private fun parseForecast(row: JSONObject): ForecastData {
val featureItems = row.optJSONArray("feature_snapshot") ?: JSONArray()
val features = mutableListOf<FeatureItem>()
for (index in 0 until featureItems.length()) {
val feature = featureItems.optJSONObject(index) ?: continue
features += FeatureItem(
group = feature.optStringClean("group"),
label = feature.optStringClean("label").ifBlank { feature.optStringClean("name") },
rawDisplay = feature.optStringClean("raw_display").ifBlank {
feature.optStringClean("raw_value")
},
modelDisplay = feature.optStringClean("model_display").ifBlank {
feature.optStringClean("model_value")
},
interpretation = feature.optStringClean("interpretation").ifBlank {
feature.optStringClean("description")
},
)
}
return ForecastData(
model = row.optStringClean("model"),
expectedReturnPercent = row.optDouble("expected_return_percent", 0.0),
probabilityUp = row.optDouble("probability_up", 0.0),
skill = row.optDouble("skill", 0.0),
volatilityPercent = row.optDouble("volatility_percent", 0.0),
horizon = row.optInt("horizon", 0),
q10Percent = row.optDouble("quantile_10_percent", 0.0),
q50Percent = row.optDouble("quantile_50_percent", 0.0),
q90Percent = row.optDouble("quantile_90_percent", 0.0),
qualityGatePassed = row.optBooleanOrNull("quality_gate_passed"),
reason = row.optStringClean("reason"),
features = features,
)
}
private fun parseLatestSignals(items: JSONArray): Map<String, SignalData> {
val output = linkedMapOf<String, SignalData>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
val symbol = row.optStringClean("symbol")
if (symbol.isBlank() || output.containsKey(symbol)) continue
val diagnostics = try {
JSONObject(row.optStringClean("diagnostics_json"))
} catch (_: Exception) {
JSONObject()
}
output[symbol] = SignalData(
symbol = symbol,
action = row.optStringClean("action"),
confidence = row.optDouble("confidence", 0.0),
reason = row.optStringClean("reason"),
diagnostics = diagnostics,
)
}
return output
}
}
private fun applyAuthHeaders(connection: HttpURLConnection, token: String) {
val value = token.trim()
if (value.isBlank()) return
connection.setRequestProperty("X-TradeBot-Token", value)
val authorization = when {
value.startsWith("Basic ", ignoreCase = true) -> value
value.startsWith("Bearer ", ignoreCase = true) -> value
":" in value -> {
val encoded = Base64.encodeToString(value.toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP)
"Basic $encoded"
}
else -> "Bearer $value"
}
connection.setRequestProperty("Authorization", authorization)
}
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#07080B"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#111722"
android:pathData="M20,20h68a10,10 0,0 1,10 10v48a10,10 0,0 1,-10 10h-68a10,10 0,0 1,-10 -10v-48a10,10 0,0 1,10 -10z" />
<path
android:strokeColor="#28303D"
android:strokeWidth="2"
android:fillColor="@android:color/transparent"
android:pathData="M20,20h68a10,10 0,0 1,10 10v48a10,10 0,0 1,-10 10h-68a10,10 0,0 1,-10 -10v-48a10,10 0,0 1,10 -10z" />
<path
android:strokeColor="#16C784"
android:strokeWidth="5"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="@android:color/transparent"
android:pathData="M22,66 L35,58 L46,62 L58,42 L72,49 L86,31" />
<path
android:strokeColor="#F5A623"
android:strokeWidth="3"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent"
android:pathData="M24,78h60" />
<path
android:fillColor="#16C784"
android:pathData="M28,31h8v22h-8z M50,36h8v17h-8z M72,29h8v24h-8z" />
<path
android:fillColor="#FF5066"
android:pathData="M39,38h8v15h-8z M61,33h8v20h-8z" />
<path
android:fillColor="#F5A623"
android:pathData="M82,26a6,6 0,1 0,0.1 0z" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.2.1" apply false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

@@ -0,0 +1,236 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="980" viewBox="0 0 1600 980">
<defs>
<style>
.canvas { fill: #07080b; }
.phone { fill: #050609; stroke: #232833; stroke-width: 2; }
.screen { fill: #07080c; }
.panel { fill: #11141a; stroke: #222833; stroke-width: 1; }
.panel2 { fill: #151922; stroke: #28303d; stroke-width: 1; }
.soft { fill: #0d1016; stroke: #1d2430; stroke-width: 1; }
.line { stroke: #242a36; stroke-width: 1; }
.grid { stroke: #1b2029; stroke-width: 1; }
.text { fill: #f4f6fb; font-family: Inter, Arial, sans-serif; }
.muted { fill: #777f8e; font-family: Inter, Arial, sans-serif; }
.dim { fill: #4f5664; font-family: Inter, Arial, sans-serif; }
.green { fill: #16c784; font-family: Inter, Arial, sans-serif; }
.red { fill: #ff5066; font-family: Inter, Arial, sans-serif; }
.amber { fill: #f5a623; font-family: Inter, Arial, sans-serif; }
.blue { fill: #6d83ff; font-family: Inter, Arial, sans-serif; }
.stroke-green { stroke: #16c784; }
.stroke-red { stroke: #ff5066; }
.stroke-amber { stroke: #f5a623; }
.stroke-blue { stroke: #6d83ff; }
.label { font-size: 13px; font-weight: 600; letter-spacing: 0; }
.tiny { font-size: 11px; letter-spacing: 0; }
.small { font-size: 13px; letter-spacing: 0; }
.base { font-size: 15px; letter-spacing: 0; }
.h2 { font-size: 18px; font-weight: 700; letter-spacing: 0; }
.h1 { font-size: 30px; font-weight: 800; letter-spacing: 0; }
.tab { font-size: 15px; font-weight: 700; letter-spacing: 0; }
.nav { font-size: 11px; font-weight: 600; letter-spacing: 0; }
.mono { font-family: "JetBrains Mono", Consolas, monospace; letter-spacing: 0; }
.shadow { filter: drop-shadow(0 24px 38px rgba(0,0,0,.42)); }
</style>
<linearGradient id="heroLine" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#16c784"/>
<stop offset="1" stop-color="#f5a623"/>
</linearGradient>
<linearGradient id="riskLine" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#16c784"/>
<stop offset=".62" stop-color="#16c784"/>
<stop offset=".62" stop-color="#28303d"/>
<stop offset="1" stop-color="#28303d"/>
</linearGradient>
</defs>
<rect class="canvas" width="1600" height="980"/>
<text x="48" y="54" class="text h2">Вариант 2: AI Trading Console</text>
<text x="48" y="79" class="muted small">Фокус не на ручной торговле, а на решении Torch, риске Kelly и контроле бота. Стиль: строгий биржевой интерфейс без пузырей.</text>
<!-- Screen 1 -->
<g class="shadow" transform="translate(54 116)">
<rect class="phone" x="0" y="0" width="420" height="820" rx="36"/>
<rect class="screen" x="16" y="16" width="388" height="788" rx="26"/>
<text x="34" y="48" class="text small">22:18</text>
<rect x="168" y="29" width="84" height="8" rx="4" fill="#151922"/>
<text x="338" y="48" class="text small">91%</text>
<text x="34" y="88" class="text h2">AI Торговля</text>
<rect x="280" y="68" width="94" height="28" rx="7" fill="#14251f" stroke="#16c784"/>
<text x="298" y="87" class="green label">PAPER</text>
<text x="34" y="130" class="text h2">BTC/USDT</text>
<text x="34" y="170" class="green h1">67 240.5</text>
<text x="205" y="169" class="green base">+0.84%</text>
<text x="34" y="194" class="muted small">Edge +0.82% · P(up) 64% · 1h</text>
<text x="276" y="128" class="muted small">Equity</text>
<text x="276" y="152" class="text h2">108.42 USDT</text>
<line x1="34" y1="220" x2="374" y2="220" class="line"/>
<text x="34" y="248" class="text tab">График</text>
<text x="114" y="248" class="muted tab">Torch</text>
<text x="190" y="248" class="muted tab">Риск</text>
<text x="250" y="248" class="muted tab">История</text>
<line x1="34" y1="258" x2="88" y2="258" stroke="#f5a623" stroke-width="3"/>
<g transform="translate(34 280)">
<rect x="0" y="0" width="340" height="242" fill="#080a0f"/>
<line x1="0" y1="48" x2="340" y2="48" class="grid"/>
<line x1="0" y1="96" x2="340" y2="96" class="grid"/>
<line x1="0" y1="144" x2="340" y2="144" class="grid"/>
<line x1="0" y1="192" x2="340" y2="192" class="grid"/>
<line x1="68" y1="0" x2="68" y2="242" class="grid"/>
<line x1="136" y1="0" x2="136" y2="242" class="grid"/>
<line x1="204" y1="0" x2="204" y2="242" class="grid"/>
<line x1="272" y1="0" x2="272" y2="242" class="grid"/>
<polyline points="2,153 32,140 62,151 92,112 122,124 152,93 182,105 212,74 242,88 272,63 302,92 338,80" fill="none" stroke="#6d83ff" stroke-width="1.5" opacity=".7"/>
<polyline points="2,166 32,155 62,160 92,140 122,128 152,119 182,113 212,104 242,94 272,82 302,88 338,84" fill="none" stroke="#f5a623" stroke-width="1.5" opacity=".85"/>
<g stroke-width="1.4">
<line x1="16" y1="125" x2="16" y2="172" class="stroke-red"/><rect x="11" y="135" width="10" height="27" fill="#ff5066"/>
<line x1="34" y1="143" x2="34" y2="181" class="stroke-green"/><rect x="29" y="151" width="10" height="20" fill="#16c784"/>
<line x1="52" y1="136" x2="52" y2="186" class="stroke-red"/><rect x="47" y="148" width="10" height="26" fill="#ff5066"/>
<line x1="70" y1="129" x2="70" y2="158" class="stroke-green"/><rect x="65" y="137" width="10" height="13" fill="#16c784"/>
<line x1="88" y1="105" x2="88" y2="150" class="stroke-green"/><rect x="83" y="118" width="10" height="24" fill="#16c784"/>
<line x1="106" y1="112" x2="106" y2="164" class="stroke-red"/><rect x="101" y="124" width="10" height="31" fill="#ff5066"/>
<line x1="124" y1="99" x2="124" y2="139" class="stroke-green"/><rect x="119" y="109" width="10" height="20" fill="#16c784"/>
<line x1="142" y1="86" x2="142" y2="132" class="stroke-green"/><rect x="137" y="98" width="10" height="22" fill="#16c784"/>
<line x1="160" y1="74" x2="160" y2="127" class="stroke-red"/><rect x="155" y="88" width="10" height="30" fill="#ff5066"/>
<line x1="178" y1="91" x2="178" y2="135" class="stroke-green"/><rect x="173" y="103" width="10" height="23" fill="#16c784"/>
<line x1="196" y1="80" x2="196" y2="115" class="stroke-green"/><rect x="191" y="91" width="10" height="15" fill="#16c784"/>
<line x1="214" y1="62" x2="214" y2="111" class="stroke-red"/><rect x="209" y="74" width="10" height="27" fill="#ff5066"/>
<line x1="232" y1="67" x2="232" y2="102" class="stroke-green"/><rect x="227" y="78" width="10" height="15" fill="#16c784"/>
<line x1="250" y1="55" x2="250" y2="91" class="stroke-green"/><rect x="245" y="66" width="10" height="17" fill="#16c784"/>
<line x1="268" y1="66" x2="268" y2="119" class="stroke-red"/><rect x="263" y="78" width="10" height="30" fill="#ff5066"/>
<line x1="286" y1="77" x2="286" y2="116" class="stroke-green"/><rect x="281" y="87" width="10" height="20" fill="#16c784"/>
<line x1="304" y1="71" x2="304" y2="121" class="stroke-red"/><rect x="299" y="84" width="10" height="27" fill="#ff5066"/>
<line x1="322" y1="69" x2="322" y2="105" class="stroke-green"/><rect x="317" y="78" width="10" height="18" fill="#16c784"/>
</g>
<line x1="0" y1="83" x2="340" y2="83" stroke="#d8dde7" stroke-width="1" stroke-dasharray="6 6" opacity=".65"/>
<rect x="274" y="66" width="66" height="34" rx="5" fill="#11141a" stroke="#d8dde7"/>
<text x="332" y="87" text-anchor="end" class="text small mono">67 240</text>
<text x="0" y="264" class="muted tiny">EMA50</text>
<text x="54" y="264" class="amber tiny">EMA200</text>
<text x="264" y="264" class="muted tiny">26.06 22:00</text>
</g>
<g transform="translate(34 574)">
<rect class="panel2" x="0" y="0" width="340" height="124" rx="8"/>
<text x="16" y="29" class="muted small">Решение Torch</text>
<text x="16" y="62" class="green h2">Покупка разрешена</text>
<text x="194" y="31" class="muted small">Kelly</text>
<text x="194" y="62" class="text h2">18.4 USDT</text>
<line x1="16" y1="78" x2="324" y2="78" class="line"/>
<text x="16" y="103" class="muted small">Причина</text>
<text x="82" y="103" class="text small">Тренд 1D выше EMA200, MACD вверх</text>
</g>
<g transform="translate(34 716)">
<rect x="0" y="0" width="340" height="48" rx="8" fill="#10141c" stroke="#28303d"/>
<text x="26" y="30" class="muted nav">Рынки</text>
<text x="98" y="30" class="amber nav">AI</text>
<text x="154" y="30" class="muted nav">Риск</text>
<text x="218" y="30" class="muted nav">Активы</text>
<text x="286" y="30" class="muted nav">Настройки</text>
</g>
</g>
<!-- Screen 2 -->
<g class="shadow" transform="translate(590 116)">
<rect class="phone" x="0" y="0" width="420" height="820" rx="36"/>
<rect class="screen" x="16" y="16" width="388" height="788" rx="26"/>
<text x="34" y="48" class="text small">22:18</text>
<rect x="168" y="29" width="84" height="8" rx="4" fill="#151922"/>
<text x="338" y="48" class="text small">91%</text>
<text x="34" y="88" class="text h2">Рынки</text>
<text x="34" y="114" class="muted small">12 фиксированных spot-пар</text>
<rect x="34" y="136" width="340" height="42" rx="7" fill="#11141a" stroke="#242a36"/>
<text x="52" y="162" class="dim small">Поиск пары или сигнала</text>
<text x="34" y="209" class="text tab">AI-рейтинг</text>
<text x="141" y="209" class="muted tab">Цена</text>
<text x="226" y="209" class="muted tab">Edge</text>
<text x="304" y="209" class="muted tab">Статус</text>
<line x1="34" y1="221" x2="374" y2="221" class="line"/>
<g transform="translate(34 240)">
<rect x="0" y="0" width="340" height="46" fill="#151922"/>
<text x="0" y="19" class="text base">BTCUSDT</text><text x="0" y="38" class="muted tiny">P(up) 64% · Kelly 18.4</text>
<text x="154" y="28" text-anchor="end" class="text base mono">67 240</text>
<text x="230" y="28" text-anchor="end" class="green base">+0.82%</text>
<text x="336" y="28" text-anchor="end" class="green small">BUY</text>
<g transform="translate(0 56)"><text x="0" y="19" class="text base">ETHUSDT</text><text x="0" y="38" class="muted tiny">P(up) 57% · Kelly 6.1</text><text x="154" y="28" text-anchor="end" class="text base mono">3 524</text><text x="230" y="28" text-anchor="end" class="green base">+0.31%</text><text x="336" y="28" text-anchor="end" class="amber small">WAIT</text></g>
<g transform="translate(0 112)"><text x="0" y="19" class="text base">HYPEUSDT</text><text x="0" y="38" class="muted tiny">P(up) 49% · Kelly 0.0</text><text x="154" y="28" text-anchor="end" class="text base mono">39.18</text><text x="230" y="28" text-anchor="end" class="red base">-0.14%</text><text x="336" y="28" text-anchor="end" class="dim small">BLOCK</text></g>
<g transform="translate(0 168)"><text x="0" y="19" class="text base">SOLUSDT</text><text x="0" y="38" class="muted tiny">P(up) 61% · Kelly 12.7</text><text x="154" y="28" text-anchor="end" class="text base mono">144.62</text><text x="230" y="28" text-anchor="end" class="green base">+0.54%</text><text x="336" y="28" text-anchor="end" class="green small">BUY</text></g>
<g transform="translate(0 224)"><text x="0" y="19" class="text base">XRPUSDT</text><text x="0" y="38" class="muted tiny">P(up) 53% · Kelly 2.2</text><text x="154" y="28" text-anchor="end" class="text base mono">2.18</text><text x="230" y="28" text-anchor="end" class="green base">+0.08%</text><text x="336" y="28" text-anchor="end" class="amber small">WAIT</text></g>
<g transform="translate(0 280)"><text x="0" y="19" class="text base">XPLUSDT</text><text x="0" y="38" class="muted tiny">P(up) 46% · Kelly 0.0</text><text x="154" y="28" text-anchor="end" class="text base mono">0.923</text><text x="230" y="28" text-anchor="end" class="red base">-0.22%</text><text x="336" y="28" text-anchor="end" class="dim small">SKIP</text></g>
<g transform="translate(0 336)"><text x="0" y="19" class="text base">WLDUSDT</text><text x="0" y="38" class="muted tiny">P(up) 58% · Kelly 5.4</text><text x="154" y="28" text-anchor="end" class="text base mono">1.12</text><text x="230" y="28" text-anchor="end" class="green base">+0.27%</text><text x="336" y="28" text-anchor="end" class="amber small">WAIT</text></g>
<g transform="translate(0 392)"><text x="0" y="19" class="text base">MNTUSDT</text><text x="0" y="38" class="muted tiny">P(up) 52% · Kelly 1.4</text><text x="154" y="28" text-anchor="end" class="text base mono">1.05</text><text x="230" y="28" text-anchor="end" class="green base">+0.05%</text><text x="336" y="28" text-anchor="end" class="amber small">WAIT</text></g>
<g transform="translate(0 448)"><text x="0" y="19" class="text base">HUSDT</text><text x="0" y="38" class="muted tiny">P(up) 44% · Kelly 0.0</text><text x="154" y="28" text-anchor="end" class="text base mono">0.073</text><text x="230" y="28" text-anchor="end" class="red base">-0.35%</text><text x="336" y="28" text-anchor="end" class="dim small">SKIP</text></g>
</g>
<g transform="translate(34 716)">
<rect x="0" y="0" width="340" height="48" rx="8" fill="#10141c" stroke="#28303d"/>
<text x="26" y="30" class="amber nav">Рынки</text>
<text x="98" y="30" class="muted nav">AI</text>
<text x="154" y="30" class="muted nav">Риск</text>
<text x="218" y="30" class="muted nav">Активы</text>
<text x="286" y="30" class="muted nav">Настройки</text>
</g>
</g>
<!-- Screen 3 -->
<g class="shadow" transform="translate(1126 116)">
<rect class="phone" x="0" y="0" width="420" height="820" rx="36"/>
<rect class="screen" x="16" y="16" width="388" height="788" rx="26"/>
<text x="34" y="48" class="text small">22:18</text>
<rect x="168" y="29" width="84" height="8" rx="4" fill="#151922"/>
<text x="338" y="48" class="text small">91%</text>
<text x="34" y="88" class="text h2">Сделка и риск</text>
<text x="34" y="114" class="muted small">BTCUSDT · решение Torch</text>
<rect x="34" y="138" width="340" height="92" rx="8" fill="#121821" stroke="#16c784"/>
<text x="52" y="168" class="muted small">Действие</text>
<text x="52" y="204" class="green h1">BUY</text>
<text x="184" y="168" class="muted small">Размер сейчас</text>
<text x="184" y="204" class="text h2">18.4 USDT</text>
<text x="300" y="168" class="muted small">Риск</text>
<text x="300" y="204" class="amber h2">0.7%</text>
<g transform="translate(34 256)">
<text x="0" y="0" class="text h2">Kelly распределение</text>
<rect x="0" y="22" width="340" height="14" rx="3" fill="url(#riskLine)"/>
<text x="0" y="59" class="muted small">Цель по паре</text><text x="330" y="59" text-anchor="end" class="text small">31.0 USDT</text>
<text x="0" y="88" class="muted small">Уже занято</text><text x="330" y="88" text-anchor="end" class="text small">12.6 USDT</text>
<text x="0" y="117" class="muted small">Можно добавить</text><text x="330" y="117" text-anchor="end" class="green small">18.4 USDT</text>
</g>
<g transform="translate(34 402)">
<rect class="panel" x="0" y="0" width="340" height="164" rx="8"/>
<text x="16" y="30" class="text h2">Почему модель так решила</text>
<text x="16" y="66" class="green small">Edge: +0.82%</text>
<text x="170" y="66" class="green small">P(up): 64%</text>
<text x="16" y="96" class="text small">EMA50 выше EMA200 на 1D</text>
<text x="16" y="124" class="text small">MACD пересёк signal вверх на 1h</text>
<text x="16" y="152" class="muted small">RSI 58, ATR в норме, спред 0.03%</text>
</g>
<g transform="translate(34 592)">
<rect class="soft" x="0" y="0" width="340" height="72" rx="8"/>
<text x="16" y="28" class="muted small">Live готовность</text>
<text x="16" y="55" class="amber base">2 шага не пройдены</text>
<text x="188" y="55" class="muted small">API ключи · лимит ордера</text>
</g>
<g transform="translate(34 684)">
<rect x="0" y="0" width="162" height="48" rx="7" fill="#161b24" stroke="#ff5066"/>
<text x="81" y="30" text-anchor="middle" class="red base">Стоп бот</text>
<rect x="178" y="0" width="162" height="48" rx="7" fill="#16251f" stroke="#16c784"/>
<text x="259" y="30" text-anchor="middle" class="green base">Запустить цикл</text>
</g>
<g transform="translate(34 748)">
<text x="0" y="0" class="dim tiny">Все действия требуют серверного подтверждения. Приложение показывает риск до сделки.</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,2 @@
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
@@ -0,0 +1,25 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
resolutionStrategy {
eachPlugin {
if (requested.id.id == "org.jetbrains.kotlin.android") {
useModule("org.jetbrains.kotlin:kotlin-gradle-plugin:${requested.version ?: "2.2.10"}")
}
}
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TradeBotMonitor"
include(":app")
+351
View File
@@ -0,0 +1,351 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from crypto_spot_bot.config import Settings
from crypto_spot_bot.storage import Storage
def analytics_snapshot(settings: Settings, storage: Storage) -> dict[str, Any]:
closed = _closed_trades_with_diagnostics(storage, settings.learning_lookback_trades)
return {
"pnl": pnl_attribution(closed),
"probability_calibration": probability_calibration(closed),
"drift": drift_snapshot(settings, closed, storage.recent_signals(240)),
"risk_guard": risk_guard_snapshot(settings, closed, storage.latest_equity()),
}
def pnl_attribution(closed_trades: list[dict[str, Any]]) -> dict[str, Any]:
total = _trade_stats(closed_trades)
by_symbol = _group_stats(closed_trades, lambda trade: str(trade.get("symbol", "")))
by_exit = _group_stats(closed_trades, lambda trade: _exit_category(str(trade.get("reason", ""))))
by_model = _group_stats(closed_trades, lambda trade: _entry_model(trade))
recent = [_trade_summary(trade) for trade in closed_trades[:20]]
return {
"total": total,
"by_symbol": by_symbol,
"by_exit": by_exit,
"by_model": by_model,
"recent": recent,
}
def probability_calibration(closed_trades: list[dict[str, Any]]) -> dict[str, Any]:
buckets: dict[str, list[dict[str, Any]]] = {}
for trade in closed_trades:
probability = _entry_probability(trade)
if probability is None:
continue
low = max(0.0, min(0.95, int(probability * 20) / 20))
high = low + 0.05
key = f"{low:.2f}-{high:.2f}"
buckets.setdefault(key, []).append(trade)
rows = []
for key in sorted(buckets):
trades = buckets[key]
stats = _trade_stats(trades)
predicted = [_entry_probability(trade) for trade in trades]
avg_probability = sum(value for value in predicted if value is not None) / len(trades)
rows.append(
{
"bucket": key,
"trades": len(trades),
"avg_probability": round(avg_probability, 4),
"actual_win_rate": stats["win_rate"],
"calibration_error": round(stats["win_rate"] - avg_probability, 4),
"net_pnl": stats["net_pnl"],
"avg_net_percent": stats["avg_net_percent"],
}
)
status = "insufficient"
if sum(row["trades"] for row in rows) >= 12:
avg_abs_error = sum(abs(row["calibration_error"]) * row["trades"] for row in rows) / sum(row["trades"] for row in rows)
status = "ok" if avg_abs_error <= 0.12 else "warn"
else:
avg_abs_error = None
return {
"status": status,
"buckets": rows,
"samples": sum(row["trades"] for row in rows),
"avg_abs_error": round(avg_abs_error, 4) if avg_abs_error is not None else None,
}
def drift_snapshot(settings: Settings, closed_trades: list[dict[str, Any]], signals: list[dict[str, Any]]) -> dict[str, Any]:
window = max(4, settings.risk_recent_trade_window)
recent = closed_trades[:window]
previous = closed_trades[window : window * 2]
recent_stats = _trade_stats(recent)
previous_stats = _trade_stats(previous)
failed_checks = _failed_check_counts(signals)
status = "insufficient"
warnings: list[str] = []
if recent_stats["trades"] >= max(4, min(window, 8)):
status = "ok"
if recent_stats["profit_factor"] < settings.risk_min_recent_profit_factor:
warnings.append("recent_profit_factor_below_min")
if recent_stats["avg_net_percent"] <= 0:
warnings.append("recent_expectancy_non_positive")
if _consecutive_losses(closed_trades) >= settings.risk_max_consecutive_losses:
warnings.append("consecutive_losses")
if warnings:
status = "warn"
return {
"status": status,
"warnings": warnings,
"recent": recent_stats,
"previous": previous_stats,
"failed_checks": failed_checks,
}
def risk_guard_snapshot(
settings: Settings,
closed_trades: list[dict[str, Any]],
latest_equity: dict[str, Any] | None,
) -> dict[str, Any]:
if not settings.risk_guard_enabled:
return {
"enabled": False,
"block_new_entries": False,
"position_size_multiplier": 1.0,
"symbol_guard_enabled": settings.risk_symbol_guard_enabled,
"blocked_symbols": [],
"symbols": [],
"reasons": [],
}
active_trades = _active_universe_trades(settings, closed_trades)
reasons: list[str] = []
degraded_reasons: list[str] = []
consecutive_losses = _consecutive_losses(active_trades)
if consecutive_losses >= settings.risk_max_consecutive_losses:
degraded_reasons.append("consecutive_losses")
today_pnl = _today_pnl(active_trades)
if today_pnl <= -abs(settings.max_daily_drawdown_usdt):
reasons.append("daily_loss_limit")
window = max(4, settings.risk_recent_trade_window)
recent_stats = _trade_stats(active_trades[:window])
if recent_stats["trades"] >= max(4, min(window, 8)):
if recent_stats["profit_factor"] < settings.risk_min_recent_profit_factor:
degraded_reasons.append("recent_profit_factor_below_min")
if recent_stats["avg_net_percent"] <= 0:
degraded_reasons.append("recent_expectancy_non_positive")
latest_drawdown = float((latest_equity or {}).get("drawdown", 0.0) or 0.0)
if latest_drawdown >= abs(settings.max_daily_drawdown_usdt):
reasons.append("equity_drawdown_limit")
symbol_stats = _symbol_guard_stats(settings, active_trades)
blocked_symbols = sorted(row["symbol"] for row in symbol_stats if row["block_new_entries"])
block = bool(reasons)
all_reasons = reasons + degraded_reasons
multiplier = 0.0 if block else (settings.risk_reduce_multiplier if degraded_reasons else 1.0)
return {
"enabled": True,
"block_new_entries": block,
"position_size_multiplier": round(max(0.0, min(1.0, multiplier)), 4),
"reasons": all_reasons,
"global_reasons": reasons,
"degraded_reasons": degraded_reasons,
"symbol_guard_enabled": settings.risk_symbol_guard_enabled,
"blocked_symbols": blocked_symbols,
"symbols": symbol_stats,
"consecutive_losses": consecutive_losses,
"today_pnl": round(today_pnl, 6),
"recent": recent_stats,
}
def _closed_trades_with_diagnostics(storage: Storage, limit: int) -> list[dict[str, Any]]:
rows = storage.closed_trades(limit)
for row in rows:
row["entry_diagnostics"] = _json_or_default(row.get("entry_diagnostics_json"), {})
return rows
def _trade_stats(trades: list[dict[str, Any]]) -> dict[str, Any]:
values = [float(trade.get("net_pnl", 0.0) or 0.0) for trade in trades]
wins = [value for value in values if value > 0]
losses = [value for value in values if value < 0]
gross_profit = sum(wins)
gross_loss = abs(sum(losses))
profit_factor = gross_profit / gross_loss if gross_loss > 0 else (999.0 if gross_profit > 0 else 0.0)
percents = [_trade_net_percent(trade) for trade in trades]
return {
"trades": len(trades),
"wins": len(wins),
"losses": len(losses),
"win_rate": round(len(wins) / len(trades), 4) if trades else 0.0,
"net_pnl": round(sum(values), 6),
"fees": round(sum(float(trade.get("fee_usdt", 0.0) or 0.0) for trade in trades), 6),
"avg_net_pnl": round(sum(values) / len(trades), 6) if trades else 0.0,
"avg_net_percent": round(sum(percents) / len(percents), 4) if percents else 0.0,
"profit_factor": round(profit_factor, 4),
"best": round(max(values), 6) if values else 0.0,
"worst": round(min(values), 6) if values else 0.0,
}
def _group_stats(trades: list[dict[str, Any]], key_fn) -> list[dict[str, Any]]:
groups: dict[str, list[dict[str, Any]]] = {}
for trade in trades:
key = key_fn(trade) or "unknown"
groups.setdefault(key, []).append(trade)
rows = [{"key": key, **_trade_stats(items)} for key, items in groups.items()]
return sorted(rows, key=lambda row: (row["net_pnl"], row["trades"]), reverse=True)
def _active_universe_trades(settings: Settings, trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
symbols = {symbol.upper() for symbol in settings.symbols}
if not symbols:
return trades
return [trade for trade in trades if str(trade.get("symbol", "")).upper() in symbols]
def _symbol_guard_stats(settings: Settings, trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
expectancy_min_samples = max(6, min(settings.risk_recent_trade_window, 10))
loss_streak_min_samples = max(3, settings.risk_max_consecutive_losses)
symbol_guard_enabled = settings.risk_symbol_guard_enabled
rows: list[dict[str, Any]] = []
for symbol in settings.symbols:
symbol_trades = [trade for trade in trades if str(trade.get("symbol", "")).upper() == symbol.upper()]
recent = symbol_trades[: settings.risk_recent_trade_window]
stats = _trade_stats(recent)
losses = _consecutive_losses(recent)
reasons: list[str] = []
if symbol_guard_enabled:
if stats["trades"] >= expectancy_min_samples:
if stats["profit_factor"] < settings.risk_min_recent_profit_factor and stats["avg_net_percent"] <= 0:
reasons.append("symbol_expectancy_negative")
if stats["trades"] >= loss_streak_min_samples:
if losses >= settings.risk_max_consecutive_losses:
reasons.append("symbol_consecutive_losses")
rows.append(
{
"symbol": symbol.upper(),
"block_new_entries": bool(reasons) if symbol_guard_enabled else False,
"reasons": reasons,
"symbol_guard_enabled": symbol_guard_enabled,
"consecutive_losses": losses,
**stats,
}
)
return rows
def _trade_summary(trade: dict[str, Any]) -> dict[str, Any]:
diagnostics = trade.get("entry_diagnostics") if isinstance(trade.get("entry_diagnostics"), dict) else {}
forecast = diagnostics.get("forecast") if isinstance(diagnostics.get("forecast"), dict) else {}
return {
"id": trade.get("id"),
"symbol": trade.get("symbol"),
"net_pnl": round(float(trade.get("net_pnl", 0.0) or 0.0), 6),
"net_percent": _trade_net_percent(trade),
"fee_usdt": round(float(trade.get("fee_usdt", 0.0) or 0.0), 6),
"entry_price": trade.get("entry_price"),
"exit_price": trade.get("exit_price"),
"closed_at": trade.get("closed_at"),
"exit_category": _exit_category(str(trade.get("reason", ""))),
"entry_probability": forecast.get("probability_up"),
"entry_expected_percent": forecast.get("expected_return_percent"),
"entry_confidence": trade.get("entry_confidence"),
"model": forecast.get("model"),
}
def _trade_net_percent(trade: dict[str, Any]) -> float:
qty = float(trade.get("qty", 0.0) or 0.0)
entry_price = float(trade.get("entry_price", 0.0) or 0.0)
notional = qty * entry_price
if notional <= 0:
return 0.0
return round(float(trade.get("net_pnl", 0.0) or 0.0) / notional * 100, 4)
def _exit_category(reason: str) -> str:
text = reason.lower()
if "stop" in text:
return "stop_loss"
if "trailing" in text:
return "trailing_stop"
if "negative" in text or "turned" in text:
return "forecast_negative"
if "weak" in text or "enough edge" in text:
return "forecast_weak"
if "take" in text:
return "take_profit"
return "other"
def _entry_model(trade: dict[str, Any]) -> str:
diagnostics = trade.get("entry_diagnostics") if isinstance(trade.get("entry_diagnostics"), dict) else {}
forecast = diagnostics.get("forecast") if isinstance(diagnostics.get("forecast"), dict) else {}
return str(forecast.get("model") or "unknown")
def _entry_probability(trade: dict[str, Any]) -> float | None:
diagnostics = trade.get("entry_diagnostics") if isinstance(trade.get("entry_diagnostics"), dict) else {}
forecast = diagnostics.get("forecast") if isinstance(diagnostics.get("forecast"), dict) else {}
value = forecast.get("probability_up")
try:
probability = float(value)
except (TypeError, ValueError):
return None
return max(0.0, min(1.0, probability))
def _failed_check_counts(signals: list[dict[str, Any]]) -> dict[str, int]:
counts: dict[str, int] = {}
for signal in signals:
diagnostics = _json_or_default(signal.get("diagnostics_json"), {})
checks = diagnostics.get("checks") if isinstance(diagnostics, dict) else {}
if not isinstance(checks, dict):
continue
for key, ok in checks.items():
if not ok:
counts[str(key)] = counts.get(str(key), 0) + 1
return dict(sorted(counts.items(), key=lambda item: item[1], reverse=True))
def _consecutive_losses(closed_trades: list[dict[str, Any]]) -> int:
count = 0
for trade in closed_trades:
if float(trade.get("net_pnl", 0.0) or 0.0) < 0:
count += 1
else:
break
return count
def _today_pnl(closed_trades: list[dict[str, Any]]) -> float:
today = datetime.now(timezone.utc).date()
total = 0.0
for trade in closed_trades:
closed_at = _parse_datetime(trade.get("closed_at"))
if closed_at and closed_at.date() == today:
total += float(trade.get("net_pnl", 0.0) or 0.0)
return total
def _json_or_default(value: Any, default: Any) -> Any:
if isinstance(value, (dict, list)):
return value
if not isinstance(value, str):
return default
try:
return json.loads(value)
except json.JSONDecodeError:
return default
def _parse_datetime(value: Any) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
+95 -8
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from crypto_spot_bot.analytics import risk_guard_snapshot
from crypto_spot_bot.config import Settings from crypto_spot_bot.config import Settings
from crypto_spot_bot.execution import LiveBroker, PaperBroker from crypto_spot_bot.execution import LiveBroker, PaperBroker
from crypto_spot_bot.learning import TradeLearner from crypto_spot_bot.learning import TradeLearner
@@ -123,6 +124,11 @@ class CryptoSpotBot:
async def _process_entries(self) -> None: async def _process_entries(self) -> None:
prices = self.market.prices() prices = self.market.prices()
risk_guard = risk_guard_snapshot(
self.settings,
self.storage.closed_trades(self.settings.learning_lookback_trades),
self.storage.latest_equity(),
)
for symbol in self.market.symbols: for symbol in self.market.symbols:
cooldown_since = self._entry_cooldown_until.get(symbol) cooldown_since = self._entry_cooldown_until.get(symbol)
if cooldown_since: if cooldown_since:
@@ -134,7 +140,7 @@ class CryptoSpotBot:
symbol, symbol,
"HOLD", "HOLD",
0.0, 0.0,
"пауза после закрытия позиции", "пауза между входами по паре",
{"cooldown_remaining_seconds": cooldown_seconds - age}, {"cooldown_remaining_seconds": cooldown_seconds - age},
) )
) )
@@ -144,11 +150,49 @@ class CryptoSpotBot:
candles = self.market.candles.get(symbol, []) candles = self.market.candles.get(symbol, [])
trend_candles = self.market.trend_candles.get(symbol, []) trend_candles = self.market.trend_candles.get(symbol, [])
open_count = len(self.broker.positions_for_symbol(symbol)) open_count = len(self.broker.positions_for_symbol(symbol))
instrument = self.market.instruments.get(symbol)
pattern = self.market.patterns.get(symbol, {}) pattern = self.market.patterns.get(symbol, {})
forecast = self.market.forecasts.get(symbol, {}) forecast = self.market.forecasts.get(symbol, {})
learning = self.learner.adjustment_for(symbol, str(pattern.get("label", ""))).as_dict() learning = self.learner.adjustment_for(symbol, str(pattern.get("label", ""))).as_dict()
learning["adaptive_rules"] = self._with_exposure_context(learning.get("adaptive_rules") or {}) learning["adaptive_rules"] = self._with_exposure_context(learning.get("adaptive_rules") or {})
account = self.broker.account_state(prices) account = self.broker.account_state(prices)
account["risk_guard"] = risk_guard
account["symbol"] = symbol
account["symbol_exposure_usdt"] = self.broker.symbol_exposure(symbol)
account["open_positions_for_symbol"] = open_count
account["exchange_min_entry_usdt"] = self.broker.minimum_entry_budget(instrument, ticker)
if risk_guard.get("block_new_entries"):
self.storage.insert_signal(
Signal(
symbol,
"HOLD",
0.0,
"risk_guard: new entries blocked",
{
"strategy_mode": self.settings.strategy_mode,
"risk_guard": risk_guard,
"checks": {"risk_guard_ok": False},
},
)
)
continue
symbol_guard = self._risk_guard_for_symbol(risk_guard, symbol)
if symbol_guard.get("block_new_entries"):
self.storage.insert_signal(
Signal(
symbol,
"HOLD",
0.0,
"risk_guard: symbol blocked",
{
"strategy_mode": self.settings.strategy_mode,
"risk_guard": risk_guard,
"symbol_guard": symbol_guard,
"checks": {"risk_guard_symbol_ok": False},
},
)
)
continue
llm = {} llm = {}
if ( if (
self.settings.llm_advisor_enabled self.settings.llm_advisor_enabled
@@ -182,12 +226,25 @@ class CryptoSpotBot:
) )
self.storage.insert_signal(signal) self.storage.insert_signal(signal)
if signal.action == "BUY" and ticker is not None: if signal.action == "BUY" and ticker is not None:
self.broker.buy( position = self.broker.buy(
signal, signal,
ticker, ticker,
self.market.instruments.get(symbol), instrument,
prices, prices,
) )
if position is not None:
self._entry_cooldown_until[symbol] = utc_now()
@staticmethod
def _risk_guard_for_symbol(risk_guard: dict, symbol: str) -> dict:
rows = risk_guard.get("symbols")
if not isinstance(rows, list):
return {}
symbol_upper = symbol.upper()
for row in rows:
if isinstance(row, dict) and str(row.get("symbol", "")).upper() == symbol_upper:
return row
return {}
def _with_exposure_context(self, rules: dict) -> dict: def _with_exposure_context(self, rules: dict) -> dict:
enriched = dict(rules) enriched = dict(rules)
@@ -242,7 +299,12 @@ class CryptoSpotBot:
return worst.id return worst.id
def _update_patterns(self) -> None: def _update_patterns(self) -> None:
if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} or not self.settings.pattern_analysis_enabled: patterns_needed = (
self.settings.pattern_analysis_enabled
or self.settings.grid_trading_enabled
or self.settings.rebound_trading_enabled
)
if self.settings.strategy_mode == "trend_macd" or not patterns_needed:
self.market.patterns = {} self.market.patterns = {}
return return
patterns: dict[str, dict] = {} patterns: dict[str, dict] = {}
@@ -296,10 +358,35 @@ class CryptoSpotBot:
def positions_snapshot(self) -> list[dict]: def positions_snapshot(self) -> list[dict]:
prices = self.market.prices() prices = self.market.prices()
return [ items: list[dict] = []
position.as_dict(mark_price=prices.get(position.symbol, position.entry_price)) for position in self.broker.open_positions():
for position in self.broker.open_positions() mark_price = prices.get(position.symbol, position.entry_price)
] item = position.as_dict(mark_price=mark_price)
exit_signal = self.strategy.exit_signal(
position=position,
candles=self.market.candles.get(position.symbol, []),
ticker=self.market.tickers.get(position.symbol),
learning=self.learner.state.as_dict(),
forecast=self.market.forecasts.get(position.symbol, {}),
)
diagnostics = exit_signal.diagnostics or {}
fallback_stop_loss = position.stop_loss if self.settings.stop_loss_exit_enabled else None
item["exit_plan"] = {
"action": exit_signal.action,
"reason": exit_signal.reason,
"confidence": exit_signal.confidence,
"stop_loss": diagnostics.get("stop_loss", fallback_stop_loss),
"take_profit": diagnostics.get("take_profit", position.take_profit),
"trailing_stop": diagnostics.get("trailing_stop"),
"atr_trailing_stop": diagnostics.get("atr_trailing_stop"),
"highest_price": diagnostics.get("highest_price", position.highest_price),
"stop_loss_exit_enabled": diagnostics.get(
"stop_loss_exit_enabled",
self.settings.stop_loss_exit_enabled,
),
}
items.append(item)
return items
def learning_snapshot(self) -> dict: def learning_snapshot(self) -> dict:
snapshot = self.learner.state.as_dict() snapshot = self.learner.state.as_dict()
+25
View File
@@ -63,6 +63,19 @@ class BybitClient:
response.raise_for_status() response.raise_for_status()
return self._unwrap(response.json()) return self._unwrap(response.json())
def private_get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
query_params = sorted((key, value) for key, value in params.items() if value is not None)
query = urlencode(query_params)
headers = self._headers(query)
response = self.session.get(
f"{self.settings.rest_base_url}{path}",
params=query_params,
headers=headers,
timeout=15,
)
response.raise_for_status()
return self._unwrap(response.json())
def _headers(self, payload: str) -> dict[str, str]: def _headers(self, payload: str) -> dict[str, str]:
timestamp = str(int(time.time() * 1000)) timestamp = str(int(time.time() * 1000))
recv_window = "5000" recv_window = "5000"
@@ -204,6 +217,18 @@ class BybitClient:
} }
return self.private_post("/v5/order/create", payload) return self.private_post("/v5/order/create", payload)
def wallet_balance(self, account_type: str = "UNIFIED", coin: str | None = None) -> dict[str, Any]:
return self.private_get(
"/v5/account/wallet-balance",
{"accountType": account_type, "coin": coin},
)
def realtime_orders(self, *, category: str = "spot", open_only: int = 0, limit: int = 50) -> dict[str, Any]:
return self.private_get(
"/v5/order/realtime",
{"category": category, "openOnly": open_only, "limit": max(1, min(limit, 50))},
)
def websocket_subscribe_message(symbols: list[str], interval: str = "1") -> str: def websocket_subscribe_message(symbols: list[str], interval: str = "1") -> str:
args: list[str] = [] args: list[str] = []
+49 -8
View File
@@ -5,7 +5,20 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
FIXED_SPOT_SYMBOLS = ("BTCUSDT", "ETHUSDT", "SOLUSDT", "LTCUSDT") FIXED_SPOT_SYMBOLS = (
"BTCUSDT",
"ETHUSDT",
"HYPEUSDT",
"SOLUSDT",
"XRPUSDT",
"XPLUSDT",
"WLDUSDT",
"MNTUSDT",
"HUSDT",
"XAUTUSDT",
"IPUSDT",
"AAVEUSDT",
)
STRATEGY_MODES = {"legacy", "trend_macd", "torch_forecast"} STRATEGY_MODES = {"legacy", "trend_macd", "torch_forecast"}
@@ -101,6 +114,12 @@ class Settings:
kelly_fraction: float kelly_fraction: float
kelly_max_fraction: float kelly_max_fraction: float
risk_per_trade_percent: float risk_per_trade_percent: float
risk_guard_enabled: bool
risk_symbol_guard_enabled: bool
risk_recent_trade_window: int
risk_max_consecutive_losses: int
risk_min_recent_profit_factor: float
risk_reduce_multiplier: float
atr_trailing_multiplier: float atr_trailing_multiplier: float
trend_rsi_min: float trend_rsi_min: float
trend_rsi_max: float trend_rsi_max: float
@@ -108,13 +127,22 @@ class Settings:
time_series_min_candles: int time_series_min_candles: int
time_series_forecast_horizon: int time_series_forecast_horizon: int
time_series_min_edge_percent: float time_series_min_edge_percent: float
time_series_min_probability_up: float
time_series_min_confidence: float
time_series_max_adjustment: float time_series_max_adjustment: float
time_series_lstm_enabled: bool time_series_lstm_enabled: bool
time_series_lstm_model_path: Path time_series_lstm_model_path: Path
time_series_probe_enabled: bool
time_series_probe_min_edge_percent: float
time_series_probe_min_probability_up: float
time_series_probe_size_multiplier: float
time_series_rebound_fallback_enabled: bool
stop_loss_percent: float stop_loss_percent: float
stop_loss_exit_enabled: bool
take_profit_percent: float take_profit_percent: float
trailing_stop_percent: float trailing_stop_percent: float
min_hold_seconds: int min_hold_seconds: int
min_exit_net_percent: float
entry_cooldown_seconds: int entry_cooldown_seconds: int
max_daily_drawdown_usdt: float max_daily_drawdown_usdt: float
min_cash_reserve_usdt: float min_cash_reserve_usdt: float
@@ -179,12 +207,10 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast") raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast")
auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", False) auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", False)
top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS)) top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS))
symbols = _symbols_env("SYMBOLS") or FIXED_SPOT_SYMBOLS requested_symbols = _symbols_env("SYMBOLS")
if strategy_mode == "torch_forecast": symbols = requested_symbols if requested_symbols else (() if auto_select_symbols else FIXED_SPOT_SYMBOLS)
auto_select_symbols = False
top_symbols_count = len(FIXED_SPOT_SYMBOLS)
symbols = FIXED_SPOT_SYMBOLS
forecast_enabled_default = strategy_mode == "torch_forecast" forecast_enabled_default = strategy_mode == "torch_forecast"
min_signal_confidence = _float_env("MIN_SIGNAL_CONFIDENCE", 0.64)
settings = Settings( settings = Settings(
trading_mode=mode, trading_mode=mode,
host=os.getenv("HOST", "127.0.0.1"), host=os.getenv("HOST", "127.0.0.1"),
@@ -207,7 +233,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
fast_entry_cooldown_seconds=_int_env("FAST_ENTRY_COOLDOWN_SECONDS", 20), fast_entry_cooldown_seconds=_int_env("FAST_ENTRY_COOLDOWN_SECONDS", 20),
max_entries_per_minute=_int_env("MAX_ENTRIES_PER_MINUTE", 12), max_entries_per_minute=_int_env("MAX_ENTRIES_PER_MINUTE", 12),
websocket_enabled=_bool_env("WEBSOCKET_ENABLED", True), websocket_enabled=_bool_env("WEBSOCKET_ENABLED", True),
min_signal_confidence=_float_env("MIN_SIGNAL_CONFIDENCE", 0.64), min_signal_confidence=min_signal_confidence,
max_spread_percent=_float_env("MAX_SPREAD_PERCENT", 0.18), max_spread_percent=_float_env("MAX_SPREAD_PERCENT", 0.18),
min_24h_turnover_usdt=_float_env("MIN_24H_TURNOVER_USDT", 1000000.0), min_24h_turnover_usdt=_float_env("MIN_24H_TURNOVER_USDT", 1000000.0),
pattern_analysis_enabled=_bool_env("PATTERN_ANALYSIS_ENABLED", False), pattern_analysis_enabled=_bool_env("PATTERN_ANALYSIS_ENABLED", False),
@@ -241,20 +267,35 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
kelly_fraction=_float_env("KELLY_FRACTION", 0.25), kelly_fraction=_float_env("KELLY_FRACTION", 0.25),
kelly_max_fraction=_float_env("KELLY_MAX_FRACTION", 0.20), kelly_max_fraction=_float_env("KELLY_MAX_FRACTION", 0.20),
risk_per_trade_percent=_float_env("RISK_PER_TRADE_PERCENT", 0.01), risk_per_trade_percent=_float_env("RISK_PER_TRADE_PERCENT", 0.01),
risk_guard_enabled=_bool_env("RISK_GUARD_ENABLED", True),
risk_symbol_guard_enabled=_bool_env("RISK_SYMBOL_GUARD_ENABLED", True),
risk_recent_trade_window=_int_env("RISK_RECENT_TRADE_WINDOW", 20),
risk_max_consecutive_losses=_int_env("RISK_MAX_CONSECUTIVE_LOSSES", 4),
risk_min_recent_profit_factor=_float_env("RISK_MIN_RECENT_PROFIT_FACTOR", 0.85),
risk_reduce_multiplier=_float_env("RISK_REDUCE_MULTIPLIER", 0.50),
atr_trailing_multiplier=_float_env("ATR_TRAILING_MULTIPLIER", 2.2), atr_trailing_multiplier=_float_env("ATR_TRAILING_MULTIPLIER", 2.2),
trend_rsi_min=_float_env("TREND_RSI_MIN", 45.0), trend_rsi_min=_float_env("TREND_RSI_MIN", 45.0),
trend_rsi_max=_float_env("TREND_RSI_MAX", 65.0), trend_rsi_max=_float_env("TREND_RSI_MAX", 65.0),
time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", forecast_enabled_default), time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", forecast_enabled_default),
time_series_min_candles=_int_env("TIME_SERIES_MIN_CANDLES", 120), time_series_min_candles=_int_env("TIME_SERIES_MIN_CANDLES", 120),
time_series_forecast_horizon=_int_env("TIME_SERIES_FORECAST_HORIZON", 3), time_series_forecast_horizon=_int_env("TIME_SERIES_FORECAST_HORIZON", 3),
time_series_min_edge_percent=_float_env("TIME_SERIES_MIN_EDGE_PERCENT", 0.04), time_series_min_edge_percent=_float_env("TIME_SERIES_MIN_EDGE_PERCENT", 0.08),
time_series_min_probability_up=_float_env("TIME_SERIES_MIN_PROBABILITY_UP", 0.58),
time_series_min_confidence=_float_env("TIME_SERIES_MIN_CONFIDENCE", 0.4),
time_series_max_adjustment=_float_env("TIME_SERIES_MAX_ADJUSTMENT", 0.08), time_series_max_adjustment=_float_env("TIME_SERIES_MAX_ADJUSTMENT", 0.08),
time_series_lstm_enabled=_bool_env("TIME_SERIES_LSTM_ENABLED", True), time_series_lstm_enabled=_bool_env("TIME_SERIES_LSTM_ENABLED", True),
time_series_lstm_model_path=Path(os.getenv("TIME_SERIES_LSTM_MODEL_PATH", "runtime/lstm_forecaster.json")), time_series_lstm_model_path=Path(os.getenv("TIME_SERIES_LSTM_MODEL_PATH", "runtime/lstm_forecaster.json")),
time_series_probe_enabled=_bool_env("TIME_SERIES_PROBE_ENABLED", True),
time_series_probe_min_edge_percent=_float_env("TIME_SERIES_PROBE_MIN_EDGE_PERCENT", 0.02),
time_series_probe_min_probability_up=_float_env("TIME_SERIES_PROBE_MIN_PROBABILITY_UP", 0.55),
time_series_probe_size_multiplier=_float_env("TIME_SERIES_PROBE_SIZE_MULTIPLIER", 0.40),
time_series_rebound_fallback_enabled=_bool_env("TIME_SERIES_REBOUND_FALLBACK_ENABLED", True),
stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.04), stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.04),
stop_loss_exit_enabled=_bool_env("STOP_LOSS_EXIT_ENABLED", True),
take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035), take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035),
trailing_stop_percent=_float_env("TRAILING_STOP_PERCENT", 0.015), trailing_stop_percent=_float_env("TRAILING_STOP_PERCENT", 0.015),
min_hold_seconds=_int_env("MIN_HOLD_SECONDS", 180), min_hold_seconds=_int_env("MIN_HOLD_SECONDS", 180),
min_exit_net_percent=_float_env("MIN_EXIT_NET_PERCENT", 0.20),
entry_cooldown_seconds=_int_env("ENTRY_COOLDOWN_SECONDS", 180), entry_cooldown_seconds=_int_env("ENTRY_COOLDOWN_SECONDS", 180),
max_daily_drawdown_usdt=_float_env("MAX_DAILY_DRAWDOWN_USDT", 6.0), max_daily_drawdown_usdt=_float_env("MAX_DAILY_DRAWDOWN_USDT", 6.0),
min_cash_reserve_usdt=_float_env("MIN_CASH_RESERVE_USDT", 5.0), min_cash_reserve_usdt=_float_env("MIN_CASH_RESERVE_USDT", 5.0),
+110 -813
View File
@@ -4,9 +4,10 @@ import json
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any from typing import Any
from fastapi import FastAPI, Response from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from fastapi.responses import JSONResponse, PlainTextResponse
from crypto_spot_bot.analytics import analytics_snapshot
from crypto_spot_bot.bot import CryptoSpotBot from crypto_spot_bot.bot import CryptoSpotBot
from crypto_spot_bot.bybit import BybitClient from crypto_spot_bot.bybit import BybitClient
from crypto_spot_bot.config import Settings, load_settings, update_env_value from crypto_spot_bot.config import Settings, load_settings, update_env_value
@@ -14,9 +15,14 @@ from crypto_spot_bot.execution import LiveBroker, PaperBroker
from crypto_spot_bot.learning import TradeLearner from crypto_spot_bot.learning import TradeLearner
from crypto_spot_bot.market_data import MarketData from crypto_spot_bot.market_data import MarketData
from crypto_spot_bot.patterns import PatternAnalyzer from crypto_spot_bot.patterns import PatternAnalyzer
from crypto_spot_bot.reconciliation import reconciliation_snapshot
from crypto_spot_bot.storage import Storage from crypto_spot_bot.storage import Storage
from crypto_spot_bot.strategy import SpotStrategy from crypto_spot_bot.strategy import SpotStrategy
from crypto_spot_bot.time_series import TimeSeriesForecaster from crypto_spot_bot.time_series import TimeSeriesForecaster
from crypto_spot_bot.training_coordination import TrainingCoordinator
WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints."
def create_app(settings: Settings | None = None) -> FastAPI: def create_app(settings: Settings | None = None) -> FastAPI:
@@ -37,6 +43,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
learner = TradeLearner(settings, storage) learner = TradeLearner(settings, storage)
forecaster = TimeSeriesForecaster(settings) forecaster = TimeSeriesForecaster(settings)
bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster) bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster)
training = TrainingCoordinator(settings.time_series_lstm_model_path.parent)
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
@@ -51,10 +58,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.storage = storage app.state.storage = storage
app.state.bot = bot app.state.bot = bot
app.state.market = market app.state.market = market
app.state.training = training
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=PlainTextResponse, status_code=410)
async def index() -> str: async def index() -> str:
return HTML return WEB_UI_REMOVED_MESSAGE
@app.get("/api/health") @app.get("/api/health")
async def health() -> dict[str, Any]: async def health() -> dict[str, Any]:
@@ -76,7 +84,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/api/trades") @app.get("/api/trades")
async def trades(limit: int = 80) -> dict[str, Any]: async def trades(limit: int = 80) -> dict[str, Any]:
return {"items": storage.recent_trades(_limit(limit))} row_limit = _limit(limit)
return {
"items": storage.recent_trades(row_limit),
"closed_items": storage.closed_trades(row_limit),
"closed_summary": storage.closed_trade_summary(),
}
@app.get("/api/signals") @app.get("/api/signals")
async def signals(limit: int = 120) -> dict[str, Any]: async def signals(limit: int = 120) -> dict[str, Any]:
@@ -86,6 +99,70 @@ def create_app(settings: Settings | None = None) -> FastAPI:
async def events(limit: int = 120) -> dict[str, Any]: async def events(limit: int = 120) -> dict[str, Any]:
return {"items": storage.recent_events(_limit(limit))} return {"items": storage.recent_events(_limit(limit))}
@app.get("/api/analytics")
async def analytics() -> dict[str, Any]:
return analytics_snapshot(settings, storage)
@app.get("/api/quality")
async def quality() -> dict[str, Any]:
return market.snapshot().get("quality", {})
@app.get("/api/reconciliation")
async def reconciliation() -> dict[str, Any]:
return reconciliation_snapshot(
settings=settings,
storage=storage,
client=client,
instruments=market.instruments,
)
@app.get("/api/backtest")
async def backtest() -> dict[str, Any]:
return _runtime_json(settings, "torch_threshold_calibration.json")
@app.get("/api/retrain")
async def retrain() -> dict[str, Any]:
data = _runtime_json(settings, "torch_retrain_guard.json")
data["coordination"] = training.status()
return data
@app.get("/api/training/status")
async def training_status() -> dict[str, Any]:
return training.status()
@app.post("/api/training/retrain")
async def training_retrain(payload: dict[str, Any] | None = None) -> dict[str, Any]:
return training.request_retrain(payload)
@app.post("/api/training/heartbeat")
async def training_heartbeat(payload: dict[str, Any] | None = None) -> dict[str, Any]:
return training.heartbeat(payload)
@app.post("/api/training/claim")
async def training_claim(payload: dict[str, Any] | None = None) -> dict[str, Any]:
return training.claim(payload)
@app.post("/api/training/jobs/{job_id}/artifacts/chunk")
async def training_artifact_chunk(job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
try:
return training.save_artifact_chunk(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/training/jobs/{job_id}/progress")
async def training_progress(job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
try:
return training.progress(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/training/jobs/{job_id}/complete")
async def training_complete(job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
try:
return training.complete(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/api/config") @app.get("/api/config")
async def config() -> dict[str, Any]: async def config() -> dict[str, Any]:
return _safe_config(settings) return _safe_config(settings)
@@ -217,6 +294,12 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
"kelly_fraction": settings.kelly_fraction, "kelly_fraction": settings.kelly_fraction,
"kelly_max_fraction": settings.kelly_max_fraction, "kelly_max_fraction": settings.kelly_max_fraction,
"risk_per_trade_percent": settings.risk_per_trade_percent, "risk_per_trade_percent": settings.risk_per_trade_percent,
"risk_guard_enabled": settings.risk_guard_enabled,
"risk_symbol_guard_enabled": settings.risk_symbol_guard_enabled,
"risk_recent_trade_window": settings.risk_recent_trade_window,
"risk_max_consecutive_losses": settings.risk_max_consecutive_losses,
"risk_min_recent_profit_factor": settings.risk_min_recent_profit_factor,
"risk_reduce_multiplier": settings.risk_reduce_multiplier,
"atr_trailing_multiplier": settings.atr_trailing_multiplier, "atr_trailing_multiplier": settings.atr_trailing_multiplier,
"trend_rsi_min": settings.trend_rsi_min, "trend_rsi_min": settings.trend_rsi_min,
"trend_rsi_max": settings.trend_rsi_max, "trend_rsi_max": settings.trend_rsi_max,
@@ -224,14 +307,23 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
"time_series_min_candles": settings.time_series_min_candles, "time_series_min_candles": settings.time_series_min_candles,
"time_series_forecast_horizon": settings.time_series_forecast_horizon, "time_series_forecast_horizon": settings.time_series_forecast_horizon,
"time_series_min_edge_percent": settings.time_series_min_edge_percent, "time_series_min_edge_percent": settings.time_series_min_edge_percent,
"time_series_min_probability_up": settings.time_series_min_probability_up,
"time_series_min_confidence": settings.time_series_min_confidence,
"time_series_max_adjustment": settings.time_series_max_adjustment, "time_series_max_adjustment": settings.time_series_max_adjustment,
"time_series_lstm_enabled": settings.time_series_lstm_enabled, "time_series_lstm_enabled": settings.time_series_lstm_enabled,
"time_series_lstm_model_path": str(settings.time_series_lstm_model_path), "time_series_lstm_model_path": str(settings.time_series_lstm_model_path),
"time_series_probe_enabled": settings.time_series_probe_enabled,
"time_series_probe_min_edge_percent": settings.time_series_probe_min_edge_percent,
"time_series_probe_min_probability_up": settings.time_series_probe_min_probability_up,
"time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier,
"time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
"time_series_model_artifact": _time_series_model_artifact(settings), "time_series_model_artifact": _time_series_model_artifact(settings),
"stop_loss_percent": settings.stop_loss_percent, "stop_loss_percent": settings.stop_loss_percent,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
"take_profit_percent": settings.take_profit_percent, "take_profit_percent": settings.take_profit_percent,
"trailing_stop_percent": settings.trailing_stop_percent, "trailing_stop_percent": settings.trailing_stop_percent,
"min_hold_seconds": settings.min_hold_seconds, "min_hold_seconds": settings.min_hold_seconds,
"min_exit_net_percent": settings.min_exit_net_percent,
"entry_cooldown_seconds": settings.entry_cooldown_seconds, "entry_cooldown_seconds": settings.entry_cooldown_seconds,
"max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt, "max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt,
"min_cash_reserve_usdt": settings.min_cash_reserve_usdt, "min_cash_reserve_usdt": settings.min_cash_reserve_usdt,
@@ -242,6 +334,19 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
} }
def _runtime_json(settings: Settings, name: str) -> dict[str, Any]:
path = settings.time_series_lstm_model_path.parent / name
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"available": False, "path": str(path)}
if not isinstance(data, dict):
return {"available": False, "path": str(path)}
data["available"] = True
data["path"] = str(path)
return data
def _time_series_model_artifact(settings: Settings) -> dict[str, Any]: def _time_series_model_artifact(settings: Settings) -> dict[str, Any]:
path = settings.time_series_lstm_model_path path = settings.time_series_lstm_model_path
try: try:
@@ -338,811 +443,3 @@ def _forecast_model_label(model: str, *, torch_artifact: bool = False) -> str:
if normalized == "gru": if normalized == "gru":
return "устаревший артефакт" return "устаревший артефакт"
return model return model
HTML = r"""
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Крипто спот-бот</title>
<style>
:root {
--bg: #f5f7fb;
--panel: #ffffff;
--panel-2: #eef3f8;
--text: #111827;
--muted: #627084;
--border: #d9e1ea;
--accent: #0b7a75;
--accent-2: #2563eb;
--danger: #c2410c;
--green: #0f9f6e;
--red: #d64545;
--shadow: 0 10px 28px rgba(16, 24, 40, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--bg);
letter-spacing: 0;
}
header {
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
padding: 16px 24px;
background: rgba(255,255,255,0.94);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(14px);
}
h1 { margin: 0; font-size: 22px; line-height: 1.15; }
h2 { margin: 0 0 12px; font-size: 17px; }
.subline { margin-top: 4px; color: var(--muted); font-size: 13px; }
.header-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
button {
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
padding: 9px 13px;
border-radius: 7px;
font-weight: 700;
cursor: pointer;
font-size: 13px;
}
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.switch-control {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--panel);
color: var(--muted);
font-size: 12px;
font-weight: 800;
cursor: pointer;
user-select: none;
}
.switch-control input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.switch {
position: relative;
width: 42px;
height: 24px;
flex: 0 0 42px;
border-radius: 999px;
background: #cbd5e1;
transition: background 0.15s ease;
}
.switch::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25);
transition: transform 0.15s ease;
}
.switch-control input:checked + .switch { background: var(--accent); }
.switch-control input:checked + .switch::after { transform: translateX(18px); }
.switch-control input:focus-visible + .switch { outline: 2px solid var(--accent-2); outline-offset: 2px; }
.switch-control input:disabled + .switch { opacity: 0.65; }
.badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 5px 9px;
border-radius: 7px;
background: var(--panel-2);
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.badge.ok { color: #075e42; background: #daf5ea; }
.badge.warn { color: #8a3b12; background: #ffedd5; }
main { padding: 18px 24px 30px; }
.grid { display: grid; gap: 14px; }
.stats { grid-template-columns: repeat(6, minmax(130px, 1fr)); margin-bottom: 16px; }
.stat, .panel, .market-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
}
.stat { padding: 13px 14px; min-height: 82px; }
.label { color: var(--muted); font-size: 12px; font-weight: 800; text-transform: uppercase; }
.value { margin-top: 8px; font-size: 22px; font-weight: 850; white-space: nowrap; }
.markets { grid-template-columns: repeat(3, minmax(260px, 1fr)); margin-bottom: 16px; }
.market-card { padding: 13px; }
.market-top { display: flex; align-items: start; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
.symbol { font-size: 16px; font-weight: 850; }
.price { font-size: 14px; font-weight: 800; color: var(--accent-2); text-align: right; }
canvas { width: 100%; height: 170px; background: #fbfdff; border: 1px solid var(--border); border-radius: 6px; display: block; }
.indicators {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
font-size: 12px;
}
.indicator { padding: 7px 8px; background: var(--panel-2); border-radius: 6px; min-width: 0; }
.indicator b { display: block; font-size: 11px; color: var(--muted); margin-bottom: 3px; }
.pattern-line {
display: flex;
justify-content: space-between;
gap: 10px;
margin: 8px 0 9px;
padding: 8px 9px;
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
}
.pattern-line strong { color: var(--accent); }
.forecast-line {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
margin: -2px 0 9px;
font-size: 11px;
}
.forecast-chip {
min-width: 0;
padding: 6px 7px;
border: 1px solid var(--border);
border-radius: 6px;
background: #ffffff;
}
.forecast-chip b { display: block; color: var(--muted); font-size: 10px; margin-bottom: 2px; }
.layout { grid-template-columns: 1.2fr 0.8fr; align-items: start; }
.panel { padding: 14px; min-width: 0; }
.table-wrap { overflow: auto; max-height: 330px; border: 1px solid var(--border); border-radius: 7px; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
th, td { padding: 9px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
th {
position: sticky;
top: 0;
z-index: 2;
background: #f8fafc;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
}
tr:last-child td { border-bottom: none; }
.positive { color: var(--green); font-weight: 800; }
.negative { color: var(--red); font-weight: 800; }
.stack { display: grid; gap: 14px; }
.config-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; font-size: 13px; }
.config-item { display: flex; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--border); padding: 7px 0; }
.learning-list { display: grid; gap: 8px; font-size: 13px; }
.learning-row { display: flex; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); padding: 7px 0; }
.learning-row span { color: var(--muted); }
.learning-state-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 0 10px;
border-bottom: 1px solid var(--border);
}
.state-pill {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 850;
white-space: nowrap;
}
.state-pill.ok { color: #075e42; background: #daf5ea; }
.state-pill.warn { color: #8a3b12; background: #ffedd5; }
.state-pill.bad { color: #991b1b; background: #fee2e2; }
.learning-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
}
.learning-metric {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 7px 0;
border-bottom: 1px solid var(--border);
min-width: 0;
}
.learning-metric span { color: var(--muted); }
.learning-metric strong { text-align: right; }
.learning-section-title {
margin-top: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 850;
text-transform: uppercase;
}
.effect-list { display: grid; gap: 6px; }
.effect-row {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 8px;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--border);
min-width: 0;
}
.effect-row .small { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reason-cell { max-width: 520px; min-width: 240px; white-space: normal; }
.muted { color: var(--muted); }
@media (max-width: 1180px) {
.stats { grid-template-columns: repeat(3, minmax(130px, 1fr)); }
.markets { grid-template-columns: repeat(2, minmax(260px, 1fr)); }
.layout { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
header { position: static; align-items: flex-start; padding: 14px; flex-direction: column; }
main { padding: 14px; }
.stats, .markets { grid-template-columns: 1fr; }
.value { font-size: 20px; }
.config-grid { grid-template-columns: 1fr; }
.learning-metrics { grid-template-columns: 1fr; }
.effect-row { grid-template-columns: 62px 1fr; }
.effect-row strong { text-align: left; }
}
</style>
</head>
<body>
<header>
<div>
<h1>Крипто спот-бот</h1>
<div class="subline" id="subline">Загрузка состояния...</div>
</div>
<div class="header-actions">
<span class="badge" id="modeBadge">Демо</span>
<span class="badge" id="liveBadge">Реальная торговля заблокирована</span>
<label class="switch-control" title="Переключает быстрый цикл принятия решений">
<input type="checkbox" id="fastToggle" data-testid="fast-toggle" />
<span class="switch" aria-hidden="true"></span>
<span id="fastToggleLabel">Быстрая торговля</span>
</label>
<span class="badge" id="fastBadge">Обычный режим</span>
<span class="badge" id="wsBadge">Поток данных</span>
<button class="primary" id="startBtn">Старт</button>
<button class="danger" id="stopBtn">Стоп</button>
</div>
</header>
<main>
<section class="grid stats">
<div class="stat"><div class="label">Баланс</div><div class="value" id="equity">-</div></div>
<div class="stat"><div class="label">Свободно</div><div class="value" id="cash">-</div></div>
<div class="stat"><div class="label">В позициях</div><div class="value" id="exposure">-</div></div>
<div class="stat"><div class="label">Прибыль/убыток</div><div class="value" id="pnl">-</div></div>
<div class="stat"><div class="label">Позиции</div><div class="value" id="positionsCount">-</div></div>
<div class="stat"><div class="label">Просадка</div><div class="value" id="drawdown">-</div></div>
</section>
<section class="grid markets" id="markets"></section>
<section class="grid layout">
<div class="stack">
<div class="panel">
<h2>Открытые позиции</h2>
<div class="table-wrap"><table><thead><tr><th>Пара</th><th>Кол-во</th><th>Вход</th><th>Цена</th><th>Прибыль/убыток</th><th>Стоп</th><th>Цель</th></tr></thead><tbody id="positionsTable"></tbody></table></div>
</div>
<div class="panel">
<h2>Сделки</h2>
<div class="table-wrap"><table><thead><tr><th>Время</th><th>Пара</th><th>Сторона</th><th>Кол-во</th><th>Вход</th><th>Выход</th><th>Итог</th><th>Причина</th></tr></thead><tbody id="tradesTable"></tbody></table></div>
</div>
<div class="panel">
<h2>Сигналы стратегии</h2>
<div class="table-wrap"><table><thead><tr><th>Время</th><th>Пара</th><th>Действие</th><th>Режим</th><th>Размер</th><th>Уверенность</th><th>База</th><th>Шаблон</th><th>Обучение</th><th>Прогноз</th><th>Отскок</th><th>Итог</th><th>Причина</th></tr></thead><tbody id="signalsTable"></tbody></table></div>
</div>
</div>
<aside class="stack">
<div class="panel">
<h2>Параметры</h2>
<div class="config-grid" id="configGrid"></div>
</div>
<div class="panel">
<h2>Обучение на сделках</h2>
<div class="learning-list" id="learningPanel"></div>
</div>
<div class="panel">
<h2>События</h2>
<div class="table-wrap"><table><thead><tr><th>Время</th><th>Уровень</th><th>Сообщение</th></tr></thead><tbody id="eventsTable"></tbody></table></div>
</div>
</aside>
</section>
</main>
<script>
const fmt = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 4 });
const money = (value) => Number.isFinite(Number(value)) ? `${fmt.format(Number(value))} USDT` : '-';
const num = (value, digits = 4) => Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : '-';
const time = (value) => value ? new Date(value).toLocaleString('ru-RU') : '-';
const modeName = (value) => ({ paper: 'Демо', live: 'Реальная торговля' }[value] || value || '-');
const actionName = (value) => ({ BUY: 'Купить', SELL: 'Продать', HOLD: 'Ждать' }[value] || value || '-');
const sideName = (value) => ({ BUY: 'Покупка', SELL: 'Продажа' }[value] || value || '-');
const levelName = (value) => ({ INFO: 'Инфо', WARN: 'Предупреждение', ERROR: 'Ошибка' }[value] || value || '-');
const yesNo = (value) => value ? 'Да' : 'Нет';
function messageText(value) {
return String(value || '')
.replaceAll('WebSocket Bybit подключен', 'Поток данных Bybit подключен')
.replaceAll('WebSocket Bybit отключен', 'Поток данных Bybit отключен')
.replaceAll('paper BUY', 'демо-покупка')
.replaceAll('paper SELL', 'демо-продажа')
.replaceAll('live shadow BUY', 'реальная покупка, локальная запись')
.replaceAll('live shadow SELL', 'реальная продажа, локальная запись')
.replaceAll('live BUY', 'реальная покупка')
.replaceAll('live SELL', 'реальная продажа')
.replaceAll('qty=', 'кол-во=')
.replaceAll('price=', 'цена=')
.replaceAll('conf=', 'уверенность=')
.replaceAll('net=', 'итог=')
.replaceAll('reason=', 'причина=')
.replaceAll('stop-loss', 'стоп-лосс')
.replaceAll('take-profit', 'тейк-профит')
.replaceAll('trailing stop', 'трейлинг-стоп');
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.json();
}
function setText(id, value) { document.getElementById(id).textContent = value; }
function signedClass(value) { return Number(value) >= 0 ? 'positive' : 'negative'; }
async function refresh() {
const [status, markets, trades, signals, events, config] = await Promise.all([
fetchJson('/api/status'),
fetchJson('/api/markets'),
fetchJson('/api/trades?limit=80'),
fetchJson('/api/signals?limit=120'),
fetchJson('/api/events?limit=80'),
fetchJson('/api/config')
]);
renderStatus(status, markets);
renderMarkets(markets);
renderPositions(status.positions || []);
renderTrades(trades.items || []);
renderSignals(signals.items || []);
renderEvents(events.items || []);
renderConfig(config);
renderFastMode(config);
renderLearning(status.learning || {}, signals.items || [], config);
}
function renderStatus(payload, markets) {
const s = payload.status;
const a = payload.account;
setText('subline', `${s.running ? 'Работает' : 'Остановлен'} · ${s.symbols.join(', ') || 'пары не выбраны'} · цикл ${time(s.last_loop_at)}`);
setText('equity', money(a.equity));
setText('cash', money(a.cash));
setText('exposure', money(a.exposure));
setText('pnl', `${num(a.net_pnl, 4)} (${num(a.net_pnl_percent, 2)}%)`);
document.getElementById('pnl').className = `value ${signedClass(a.net_pnl)}`;
setText('positionsCount', String((payload.positions || []).length));
setText('drawdown', money(a.drawdown));
const modeBadge = document.getElementById('modeBadge');
modeBadge.textContent = modeName(s.mode);
modeBadge.className = `badge ${s.mode === 'paper' ? 'ok' : 'warn'}`;
const liveBadge = document.getElementById('liveBadge');
liveBadge.textContent = s.live_trading_ready ? 'Реальная торговля разрешена' : 'Реальная торговля заблокирована';
liveBadge.className = `badge ${s.live_trading_ready ? 'warn' : 'ok'}`;
const wsBadge = document.getElementById('wsBadge');
wsBadge.textContent = markets.ws_connected ? 'Поток данных подключен' : 'Поток данных отключен';
wsBadge.className = `badge ${markets.ws_connected ? 'ok' : 'warn'}`;
}
function renderMarkets(payload) {
const root = document.getElementById('markets');
root.innerHTML = '';
for (const market of payload.markets || []) {
const ticker = market.ticker;
const pattern = market.pattern || {};
const last = market.candles?.[market.candles.length - 1] || {};
const card = document.createElement('article');
card.className = 'market-card';
const symbol = ticker?.symbol || market.instrument?.symbol || '-';
card.innerHTML = `
<div class="market-top">
<div><div class="symbol">${symbol}</div><div class="muted">Спред ${num(ticker?.spread_percent, 4)}%</div></div>
<div class="price">${money(ticker?.last_price)}</div>
</div>
<div class="pattern-line">
<span>Шаблон: <strong>${escapeHtml(pattern.label || 'нет данных')}</strong></span>
<span>${num(pattern.score, 2)}</span>
</div>
${forecastHtml(market.forecast || {})}
<canvas width="520" height="220"></canvas>
<div class="indicators">
<div class="indicator"><b>RSI14</b>${num(last.rsi_14, 2)}</div>
<div class="indicator"><b>EMA50</b>${num(last.ema_50, 4)}</div>
<div class="indicator"><b>EMA200</b>${num(last.ema_200, 4)}</div>
<div class="indicator"><b>ATR14</b>${num(last.atr_14, 4)}</div>
<div class="indicator"><b>Объем MA20</b>${num(last.volume_ma_20, 4)}</div>
<div class="indicator"><b>Оборот 24ч</b>${money(ticker?.turnover_24h)}</div>
</div>
`;
root.appendChild(card);
drawCandles(card.querySelector('canvas'), market.candles || []);
}
}
function modelName(model) {
const key = String(model || '').toLowerCase();
const names = {
torch_lstm: 'PyTorch LSTM',
torch_gru: 'PyTorch GRU',
none: '-'
};
return names[key] || String(model || '-');
}
function modelReason(reason) {
return String(reason || '')
.replaceAll('torch_lstm', 'PyTorch LSTM')
.replaceAll('torch_gru', 'PyTorch GRU')
.replaceAll('модель lstm', 'модель устаревший LSTM');
}
function modelArtifactSummary(config) {
if (!config.time_series_lstm_enabled) {
return 'выкл';
}
const artifact = config.time_series_model_artifact || {};
if (!artifact.available) {
return artifact.label || 'нет файла модели';
}
const parts = [artifact.label || 'модель прогноза'];
if (Number(artifact.symbol_count || 0) > 0) {
parts.push(`${artifact.symbol_count} пар`);
}
if (Array.isArray(artifact.models) && artifact.models.length) {
parts.push(artifact.models.join(', '));
}
const date = shortDateTime(artifact.created_at);
if (date) {
parts.push(date);
}
return parts.join(' · ');
}
function shortDateTime(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function forecastHtml(forecast) {
if (!forecast || !forecast.usable) {
return `<div class="forecast-line"><div class="forecast-chip"><b>Прогноз</b>${escapeHtml(modelReason(forecast?.reason || 'нет данных'))}</div></div>`;
}
return `<div class="forecast-line">
<div class="forecast-chip"><b>Модель</b>${escapeHtml(modelName(forecast.model || '-'))}</div>
<div class="forecast-chip"><b>Горизонт</b>${num(forecast.horizon || 0, 0)}ч</div>
<div class="forecast-chip"><b>P роста</b>${num((forecast.probability_up || 0) * 100, 1)}%</div>
<div class="forecast-chip"><b>Ожидание</b><span class="${signedClass(forecast.expected_return_percent || 0)}">${signedNum(forecast.expected_return_percent, 3)}%</span></div>
<div class="forecast-chip"><b>Q10/Q50/Q90</b>${signedNum(forecast.quantile_10_percent, 2)} / ${signedNum(forecast.quantile_50_percent, 2)} / ${signedNum(forecast.quantile_90_percent, 2)}%</div>
<div class="forecast-chip"><b>Волат.</b>${num(forecast.volatility_percent, 3)}%</div>
</div>`;
}
function drawCandles(canvas, candles) {
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#fbfdff';
ctx.fillRect(0, 0, w, h);
const data = candles.slice(-80);
if (data.length < 2) return;
const highs = data.map(c => c.high).concat(data.map(c => c.ema_50 || c.close), data.map(c => c.ema_200 || c.close));
const lows = data.map(c => c.low).concat(data.map(c => c.ema_50 || c.close), data.map(c => c.ema_200 || c.close));
const max = Math.max(...highs), min = Math.min(...lows);
const pad = Math.max((max - min) * 0.08, max * 0.0008);
const y = (v) => h - 18 - ((v - min + pad) / (max - min + pad * 2)) * (h - 34);
const step = w / data.length;
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
for (let i = 0; i < 4; i++) {
const yy = 16 + i * ((h - 34) / 3);
ctx.beginPath(); ctx.moveTo(0, yy); ctx.lineTo(w, yy); ctx.stroke();
}
data.forEach((c, i) => {
const x = i * step + step / 2;
const up = c.close >= c.open;
ctx.strokeStyle = up ? '#0f9f6e' : '#d64545';
ctx.fillStyle = ctx.strokeStyle;
ctx.beginPath(); ctx.moveTo(x, y(c.high)); ctx.lineTo(x, y(c.low)); ctx.stroke();
const bodyY = Math.min(y(c.open), y(c.close));
const bodyH = Math.max(2, Math.abs(y(c.open) - y(c.close)));
ctx.fillRect(x - Math.max(2, step * 0.28), bodyY, Math.max(3, step * 0.56), bodyH);
});
drawLine(ctx, data, 'ema_50', '#2563eb', y, step);
drawLine(ctx, data, 'ema_200', '#7c3aed', y, step);
}
function drawLine(ctx, data, key, color, y, step) {
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
let started = false;
data.forEach((c, i) => {
if (!c[key]) return;
const x = i * step + step / 2;
if (!started) { ctx.moveTo(x, y(c[key])); started = true; }
else ctx.lineTo(x, y(c[key]));
});
if (started) ctx.stroke();
}
function renderPositions(items) {
document.getElementById('positionsTable').innerHTML = items.map(p => `
<tr><td>${p.symbol}</td><td>${num(p.qty, 8)}</td><td>${num(p.entry_price, 6)}</td><td>${num(p.mark_price, 6)}</td><td class="${signedClass(p.unrealized_pnl)}">${num(p.unrealized_pnl, 4)}</td><td>${num(p.stop_loss, 6)}</td><td>${num(p.take_profit, 6)}</td></tr>
`).join('') || `<tr><td colspan="7" class="muted">Нет открытых позиций</td></tr>`;
}
function renderTrades(items) {
document.getElementById('tradesTable').innerHTML = items.map(t => `
<tr><td>${time(t.closed_at || t.opened_at)}</td><td>${t.symbol}</td><td>${sideName(t.side)}</td><td>${num(t.qty, 8)}</td><td>${num(t.entry_price, 6)}</td><td>${num(t.exit_price, 6)}</td><td class="${signedClass(t.net_pnl)}">${num(t.net_pnl, 4)}</td><td>${escapeHtml(t.reason || '')}</td></tr>
`).join('') || `<tr><td colspan="8" class="muted">Сделок пока нет</td></tr>`;
}
function renderSignals(items) {
document.getElementById('signalsTable').innerHTML = items.map(s => `
${signalRowHtml(s)}
`).join('');
}
function signalRowHtml(signal) {
const d = parseDiagnostics(signal);
return `<tr>
<td>${time(signal.created_at)}</td>
<td>${signal.symbol}</td>
<td>${actionName(signal.action)}</td>
<td>${escapeHtml(d.trade_mode || '-')}</td>
<td>${money(d.position_notional_usdt)}</td>
<td>${num(signal.confidence, 3)}</td>
<td>${num(d.base_score, 3)}</td>
<td class="${signedClass(d.pattern_adjustment || 0)}">${signedNum(d.pattern_adjustment, 3)}</td>
<td class="${signedClass(d.learning_adjustment || 0)}">${signedNum(d.learning_adjustment, 3)}</td>
${forecastSignalCell(d.forecast || {}, d.forecast_adjustment)}
<td>${num(d.rebound_probability, 3)}</td>
<td>${num(d.final_score, 3)}</td>
<td>${escapeHtml(signal.reason || '')}</td>
</tr>`;
}
function renderEvents(items) {
document.getElementById('eventsTable').innerHTML = items.map(e => `
<tr><td>${time(e.created_at)}</td><td>${levelName(e.level)}</td><td>${escapeHtml(messageText(e.message))}</td></tr>
`).join('');
}
function forecastSignalCell(forecast, adjustment) {
if (!forecast || !forecast.model) {
return '<td>-</td>';
}
const expected = Number(forecast.expected_return_percent);
const probability = Number(forecast.probability_up);
const skill = Number(forecast.skill);
const model = escapeHtml(modelName(forecast.model || '-'));
const expectedText = Number.isFinite(expected) ? `${signedNum(expected, 3)}%` : '-';
const probabilityText = Number.isFinite(probability) ? `P${num(probability * 100, 1)}%` : 'P-';
const skillText = Number.isFinite(skill) ? `S${num(skill, 3)}` : 'S-';
const adjustmentText = Number.isFinite(Number(adjustment)) && Number(adjustment) !== 0
? ` · ${signedNum(adjustment, 3)}`
: '';
return `<td class="${signedClass(expected || 0)}">${model} ${expectedText} · ${probabilityText} · ${skillText}${adjustmentText}</td>`;
}
function renderConfig(config) {
const keys = [
['Режим', modeName(config.trading_mode)],
['Стратегия', config.strategy_mode || '-'],
['Стартовый баланс', money(config.starting_balance_usdt)],
['Мин. уверенность', config.min_signal_confidence],
['Быстрая торговля', yesNo(config.fast_trading_enabled)],
['Цикл решений', `${num(config.effective_loop_interval_seconds, 2)}с`],
['Пауза входа', `${config.effective_entry_cooldown_seconds}с`],
['Новых входов/мин', config.max_entries_per_minute],
['Анализ шаблонов', yesNo(config.pattern_analysis_enabled)],
['Вес шаблонов', config.pattern_score_weight],
['Обучение', yesNo(config.learning_enabled)],
['Сделок для обучения', `${config.learning_lookback_trades}, мин. ${config.learning_min_samples}`],
['Макс. спред', `${config.max_spread_percent}%`],
['Мин. оборот 24ч', money(config.min_24h_turnover_usdt)],
['Размер позиции', `${money(config.min_position_usdt)} - ${money(config.max_position_usdt)}`],
['Лимит на пару', money(config.max_symbol_exposure_usdt)],
['Риск на сделку', `${num((config.risk_per_trade_percent || 0) * 100, 2)}% equity`],
['RSI входа', `${num(config.trend_rsi_min, 1)} - ${num(config.trend_rsi_max, 1)}`],
['Лимит в позициях', money(config.max_total_exposure_usdt)],
['Лимит позиций', `${config.max_open_positions} всего / ${config.max_positions_per_symbol} на пару`],
['Стоп', `${num(config.stop_loss_percent * 100, 2)}%`],
['ATR trailing', `${num(config.atr_trailing_multiplier, 2)} ATR`],
['Удержание / пауза', `${config.min_hold_seconds}с / ${config.entry_cooldown_seconds}с`],
['Комиссия / проскальзывание', `${num(config.taker_fee_rate * 100, 3)}% / ${num(config.slippage_rate * 100, 3)}%`],
['Таймфрейм', `${config.base_interval} / тренд ${config.trend_interval}`],
['Поток данных', yesNo(config.websocket_enabled)]
];
document.getElementById('configGrid').innerHTML = keys.map(([k, v]) => `
<div class="config-item"><span class="muted">${escapeHtml(k)}</span><strong>${escapeHtml(String(v))}</strong></div>
`).join('');
}
function renderFastMode(config) {
const fastBadge = document.getElementById('fastBadge');
fastBadge.textContent = config.fast_trading_enabled
? `Быстрый режим · ${num(config.effective_loop_interval_seconds, 2)}с`
: 'Обычный режим';
fastBadge.className = `badge ${config.fast_trading_enabled ? 'warn' : 'ok'}`;
const fastToggle = document.getElementById('fastToggle');
const fastToggleLabel = document.getElementById('fastToggleLabel');
fastToggle.checked = Boolean(config.fast_trading_enabled);
fastToggle.disabled = false;
fastToggleLabel.textContent = config.fast_trading_enabled
? 'Быстрая торговля: вкл'
: 'Быстрая торговля: выкл';
}
function renderLearning(learning, signals, config) {
const root = document.getElementById('learningPanel');
const patternStats = learning.pattern_stats || {};
const effects = strategyEffects(signals);
const learningEffects = effects.filter(item => Number.isFinite(item.learningAdjustment));
const activeLearning = learningEffects.filter(item => item.learningAdjustment !== 0);
const patternEffects = effects.filter(item => Number.isFinite(item.patternAdjustment) && item.patternAdjustment !== 0);
const avgLearning = average(activeLearning.map(item => item.learningAdjustment));
const lastLearning = activeLearning[0];
const ready = Boolean(learning.enabled) && Number(learning.sample_size || 0) >= Number(config.learning_min_samples || 0);
const losing = Number(learning.net_pnl || 0) < 0;
const stateClass = !learning.enabled ? 'warn' : ready ? (losing ? 'bad' : 'ok') : 'warn';
const stateText = !learning.enabled
? 'Выключено'
: ready
? (losing ? 'Работает, статистика минусовая' : 'Работает')
: 'Мало данных';
const stateHint = ready
? `${learning.sample_size || 0} закрытых сделок из окна ${config.learning_lookback_trades || '-'}`
: `${learning.sample_size || 0}/${config.learning_min_samples || 0} сделок до первых выводов`;
const rows = Object.entries(patternStats)
.sort((a, b) => (b[1].sample_size || 0) - (a[1].sample_size || 0))
.slice(0, 5);
const rules = learning.adaptive_rules || {};
const validation = rules.validation || {};
const ruleRows = [
['Разрешение торговли', rules.trade_permission || 'normal'],
['Режим риска', rules.risk_mode || 'neutral'],
['Экспозиция / цель', `${money(rules.current_total_exposure_usdt || 0)} / ${money(rules.target_total_exposure_usdt || config.max_total_exposure_usdt || 0)}`],
['Порог входа', signedNum(Number(rules.entry_threshold_adjustment || 0), 4)],
['Множитель Kelly', `${num(rules.effective_position_size_multiplier || rules.position_size_multiplier || 1, 2)}x`],
['Мин. удержание', `${rules.min_hold_seconds || config.min_hold_seconds || '-'} сек`],
['EMA-выход', exitRuleName(rules.ema_exit_mode)],
['RSI-выход', exitRuleName(rules.rsi_exit_mode)],
['Мин. прибыль выхода', `${num(rules.min_exit_profit_percent || 0, 3)}%`],
['Стоп / цель обучения', `${num((rules.stop_loss_percent || config.stop_loss_percent || 0) * 100, 2)}% / ${num((rules.take_profit_percent || config.take_profit_percent || 0) * 100, 2)}%`],
['Проверка правил', `${validation.status || '-'} / ${num(validation.avoided_loss_usdt || 0, 4)} USDT`],
];
const base = [
['Закрытых сделок', `${learning.sample_size || 0} / ${config.learning_lookback_trades || '-'}`],
['Итог обучения', `${num(learning.net_pnl, 4)} USDT`],
['Win rate', `${num((learning.win_rate || 0) * 100, 1)}%`],
['Активные поправки', `${activeLearning.length} из ${learningEffects.length}`],
['Средняя поправка', signedNum(avgLearning, 4)],
['Шаблоны влияют', `${patternEffects.length} сигналов`],
['Последняя поправка', lastLearning ? `${lastLearning.symbol} ${signedNum(lastLearning.learningAdjustment, 4)}` : 'нет'],
['Блокировки входа', `${effects.filter(item => item.blockedByPattern || item.blockedByLearning || item.blockedByAdaptive || item.blockedByForecast).length} сигналов`],
];
const stateHtml = `
<div class="learning-state-line">
<span class="state-pill ${stateClass}">${stateText}</span>
<span class="muted">${escapeHtml(stateHint)}</span>
</div>`;
const statHtml = `<div class="learning-metrics">${base.map(([k, v]) => `
<div class="learning-metric"><span>${k}</span><strong>${v}</strong></div>
`).join('')}</div>`;
const effectHtml = activeLearning.length
? `<div class="learning-section-title">Последние поправки обучения</div><div class="effect-list">${activeLearning.slice(0, 5).map(item => `
<div class="effect-row"><strong>${item.symbol}</strong><span class="small">${escapeHtml(item.pattern || item.reason || '-')}</span><strong class="${signedClass(item.learningAdjustment)}">${signedNum(item.learningAdjustment, 4)}</strong></div>
`).join('')}</div>`
: '<div class="muted">В последних сигналах активных поправок обучения нет.</div>';
const ruleHtml = `<div class="learning-section-title">Адаптивные правила</div>${ruleRows.map(([k, v]) => `
<div class="learning-row"><span>${escapeHtml(k)}</span><strong>${escapeHtml(String(v))}</strong></div>
`).join('')}`;
const patternHtml = rows.length
? `<div class="learning-section-title">Статистика по шаблонам</div>${rows.map(([name, stat]) => `
<div class="learning-row"><span>${escapeHtml(name)}</span><strong class="${signedClass(stat.net_pnl)}">${num(stat.net_pnl, 4)} / ${num((stat.win_rate || 0) * 100, 1)}%</strong></div>
`).join('')}`
: '<div class="muted">Закрытых сделок пока мало; статистика шаблонов не сформирована.</div>';
root.innerHTML = stateHtml + statHtml + ruleHtml + effectHtml + patternHtml;
}
function strategyEffects(signals) {
return signals.map(signal => {
const d = parseDiagnostics(signal);
const pattern = d.pattern || {};
const learning = d.learning || {};
return {
symbol: signal.symbol,
pattern: pattern.label || '',
reason: learning.reason || '',
baseScore: numberOrNull(d.base_score),
patternAdjustment: numberOrNull(d.pattern_adjustment),
learningAdjustment: numberOrNull(d.learning_adjustment),
forecastAdjustment: numberOrNull(d.forecast_adjustment),
finalScore: numberOrNull(d.final_score),
blockedByPattern: Boolean(d.entry_blocked_by_pattern),
blockedByLearning: Boolean(d.entry_blocked_by_learning),
blockedByAdaptive: Boolean(d.entry_blocked_by_adaptive_rules),
blockedByForecast: Boolean(d.entry_blocked_by_forecast),
};
}).filter(item => (
item.baseScore !== null
|| item.patternAdjustment !== null
|| item.learningAdjustment !== null
|| item.forecastAdjustment !== null
|| item.finalScore !== null
));
}
function parseDiagnostics(signal) {
try {
return JSON.parse(signal.diagnostics_json || '{}');
} catch (_) {
return {};
}
}
function exitRuleName(value) {
return value === 'profit_only' ? 'только при прибыли' : 'обычный';
}
function numberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function average(values) {
const filtered = values.filter(value => Number.isFinite(value));
return filtered.length ? filtered.reduce((sum, value) => sum + value, 0) / filtered.length : 0;
}
function signedNum(value, digits = 4) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return '-';
const sign = parsed > 0 ? '+' : '';
return `${sign}${parsed.toFixed(digits)}`;
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
}
document.getElementById('startBtn').addEventListener('click', async () => { await fetchJson('/api/control/start', { method: 'POST' }); await refresh(); });
document.getElementById('stopBtn').addEventListener('click', async () => { await fetchJson('/api/control/stop', { method: 'POST' }); await refresh(); });
document.getElementById('fastToggle').addEventListener('change', async (event) => {
const enabled = event.target.checked;
event.target.disabled = true;
setText('fastToggleLabel', enabled ? 'Включение...' : 'Выключение...');
try {
await fetchJson('/api/config/fast-trading', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
await refresh();
} catch (err) {
event.target.checked = !enabled;
event.target.disabled = false;
setText('fastToggleLabel', enabled ? 'Быстрая торговля: выкл' : 'Быстрая торговля: вкл');
setText('subline', `Ошибка переключения быстрой торговли: ${err.message}`);
}
});
refresh().catch(err => setText('subline', `Ошибка загрузки: ${err.message}`));
setInterval(() => refresh().catch(err => setText('subline', `Ошибка обновления: ${err.message}`)), 3000);
</script>
</body>
</html>
"""
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
from typing import Any
from crypto_spot_bot.models import Candle, Ticker, utc_now
def market_quality_snapshot(
*,
symbols: list[str],
candles_by_symbol: dict[str, list[Candle]],
tickers: dict[str, Ticker],
interval: str,
) -> dict[str, Any]:
rows = [
analyze_symbol_quality(
symbol=symbol,
candles=candles_by_symbol.get(symbol, []),
ticker=tickers.get(symbol),
interval=interval,
)
for symbol in symbols
]
worst_score = min((row["score"] for row in rows), default=0.0)
status = "ok"
if any(row["status"] == "error" for row in rows):
status = "error"
elif any(row["status"] == "warn" for row in rows):
status = "warn"
return {
"status": status,
"score": round(worst_score, 4),
"symbols": rows,
}
def analyze_symbol_quality(
*,
symbol: str,
candles: list[Candle],
ticker: Ticker | None,
interval: str,
) -> dict[str, Any]:
issues: list[dict[str, Any]] = []
score = 1.0
interval_ms = _interval_ms(interval)
if not candles:
return _row(symbol, "error", 0.0, [{"code": "no_candles", "severity": "error"}], 0, None, None)
timestamps = [candle.timestamp for candle in candles]
duplicates = len(timestamps) - len(set(timestamps))
if duplicates:
issues.append({"code": "duplicate_candles", "severity": "warn", "count": duplicates})
score -= min(0.25, duplicates * 0.03)
invalid_ohlc = 0
zero_volume = 0
for candle in candles:
prices = [candle.open, candle.high, candle.low, candle.close]
if any(price <= 0 for price in prices) or candle.high < max(candle.open, candle.close) or candle.low > min(candle.open, candle.close):
invalid_ohlc += 1
if candle.volume <= 0:
zero_volume += 1
if invalid_ohlc:
issues.append({"code": "invalid_ohlc", "severity": "error", "count": invalid_ohlc})
score -= min(0.45, invalid_ohlc * 0.08)
if zero_volume:
issues.append({"code": "zero_volume", "severity": "warn", "count": zero_volume})
score -= min(0.20, zero_volume * 0.02)
missing_gaps = 0
largest_gap_ms = 0
if interval_ms > 0:
ordered = sorted(set(timestamps))
for left, right in zip(ordered, ordered[1:]):
gap = right - left
largest_gap_ms = max(largest_gap_ms, gap)
if gap > interval_ms * 1.5:
missing_gaps += max(1, round(gap / interval_ms) - 1)
if missing_gaps:
issues.append({"code": "missing_candles", "severity": "warn", "count": missing_gaps})
score -= min(0.35, missing_gaps * 0.04)
age_seconds = max(0.0, (utc_now().timestamp() * 1000 - max(timestamps)) / 1000)
stale_after = interval_ms / 1000 * 2.5
if age_seconds > stale_after:
issues.append({"code": "stale_candles", "severity": "warn", "age_seconds": round(age_seconds, 1)})
score -= 0.20
else:
age_seconds = None
if ticker is None:
issues.append({"code": "no_ticker", "severity": "error"})
score -= 0.45
else:
if ticker.last_price <= 0:
issues.append({"code": "invalid_ticker_price", "severity": "error"})
score -= 0.35
if ticker.spread_percent > 0.35:
issues.append({"code": "wide_spread", "severity": "warn", "spread_percent": round(ticker.spread_percent, 4)})
score -= 0.15
score = max(0.0, min(1.0, score))
severity = {str(issue.get("severity")) for issue in issues}
status = "error" if "error" in severity else "warn" if "warn" in severity else "ok"
return _row(
symbol,
status,
score,
issues,
len(candles),
max(timestamps),
largest_gap_ms if interval_ms > 0 else None,
)
def _row(
symbol: str,
status: str,
score: float,
issues: list[dict[str, Any]],
candle_count: int,
last_candle_timestamp: int | None,
largest_gap_ms: int | None,
) -> dict[str, Any]:
return {
"symbol": symbol,
"status": status,
"score": round(score, 4),
"candle_count": candle_count,
"last_candle_timestamp": last_candle_timestamp,
"largest_gap_ms": largest_gap_ms,
"issues": issues,
}
def _interval_ms(interval: str) -> int:
normalized = str(interval).strip().upper()
if normalized == "D":
return 24 * 60 * 60 * 1000
if normalized == "W":
return 7 * 24 * 60 * 60 * 1000
if normalized.isdigit():
return int(normalized) * 60 * 1000
return 0
+97 -14
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections import deque from collections import deque
from datetime import timedelta from datetime import timedelta
from decimal import Decimal, ROUND_DOWN from decimal import Decimal, ROUND_DOWN, ROUND_UP
from typing import Iterable from typing import Iterable
from uuid import uuid4 from uuid import uuid4
@@ -25,6 +25,15 @@ def _round_step(value: float, step: float) -> float:
return float(rounded * step_decimal) return float(rounded * step_decimal)
def _round_step_up(value: float, step: float) -> float:
if step <= 0:
return value
value_decimal = Decimal(str(value))
step_decimal = Decimal(str(step))
rounded = (value_decimal / step_decimal).to_integral_value(rounding=ROUND_UP)
return float(rounded * step_decimal)
class PaperBroker: class PaperBroker:
def __init__(self, settings: Settings, storage: Storage): def __init__(self, settings: Settings, storage: Storage):
self.settings = settings self.settings = settings
@@ -92,12 +101,9 @@ class PaperBroker:
return False, "достигнут лимит новых входов в минуту" return False, "достигнут лимит новых входов в минуту"
if len(self.positions) >= self.settings.max_open_positions: if len(self.positions) >= self.settings.max_open_positions:
return False, "достигнут общий лимит открытых позиций" return False, "достигнут общий лимит открытых позиций"
if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} and len(self.positions_for_symbol(symbol)) >= 1: if self.settings.strategy_mode == "trend_macd" and len(self.positions_for_symbol(symbol)) >= 1:
return False, "DCA/усреднение отключено: позиция по паре уже открыта" return False, "DCA/усреднение отключено: позиция по паре уже открыта"
dynamic_pair_limit = max( dynamic_pair_limit = _symbol_position_limit(self.settings)
self.settings.max_positions_per_symbol,
int(self.settings.max_symbol_exposure_usdt // max(self.settings.min_position_usdt, 0.01)),
)
if len(self.positions_for_symbol(symbol)) >= dynamic_pair_limit: if len(self.positions_for_symbol(symbol)) >= dynamic_pair_limit:
return False, "достигнут лимит позиций по паре" return False, "достигнут лимит позиций по паре"
requested = requested_notional if requested_notional is not None else self.settings.min_position_usdt requested = requested_notional if requested_notional is not None else self.settings.min_position_usdt
@@ -136,12 +142,15 @@ class PaperBroker:
) -> Position | None: ) -> Position | None:
fill_price = self._buy_price(ticker) fill_price = self._buy_price(ticker)
notional = self._entry_budget(signal, ticker) minimum_budget = self._minimum_entry_budget(instrument, fill_price)
if notional < self.settings.min_position_usdt: budget = self._entry_budget(signal, ticker, minimum_notional=minimum_budget)
if budget < max(self.settings.min_position_usdt, minimum_budget):
self.storage.event(f"{ticker.symbol}: покупка пропущена, adaptive-лимит экспозиции исчерпан", "WARN") self.storage.event(f"{ticker.symbol}: покупка пропущена, adaptive-лимит экспозиции исчерпан", "WARN")
return None return None
notional = notional / (1 + self.settings.taker_fee_rate) notional = budget / (1 + self.settings.taker_fee_rate)
qty = _round_step(notional / fill_price, instrument.qty_step if instrument else 0) qty = _round_step(notional / fill_price, instrument.qty_step if instrument else 0)
if instrument:
qty = self._raise_qty_to_exchange_minimum(qty, fill_price, instrument, budget)
if instrument and qty < instrument.min_order_qty: if instrument and qty < instrument.min_order_qty:
self.storage.event(f"{ticker.symbol}: количество ниже minOrderQty Bybit", "WARN") self.storage.event(f"{ticker.symbol}: количество ниже minOrderQty Bybit", "WARN")
return None return None
@@ -171,6 +180,7 @@ class PaperBroker:
entry_reason=signal.reason, entry_reason=signal.reason,
entry_confidence=signal.confidence, entry_confidence=signal.confidence,
entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")), entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")),
entry_diagnostics=signal.diagnostics,
) )
position.id = self.storage.insert_position(position) position.id = self.storage.insert_position(position)
self.positions.append(position) self.positions.append(position)
@@ -189,6 +199,7 @@ class PaperBroker:
reason=signal.reason, reason=signal.reason,
entry_pattern=position.entry_pattern, entry_pattern=position.entry_pattern,
entry_confidence=position.entry_confidence, entry_confidence=position.entry_confidence,
entry_diagnostics=position.entry_diagnostics,
opened_at=position.opened_at, opened_at=position.opened_at,
) )
) )
@@ -230,6 +241,7 @@ class PaperBroker:
reason=reason, reason=reason,
entry_pattern=position.entry_pattern, entry_pattern=position.entry_pattern,
entry_confidence=position.entry_confidence, entry_confidence=position.entry_confidence,
entry_diagnostics=position.entry_diagnostics,
opened_at=position.opened_at, opened_at=position.opened_at,
closed_at=utc_now(), closed_at=utc_now(),
) )
@@ -266,14 +278,66 @@ class PaperBroker:
value = default value = default
return max(low, min(high, value)) return max(low, min(high, value))
def _entry_budget(self, signal: Signal, ticker: Ticker, extra_cap: float | None = None) -> float: def minimum_entry_budget(self, instrument: Instrument | None, ticker: Ticker | None = None) -> float:
fill_price = self._buy_price(ticker) if ticker is not None else None
return self._minimum_entry_budget(instrument, fill_price)
def _minimum_entry_budget(self, instrument: Instrument | None, fill_price: float | None = None) -> float:
minimum = max(0.0, self.settings.min_position_usdt)
if instrument:
exchange_notional = max(0.0, instrument.min_notional_value)
if fill_price and fill_price > 0:
minimum_qty = max(0.0, instrument.min_order_qty)
if exchange_notional > 0:
minimum_qty = max(
minimum_qty,
_round_step_up(exchange_notional / fill_price, instrument.qty_step),
)
if minimum_qty > 0:
exchange_notional = max(exchange_notional, minimum_qty * fill_price)
if exchange_notional > 0:
exchange_minimum = exchange_notional * (1 + self.settings.taker_fee_rate) * 1.002 + 0.01
minimum = max(minimum, exchange_minimum)
return minimum
def _raise_qty_to_exchange_minimum(
self,
qty: float,
fill_price: float,
instrument: Instrument,
budget: float,
) -> float:
minimum_qty = max(0.0, instrument.min_order_qty)
if instrument.min_notional_value > 0 and fill_price > 0:
minimum_qty = max(
minimum_qty,
_round_step_up(instrument.min_notional_value / fill_price, instrument.qty_step),
)
if minimum_qty <= qty:
return qty
minimum_cost = minimum_qty * fill_price * (1 + self.settings.taker_fee_rate)
if minimum_cost <= budget + 1e-9:
return minimum_qty
return qty
def _entry_budget(
self,
signal: Signal,
ticker: Ticker,
extra_cap: float | None = None,
minimum_notional: float = 0.0,
) -> float:
available = max(0.0, self.cash - self.settings.min_cash_reserve_usdt) available = max(0.0, self.cash - self.settings.min_cash_reserve_usdt)
rules = signal.diagnostics.get("adaptive_rules") or {} rules = signal.diagnostics.get("adaptive_rules") or {}
target_total = self._adaptive_cap(rules, "target_total_exposure_usdt", self.settings.max_total_exposure_usdt) target_total = self._adaptive_cap(rules, "target_total_exposure_usdt", self.settings.max_total_exposure_usdt)
target_symbol = self._adaptive_cap(rules, "target_symbol_exposure_usdt", self.settings.max_symbol_exposure_usdt) target_symbol = self._adaptive_cap(rules, "target_symbol_exposure_usdt", self.settings.max_symbol_exposure_usdt)
exposure_room = max(0.0, target_total - self.exposure()) exposure_room = max(0.0, target_total - self.exposure())
symbol_room = max(0.0, target_symbol - self.symbol_exposure(ticker.symbol)) symbol_room = max(0.0, target_symbol - self.symbol_exposure(ticker.symbol))
caps = [self._signal_notional(signal), available, exposure_room, symbol_room] requested = min(
max(self._signal_notional(signal), minimum_notional),
max(0.0, self.settings.max_position_usdt),
)
caps = [requested, available, exposure_room, symbol_room]
if extra_cap is not None: if extra_cap is not None:
caps.append(max(0.0, extra_cap)) caps.append(max(0.0, extra_cap))
return max(0.0, min(caps)) return max(0.0, min(caps))
@@ -317,13 +381,23 @@ class LiveBroker(PaperBroker):
instrument: Instrument | None, instrument: Instrument | None,
prices: dict[str, float], prices: dict[str, float],
) -> Position | None: ) -> Position | None:
requested_notional = min(self._signal_notional(signal), self.settings.live_order_max_usdt) fill_price = self._buy_price(ticker)
minimum_budget = self._minimum_entry_budget(instrument, fill_price)
requested_notional = min(
max(self._signal_notional(signal), minimum_budget),
self.settings.live_order_max_usdt,
)
allowed, reason = self.can_open(ticker.symbol, prices, requested_notional) allowed, reason = self.can_open(ticker.symbol, prices, requested_notional)
if not allowed: if not allowed:
self.storage.event(f"{ticker.symbol}: live BUY пропущен, {reason}", "WARN") self.storage.event(f"{ticker.symbol}: live BUY пропущен, {reason}", "WARN")
return None return None
budget = self._entry_budget(signal, ticker, self.settings.live_order_max_usdt) budget = self._entry_budget(
if budget < self.settings.min_position_usdt: signal,
ticker,
self.settings.live_order_max_usdt,
minimum_notional=minimum_budget,
)
if budget < max(self.settings.min_position_usdt, minimum_budget):
self.storage.event(f"{ticker.symbol}: live BUY skipped, adjusted budget below minimum", "WARN") self.storage.event(f"{ticker.symbol}: live BUY skipped, adjusted budget below minimum", "WARN")
return None return None
signal.diagnostics["position_notional_usdt"] = budget signal.diagnostics["position_notional_usdt"] = budget
@@ -354,3 +428,12 @@ class LiveBroker(PaperBroker):
def prices_from_tickers(tickers: Iterable[Ticker]) -> dict[str, float]: def prices_from_tickers(tickers: Iterable[Ticker]) -> dict[str, float]:
return {ticker.symbol: ticker.last_price for ticker in tickers} return {ticker.symbol: ticker.last_price for ticker in tickers}
def _symbol_position_limit(settings: Settings) -> int:
configured_limit = max(1, settings.max_positions_per_symbol)
exposure_based_limit = max(
1,
int(settings.max_symbol_exposure_usdt // max(settings.min_position_usdt, 0.01)),
)
return min(configured_limit, exposure_based_limit)
+1
View File
@@ -299,6 +299,7 @@ def _neutral_rules(settings: Settings, reason: str | None = None) -> dict[str, A
"ema_exit_mode": "normal", "ema_exit_mode": "normal",
"rsi_exit_mode": "normal", "rsi_exit_mode": "normal",
"stop_loss_percent": settings.stop_loss_percent, "stop_loss_percent": settings.stop_loss_percent,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
"take_profit_percent": settings.take_profit_percent, "take_profit_percent": settings.take_profit_percent,
"trailing_stop_percent": settings.trailing_stop_percent, "trailing_stop_percent": settings.trailing_stop_percent,
"symbol_threshold_adjustments": {}, "symbol_threshold_adjustments": {},
+27 -1
View File
@@ -10,12 +10,26 @@ import websockets
from crypto_spot_bot.bybit import BybitClient, Instrument, websocket_subscribe_message from crypto_spot_bot.bybit import BybitClient, Instrument, websocket_subscribe_message
from crypto_spot_bot.config import Settings from crypto_spot_bot.config import Settings
from crypto_spot_bot.data_quality import analyze_symbol_quality, market_quality_snapshot
from crypto_spot_bot.indicators import add_indicators from crypto_spot_bot.indicators import add_indicators
from crypto_spot_bot.models import Candle, Ticker, utc_now from crypto_spot_bot.models import Candle, Ticker, utc_now
from crypto_spot_bot.storage import Storage from crypto_spot_bot.storage import Storage
POPULAR_FALLBACK = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "LTCUSDT"] POPULAR_FALLBACK = [
"BTCUSDT",
"ETHUSDT",
"HYPEUSDT",
"SOLUSDT",
"XRPUSDT",
"XPLUSDT",
"WLDUSDT",
"MNTUSDT",
"HUSDT",
"XAUTUSDT",
"IPUSDT",
"AAVEUSDT",
]
def _float(value: Any, default: float = 0.0) -> float: def _float(value: Any, default: float = 0.0) -> float:
@@ -217,6 +231,12 @@ class MarketData:
return { return {
"symbols": self.symbols, "symbols": self.symbols,
"ws_connected": self.ws_connected, "ws_connected": self.ws_connected,
"quality": market_quality_snapshot(
symbols=self.symbols,
candles_by_symbol=self.candles,
tickers=self.tickers,
interval=self.settings.base_interval,
),
"last_rest_refresh_at": self.last_rest_refresh_at.isoformat() "last_rest_refresh_at": self.last_rest_refresh_at.isoformat()
if self.last_rest_refresh_at if self.last_rest_refresh_at
else None, else None,
@@ -230,6 +250,12 @@ class MarketData:
"trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]], "trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]],
"pattern": self.patterns.get(symbol), "pattern": self.patterns.get(symbol),
"forecast": self.forecasts.get(symbol), "forecast": self.forecasts.get(symbol),
"quality": analyze_symbol_quality(
symbol=symbol,
candles=self.candles.get(symbol, []),
ticker=self.tickers.get(symbol),
interval=self.settings.base_interval,
),
"instrument": asdict(self.instruments[symbol]) if symbol in self.instruments else None, "instrument": asdict(self.instruments[symbol]) if symbol in self.instruments else None,
} }
for symbol in self.symbols for symbol in self.symbols
+2
View File
@@ -87,6 +87,7 @@ class Position:
entry_reason: str = "" entry_reason: str = ""
entry_confidence: float = 0.0 entry_confidence: float = 0.0
entry_pattern: str = "" entry_pattern: str = ""
entry_diagnostics: dict[str, Any] = field(default_factory=dict)
def mark_price(self, price: float) -> float: def mark_price(self, price: float) -> float:
return self.qty * price return self.qty * price
@@ -127,6 +128,7 @@ class Trade:
reason: str = "" reason: str = ""
entry_pattern: str = "" entry_pattern: str = ""
entry_confidence: float = 0.0 entry_confidence: float = 0.0
entry_diagnostics: dict[str, Any] = field(default_factory=dict)
opened_at: datetime | None = None opened_at: datetime | None = None
closed_at: datetime | None = None closed_at: datetime | None = None
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from crypto_spot_bot.bybit import BybitClient, Instrument
from crypto_spot_bot.config import Settings
from crypto_spot_bot.storage import Storage
def reconciliation_snapshot(
*,
settings: Settings,
storage: Storage,
client: BybitClient,
instruments: dict[str, Instrument],
) -> dict[str, Any]:
local_positions = storage.open_positions()
local = [
{
"id": position.id,
"symbol": position.symbol,
"qty": position.qty,
"entry_price": position.entry_price,
"notional_usdt": position.notional_usdt,
}
for position in local_positions
]
if not settings.live_ready:
return {
"status": "paper",
"live_ready": False,
"local_positions": local,
"remote_balances": [],
"open_orders": [],
"discrepancies": [],
"message": "reconciliation requires unlocked live Bybit credentials",
}
try:
coins = _coins_for_symbols(settings.symbols, instruments)
wallet = client.wallet_balance(coin=",".join(coins))
orders = client.realtime_orders(category="spot", open_only=0, limit=50)
except Exception as exc:
return {
"status": "error",
"live_ready": True,
"local_positions": local,
"remote_balances": [],
"open_orders": [],
"discrepancies": [{"severity": "error", "code": "bybit_read_failed", "message": str(exc)}],
}
balances = _balances(wallet)
open_orders = orders.get("list", []) if isinstance(orders.get("list"), list) else []
discrepancies = _discrepancies(local_positions, balances, instruments)
return {
"status": "warn" if discrepancies else "ok",
"live_ready": True,
"local_positions": local,
"remote_balances": balances,
"open_orders": open_orders,
"discrepancies": discrepancies,
"account": _account_summary(wallet),
"instruments": {symbol: asdict(info) for symbol, info in instruments.items() if symbol in settings.symbols},
}
def _coins_for_symbols(symbols: tuple[str, ...], instruments: dict[str, Instrument]) -> list[str]:
coins = {"USDT"}
for symbol in symbols:
info = instruments.get(symbol)
if info and info.base_coin:
coins.add(info.base_coin.upper())
elif symbol.endswith("USDT"):
coins.add(symbol[:-4].upper())
return sorted(coins)
def _balances(wallet: dict[str, Any]) -> list[dict[str, Any]]:
rows = wallet.get("list")
if not isinstance(rows, list) or not rows:
return []
coins = rows[0].get("coin")
if not isinstance(coins, list):
return []
output = []
for row in coins:
if not isinstance(row, dict):
continue
output.append(
{
"coin": str(row.get("coin", "")),
"equity": _float(row.get("equity")),
"wallet_balance": _float(row.get("walletBalance")),
"locked": _float(row.get("locked")),
"usd_value": _float(row.get("usdValue")),
}
)
return output
def _account_summary(wallet: dict[str, Any]) -> dict[str, Any]:
rows = wallet.get("list")
if not isinstance(rows, list) or not rows:
return {}
row = rows[0]
return {
"account_type": row.get("accountType"),
"total_equity": _float(row.get("totalEquity")),
"total_wallet_balance": _float(row.get("totalWalletBalance")),
"total_available_balance": _float(row.get("totalAvailableBalance")),
}
def _discrepancies(
positions: list[Any],
balances: list[dict[str, Any]],
instruments: dict[str, Instrument],
) -> list[dict[str, Any]]:
by_coin = {str(row.get("coin", "")).upper(): row for row in balances}
local_by_coin: dict[str, float] = {}
for position in positions:
info = instruments.get(position.symbol)
coin = info.base_coin.upper() if info and info.base_coin else position.symbol.removesuffix("USDT")
local_by_coin[coin] = local_by_coin.get(coin, 0.0) + float(position.qty)
issues = []
for coin, local_qty in local_by_coin.items():
remote_qty = _float((by_coin.get(coin) or {}).get("equity"))
tolerance = max(1e-8, local_qty * 0.002)
if remote_qty + tolerance < local_qty:
issues.append(
{
"severity": "error",
"code": "remote_balance_below_local_position",
"coin": coin,
"local_qty": round(local_qty, 10),
"remote_qty": round(remote_qty, 10),
}
)
for coin, row in by_coin.items():
if coin == "USDT":
continue
remote_qty = _float(row.get("equity"))
local_qty = local_by_coin.get(coin, 0.0)
if remote_qty > max(1e-8, local_qty * 1.05) and local_qty <= 0:
issues.append(
{
"severity": "warn",
"code": "remote_asset_without_local_position",
"coin": coin,
"remote_qty": round(remote_qty, 10),
}
)
return issues
def _float(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
+39 -4
View File
@@ -43,6 +43,7 @@ class Storage:
entry_reason TEXT NOT NULL DEFAULT '', entry_reason TEXT NOT NULL DEFAULT '',
entry_confidence REAL NOT NULL DEFAULT 0, entry_confidence REAL NOT NULL DEFAULT 0,
entry_pattern TEXT NOT NULL DEFAULT '', entry_pattern TEXT NOT NULL DEFAULT '',
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'OPEN' status TEXT NOT NULL DEFAULT 'OPEN'
); );
CREATE TABLE IF NOT EXISTS trades ( CREATE TABLE IF NOT EXISTS trades (
@@ -58,6 +59,7 @@ class Storage:
reason TEXT NOT NULL DEFAULT '', reason TEXT NOT NULL DEFAULT '',
entry_pattern TEXT NOT NULL DEFAULT '', entry_pattern TEXT NOT NULL DEFAULT '',
entry_confidence REAL NOT NULL DEFAULT 0, entry_confidence REAL NOT NULL DEFAULT 0,
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
opened_at TEXT, opened_at TEXT,
closed_at TEXT closed_at TEXT
); );
@@ -113,6 +115,7 @@ class Storage:
"entry_reason": "TEXT NOT NULL DEFAULT ''", "entry_reason": "TEXT NOT NULL DEFAULT ''",
"entry_confidence": "REAL NOT NULL DEFAULT 0", "entry_confidence": "REAL NOT NULL DEFAULT 0",
"entry_pattern": "TEXT NOT NULL DEFAULT ''", "entry_pattern": "TEXT NOT NULL DEFAULT ''",
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
}.items(): }.items():
if column not in columns: if column not in columns:
conn.execute(f"ALTER TABLE positions ADD COLUMN {column} {definition}") conn.execute(f"ALTER TABLE positions ADD COLUMN {column} {definition}")
@@ -123,6 +126,7 @@ class Storage:
for column, definition in { for column, definition in {
"entry_pattern": "TEXT NOT NULL DEFAULT ''", "entry_pattern": "TEXT NOT NULL DEFAULT ''",
"entry_confidence": "REAL NOT NULL DEFAULT 0", "entry_confidence": "REAL NOT NULL DEFAULT 0",
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
}.items(): }.items():
if column not in trade_columns: if column not in trade_columns:
conn.execute(f"ALTER TABLE trades ADD COLUMN {column} {definition}") conn.execute(f"ALTER TABLE trades ADD COLUMN {column} {definition}")
@@ -134,8 +138,8 @@ class Storage:
INSERT INTO positions ( INSERT INTO positions (
symbol, qty, entry_price, notional_usdt, entry_fee_usdt, stop_loss, symbol, qty, entry_price, notional_usdt, entry_fee_usdt, stop_loss,
take_profit, highest_price, opened_at, entry_reason, take_profit, highest_price, opened_at, entry_reason,
entry_confidence, entry_pattern, status entry_confidence, entry_pattern, entry_diagnostics_json, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN') ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN')
""", """,
( (
position.symbol, position.symbol,
@@ -150,6 +154,7 @@ class Storage:
position.entry_reason, position.entry_reason,
position.entry_confidence, position.entry_confidence,
position.entry_pattern, position.entry_pattern,
json.dumps(position.entry_diagnostics, ensure_ascii=False),
), ),
) )
return int(cur.lastrowid) return int(cur.lastrowid)
@@ -185,6 +190,7 @@ class Storage:
entry_reason=row["entry_reason"], entry_reason=row["entry_reason"],
entry_confidence=float(row["entry_confidence"]), entry_confidence=float(row["entry_confidence"]),
entry_pattern=row["entry_pattern"], entry_pattern=row["entry_pattern"],
entry_diagnostics=_json_or_default(row["entry_diagnostics_json"], {}),
) )
for row in rows for row in rows
] ]
@@ -196,8 +202,8 @@ class Storage:
INSERT INTO trades ( INSERT INTO trades (
symbol, side, qty, entry_price, exit_price, gross_pnl, symbol, side, qty, entry_price, exit_price, gross_pnl,
fee_usdt, net_pnl, reason, entry_pattern, entry_confidence, fee_usdt, net_pnl, reason, entry_pattern, entry_confidence,
opened_at, closed_at entry_diagnostics_json, opened_at, closed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
trade.symbol, trade.symbol,
@@ -211,6 +217,7 @@ class Storage:
trade.reason, trade.reason,
trade.entry_pattern, trade.entry_pattern,
trade.entry_confidence, trade.entry_confidence,
json.dumps(trade.entry_diagnostics, ensure_ascii=False),
trade.opened_at.isoformat() if trade.opened_at else None, trade.opened_at.isoformat() if trade.opened_at else None,
trade.closed_at.isoformat() if trade.closed_at else None, trade.closed_at.isoformat() if trade.closed_at else None,
), ),
@@ -235,6 +242,34 @@ class Storage:
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def closed_trade_summary(self) -> dict[str, Any]:
with self.connect() as conn:
row = conn.execute(
"""
SELECT
COUNT(*) AS trades,
COALESCE(SUM(net_pnl), 0) AS net_pnl,
COALESCE(SUM(gross_pnl), 0) AS gross_pnl,
COALESCE(SUM(fee_usdt), 0) AS fee_usdt,
COALESCE(SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END), 0) AS wins,
COALESCE(SUM(CASE WHEN net_pnl < 0 THEN 1 ELSE 0 END), 0) AS losses
FROM trades
WHERE side='SELL' AND closed_at IS NOT NULL
"""
).fetchone()
trades = int(row["trades"] if row else 0)
wins = int(row["wins"] if row else 0)
losses = int(row["losses"] if row else 0)
return {
"trades": trades,
"net_pnl": round(float(row["net_pnl"] if row else 0.0), 6),
"gross_pnl": round(float(row["gross_pnl"] if row else 0.0), 6),
"fee_usdt": round(float(row["fee_usdt"] if row else 0.0), 6),
"wins": wins,
"losses": losses,
"win_rate": round(wins / trades, 4) if trades else 0.0,
}
def insert_signal(self, signal: Signal) -> None: def insert_signal(self, signal: Signal) -> None:
with self.connect() as conn: with self.connect() as conn:
conn.execute( conn.execute(
+433 -48
View File
@@ -28,8 +28,11 @@ class SpotStrategy:
return _torch_forecast_entry_signal( return _torch_forecast_entry_signal(
settings=self.settings, settings=self.settings,
symbol=symbol, symbol=symbol,
candles=candles,
ticker=ticker, ticker=ticker,
open_positions_for_symbol=open_positions_for_symbol, open_positions_for_symbol=open_positions_for_symbol,
pattern=pattern or {},
llm=llm or {},
forecast=forecast or {}, forecast=forecast or {},
account=account, account=account,
) )
@@ -317,7 +320,8 @@ class SpotStrategy:
diagnostics = { diagnostics = {
"price": price, "price": price,
"entry_price": position.entry_price, "entry_price": position.entry_price,
"stop_loss": position.stop_loss, "stop_loss": position.stop_loss if self.settings.stop_loss_exit_enabled else None,
"stop_loss_exit_enabled": self.settings.stop_loss_exit_enabled,
"take_profit": position.take_profit, "take_profit": position.take_profit,
"highest_price": position.highest_price, "highest_price": position.highest_price,
"trailing_stop": trailing, "trailing_stop": trailing,
@@ -325,7 +329,7 @@ class SpotStrategy:
"ema_20": latest.ema_20, "ema_20": latest.ema_20,
"ema_50": latest.ema_50, "ema_50": latest.ema_50,
} }
if price <= position.stop_loss: if self.settings.stop_loss_exit_enabled and price <= position.stop_loss:
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
if price >= position.take_profit: if price >= position.take_profit:
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
@@ -386,14 +390,16 @@ class SpotStrategy:
trailing_percent = _adaptive_percent( trailing_percent = _adaptive_percent(
adaptive, "trailing_stop_percent", self.settings.trailing_stop_percent, 0.003, 0.08 adaptive, "trailing_stop_percent", self.settings.trailing_stop_percent, 0.003, 0.08
) )
effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent)) effective_stop_loss = _effective_stop_loss(self.settings, position, stop_loss_percent)
effective_take_profit = position.entry_price * (1 + take_profit_percent) effective_take_profit = position.entry_price * (1 + take_profit_percent)
trailing = position.trailing_stop(trailing_percent) trailing = position.trailing_stop(trailing_percent)
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, self.settings) estimated_exit_net_percent = _estimated_exit_net_percent(position, price, self.settings)
min_exit_net_percent = _min_exit_net_percent(self.settings)
diagnostics = { diagnostics = {
"price": price, "price": price,
"entry_price": position.entry_price, "entry_price": position.entry_price,
"stop_loss": effective_stop_loss, "stop_loss": effective_stop_loss,
"stop_loss_exit_enabled": self.settings.stop_loss_exit_enabled,
"take_profit": effective_take_profit, "take_profit": effective_take_profit,
"highest_price": position.highest_price, "highest_price": position.highest_price,
"trailing_stop": trailing, "trailing_stop": trailing,
@@ -403,9 +409,10 @@ class SpotStrategy:
"adaptive_rules": adaptive, "adaptive_rules": adaptive,
"forecast": forecast, "forecast": forecast,
"estimated_exit_net_percent": round(estimated_exit_net_percent, 4), "estimated_exit_net_percent": round(estimated_exit_net_percent, 4),
"min_exit_net_percent": min_exit_net_percent,
"min_exit_profit_percent": float(adaptive.get("min_exit_profit_percent", 0.0) or 0.0), "min_exit_profit_percent": float(adaptive.get("min_exit_profit_percent", 0.0) or 0.0),
} }
if price <= effective_stop_loss: if effective_stop_loss is not None and price <= effective_stop_loss:
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
if price >= effective_take_profit: if price >= effective_take_profit:
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
@@ -433,6 +440,7 @@ class SpotStrategy:
estimated_exit_net_percent=estimated_exit_net_percent, estimated_exit_net_percent=estimated_exit_net_percent,
stop_loss_percent=stop_loss_percent, stop_loss_percent=stop_loss_percent,
min_edge_percent=self.settings.time_series_min_edge_percent, min_edge_percent=self.settings.time_series_min_edge_percent,
min_exit_net_percent=min_exit_net_percent,
) )
if forecast_exit is not None: if forecast_exit is not None:
action, confidence, reason = forecast_exit action, confidence, reason = forecast_exit
@@ -577,11 +585,9 @@ def _trend_macd_exit_signal(
previous = candles[-2] previous = candles[-2]
price = ticker.last_price price = ticker.last_price
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08) stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent)) effective_stop_loss = _effective_stop_loss(settings, position, stop_loss_percent)
atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0) atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0)
atr_trailing_stop = None atr_trailing_stop = _atr_trailing_stop(settings, position, latest.atr_14, atr_multiplier, effective_stop_loss)
if latest.atr_14 is not None and position.highest_price > position.entry_price:
atr_trailing_stop = max(effective_stop_loss, position.highest_price - latest.atr_14 * atr_multiplier)
macd_cross_down = _macd_crossed_down(previous, latest) macd_cross_down = _macd_crossed_down(previous, latest)
close_below_ema50 = latest.ema_50 is not None and latest.close < latest.ema_50 close_below_ema50 = latest.ema_50 is not None and latest.close < latest.ema_50
diagnostics = { diagnostics = {
@@ -589,6 +595,7 @@ def _trend_macd_exit_signal(
"price": price, "price": price,
"entry_price": position.entry_price, "entry_price": position.entry_price,
"stop_loss": effective_stop_loss, "stop_loss": effective_stop_loss,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
"atr_trailing_stop": atr_trailing_stop, "atr_trailing_stop": atr_trailing_stop,
"atr_trailing_multiplier": atr_multiplier, "atr_trailing_multiplier": atr_multiplier,
"highest_price": position.highest_price, "highest_price": position.highest_price,
@@ -600,7 +607,7 @@ def _trend_macd_exit_signal(
"macd_cross_down": macd_cross_down, "macd_cross_down": macd_cross_down,
"close_below_ema50": close_below_ema50, "close_below_ema50": close_below_ema50,
} }
if price <= effective_stop_loss: if effective_stop_loss is not None and price <= effective_stop_loss:
return Signal(position.symbol, "SELL", 1.0, "trend_macd: сработал стоп-лосс", diagnostics) return Signal(position.symbol, "SELL", 1.0, "trend_macd: сработал стоп-лосс", diagnostics)
if atr_trailing_stop is not None and price <= atr_trailing_stop: if atr_trailing_stop is not None and price <= atr_trailing_stop:
return Signal(position.symbol, "SELL", 0.94, "trend_macd: сработал ATR trailing stop", diagnostics) return Signal(position.symbol, "SELL", 0.94, "trend_macd: сработал ATR trailing stop", diagnostics)
@@ -615,39 +622,126 @@ def _torch_forecast_entry_signal(
*, *,
settings: Settings, settings: Settings,
symbol: str, symbol: str,
candles: list[Candle] | None,
ticker: Ticker | None, ticker: Ticker | None,
open_positions_for_symbol: int, open_positions_for_symbol: int,
pattern: dict,
llm: dict,
forecast: dict, forecast: dict,
account: dict | None, account: dict | None,
) -> Signal: ) -> Signal:
if ticker is None: if ticker is None:
return Signal(symbol, "HOLD", 0.0, "torch_forecast: no ticker data") return Signal(symbol, "HOLD", 0.0, "torch_forecast: no ticker data")
if open_positions_for_symbol > 0: if open_positions_for_symbol >= _dynamic_symbol_position_limit(settings):
return Signal(symbol, "HOLD", 0.0, "torch_forecast: position for symbol is already open") return Signal(symbol, "HOLD", 0.0, "torch_forecast: symbol position limit reached")
account_context = dict(account or {})
account_context.setdefault("symbol", symbol)
account_context.setdefault("open_positions_for_symbol", open_positions_for_symbol)
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08) stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
sizing = _torch_forecast_position_sizing(settings, account, stop_loss_percent, forecast) sizing = _torch_forecast_position_sizing(settings, account_context, stop_loss_percent, forecast, symbol)
position_notional = float(sizing["notional_usdt"]) position_notional = float(sizing["notional_usdt"])
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0) expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
probability_up = _safe_float(forecast.get("probability_up"), 0.5) probability_up = _safe_float(forecast.get("probability_up"), 0.5)
skill = _safe_float(forecast.get("skill"), 0.0) skill = _safe_float(forecast.get("skill"), 0.0)
min_edge = max(0.0, settings.time_series_min_edge_percent) min_edge = max(0.0, settings.time_series_min_edge_percent)
min_probability = _torch_min_probability(settings) min_probability = _torch_min_probability(settings)
probe_min_edge = max(0.0, min(settings.time_series_probe_min_edge_percent, min_edge))
probe_min_probability = round(
_clamp(settings.time_series_probe_min_probability_up, min_probability, 0.85),
4,
)
full_edge_ok = expected_return >= min_edge
probe_edge_ok = bool(
settings.time_series_probe_enabled
and not full_edge_ok
and expected_return >= probe_min_edge
and probability_up >= probe_min_probability
)
edge_mode = "full" if full_edge_ok else ("probe" if probe_edge_ok else "blocked")
if probe_edge_ok and position_notional > 0:
probe_multiplier = _clamp(settings.time_series_probe_size_multiplier, 0.05, 1.0)
position_notional = round(
min(
settings.max_position_usdt,
max(settings.min_position_usdt, position_notional * probe_multiplier),
),
2,
)
sizing = {
**sizing,
"notional_usdt": position_notional,
"probe_size_multiplier": round(probe_multiplier, 4),
"edge_mode": "probe",
}
confidence = _torch_forecast_confidence(settings, forecast) confidence = _torch_forecast_confidence(settings, forecast)
spread_ok = ticker.spread_percent <= settings.max_spread_percent spread_ok = ticker.spread_percent <= settings.max_spread_percent
liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt
model_ok = _is_torch_forecast(forecast) model_ok = _is_torch_forecast(forecast)
quality_gate_ok = forecast.get("quality_gate_passed") is not False
rebound = _torch_rebound_overlay(
settings=settings,
candles=candles or [],
ticker=ticker,
pattern=pattern,
llm=llm,
spread_ok=spread_ok,
liquidity_ok=liquidity_ok,
)
rebound_model_probability_min = round(
_clamp(settings.time_series_probe_min_probability_up, 0.50, 0.75),
4,
)
missing_torch_model = _missing_torch_model(forecast)
model_rebound_entry_ok = bool(
rebound.get("active")
and model_ok
and quality_gate_ok
and bool(forecast.get("usable", False))
and not bool(forecast.get("block_entry", False))
and expected_return >= 0.0
and probability_up >= rebound_model_probability_min
and skill > 0.0
and confidence >= settings.time_series_min_confidence
)
fallback_rebound_entry_ok = bool(
settings.time_series_rebound_fallback_enabled
and rebound.get("active")
and missing_torch_model
and quality_gate_ok
and not bool(forecast.get("block_entry", False))
and confidence >= settings.time_series_min_confidence
)
rebound_entry_ok = model_rebound_entry_ok or fallback_rebound_entry_ok
if rebound_entry_ok and position_notional > 0:
rebound_cap = max(settings.min_position_usdt, settings.rebound_max_position_usdt)
position_notional = round(
min(settings.max_position_usdt, rebound_cap, max(settings.min_position_usdt, position_notional)),
2,
)
sizing_method = "torch_forecast_rebound_fallback" if fallback_rebound_entry_ok else "torch_forecast_rebound"
sizing = {
**sizing,
"method": sizing_method,
"notional_usdt": position_notional,
"edge_mode": "rebound_fallback" if fallback_rebound_entry_ok else "rebound",
"rebound_probability": rebound.get("probability", 0.0),
}
edge_mode = "rebound_fallback" if fallback_rebound_entry_ok else "rebound"
risk_size_ok = position_notional >= settings.min_position_usdt
rebound_entry_sized_ok = rebound_entry_ok and risk_size_ok
checks = { checks = {
"torch_model_ok": model_ok, "torch_model_ok": model_ok,
"quality_gate_ok": quality_gate_ok,
"forecast_usable": bool(forecast.get("usable", False)), "forecast_usable": bool(forecast.get("usable", False)),
"forecast_not_blocked": not bool(forecast.get("block_entry", False)), "forecast_not_blocked": not bool(forecast.get("block_entry", False)),
"expected_edge_ok": expected_return >= min_edge, "expected_edge_ok": full_edge_ok or probe_edge_ok,
"probability_ok": probability_up >= min_probability, "probability_ok": probability_up >= min_probability,
"skill_ok": skill > 0.0, "skill_ok": skill > 0.0,
"confidence_ok": confidence >= settings.min_signal_confidence, "confidence_ok": confidence >= settings.time_series_min_confidence,
"spread_ok": spread_ok, "spread_ok": spread_ok,
"liquidity_ok": liquidity_ok, "liquidity_ok": liquidity_ok,
"risk_size_ok": position_notional >= settings.min_position_usdt, "risk_size_ok": risk_size_ok,
} }
diagnostics = { diagnostics = {
"strategy_mode": "torch_forecast", "strategy_mode": "torch_forecast",
@@ -659,33 +753,103 @@ def _torch_forecast_entry_signal(
"atr_trailing_multiplier": _clamp(settings.atr_trailing_multiplier, 0.5, 10.0), "atr_trailing_multiplier": _clamp(settings.atr_trailing_multiplier, 0.5, 10.0),
"expected_return_percent": expected_return, "expected_return_percent": expected_return,
"min_edge_percent": min_edge, "min_edge_percent": min_edge,
"probe_enabled": settings.time_series_probe_enabled,
"probe_min_edge_percent": probe_min_edge,
"probe_min_probability_up": probe_min_probability,
"edge_mode": edge_mode,
"probability_up": probability_up, "probability_up": probability_up,
"min_probability_up": min_probability, "min_probability_up": min_probability,
"rebound_model_probability_min": rebound_model_probability_min,
"missing_torch_model": missing_torch_model,
"time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
"model_rebound_entry_ok": model_rebound_entry_ok,
"fallback_rebound_entry_ok": fallback_rebound_entry_ok,
"rebound_entry_ok": rebound_entry_ok,
"rebound_entry_sized_ok": rebound_entry_sized_ok,
"min_confidence": settings.time_series_min_confidence,
"skill": skill, "skill": skill,
"quality_gate": forecast.get("quality_gate", {}),
"quality_gate_passed": forecast.get("quality_gate_passed"),
"spread_percent": round(ticker.spread_percent, 5), "spread_percent": round(ticker.spread_percent, 5),
"turnover_24h": ticker.turnover_24h, "turnover_24h": ticker.turnover_24h,
"checks": checks, "checks": checks,
"grid": {"enabled": False, "active": False}, "grid": {"enabled": False, "active": False},
"rebound": {"enabled": False, "active": False}, "rebound": rebound,
"learning": {}, "learning": {},
"llm": {}, "llm": {},
} }
if all(checks.values()): base_entry_ok = all(checks.values())
if base_entry_ok or rebound_entry_sized_ok:
buy_confidence = max(confidence, float(rebound.get("probability", 0.0) or 0.0)) if rebound_entry_sized_ok else confidence
entry_path = edge_mode if rebound_entry_ok and not base_entry_ok else edge_mode
diagnostics["entry_path"] = entry_path
if fallback_rebound_entry_ok and not base_entry_ok:
reason = (
"torch_forecast: rebound fallback confirmed without PyTorch model; "
f"rebound_probability={float(rebound.get('probability', 0.0) or 0.0):.3f}, "
f"size={position_notional:.2f} USDT"
)
elif rebound_entry_ok and not base_entry_ok:
reason = (
"torch_forecast: rebound overlay confirmed; "
f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
f"expected={expected_return:.4f}%, rebound_probability={float(rebound.get('probability', 0.0) or 0.0):.3f}, "
f"size={position_notional:.2f} USDT"
)
else:
reason = (
"torch_forecast: PyTorch edge confirmed; "
f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
f"expected={expected_return:.4f}%, edge_mode={edge_mode}, "
f"size={position_notional:.2f} USDT"
)
return Signal( return Signal(
symbol, symbol,
"BUY", "BUY",
confidence, round(_clamp(buy_confidence, 0.0, 0.96), 4),
( reason,
"torch_forecast: PyTorch edge confirmed; "
f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
f"expected={expected_return:.4f}%, size={position_notional:.2f} USDT"
),
diagnostics, diagnostics,
) )
failed = ", ".join(name for name, ok in checks.items() if not ok) failed = ", ".join(name for name, ok in checks.items() if not ok)
return Signal(symbol, "HOLD", confidence, f"torch_forecast: entry blocked ({failed})", diagnostics) return Signal(symbol, "HOLD", confidence, f"torch_forecast: entry blocked ({failed})", diagnostics)
def _torch_rebound_overlay(
*,
settings: Settings,
candles: list[Candle],
ticker: Ticker,
pattern: dict,
llm: dict,
spread_ok: bool,
liquidity_ok: bool,
) -> dict:
if not settings.rebound_trading_enabled:
return {"enabled": False, "active": False, "reason": "rebound trading disabled"}
if len(candles) < 21:
return {"enabled": True, "active": False, "reason": "not enough candles for rebound"}
latest = candles[-1]
previous = candles[-2] if len(candles) >= 2 else latest
if not _has_entry_indicators(latest):
return {"enabled": True, "active": False, "reason": "entry indicators are not ready"}
volume_ok = latest.volume_ma_20 is not None and latest.volume >= latest.volume_ma_20 * 0.75
atr_percent = (latest.atr_14 / latest.close) * 100 if latest.close and latest.atr_14 is not None else 0.0
volatility_ok = 0.04 <= atr_percent <= 6.0
return _rebound_state(
settings=settings,
candles=candles,
latest=latest,
previous=previous,
pattern=pattern,
llm=llm,
spread_ok=spread_ok,
liquidity_ok=liquidity_ok,
volume_ok=volume_ok,
volatility_ok=volatility_ok,
atr_percent=atr_percent,
)
def _torch_forecast_exit_signal( def _torch_forecast_exit_signal(
settings: Settings, settings: Settings,
position: Position, position: Position,
@@ -699,11 +863,15 @@ def _torch_forecast_exit_signal(
latest = candles[-1] if candles else None latest = candles[-1] if candles else None
price = ticker.last_price price = ticker.last_price
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08) stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent)) effective_stop_loss = _effective_stop_loss(settings, position, stop_loss_percent)
atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0) atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0)
atr_trailing_stop = None atr_trailing_stop = _atr_trailing_stop(
if latest and latest.atr_14 is not None and position.highest_price > position.entry_price: settings,
atr_trailing_stop = max(effective_stop_loss, position.highest_price - latest.atr_14 * atr_multiplier) position,
latest.atr_14 if latest else None,
atr_multiplier,
effective_stop_loss,
)
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0) expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
probability_up = _safe_float(forecast.get("probability_up"), 0.5) probability_up = _safe_float(forecast.get("probability_up"), 0.5)
@@ -711,14 +879,23 @@ def _torch_forecast_exit_signal(
min_edge = max(0.0, settings.time_series_min_edge_percent) min_edge = max(0.0, settings.time_series_min_edge_percent)
min_probability = _torch_min_probability(settings) min_probability = _torch_min_probability(settings)
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, settings) estimated_exit_net_percent = _estimated_exit_net_percent(position, price, settings)
min_exit_net_percent = _min_exit_net_percent(settings)
entry_path = str(position.entry_diagnostics.get("entry_path", ""))
entry_edge_mode = str(position.entry_diagnostics.get("edge_mode", ""))
rebound_fallback_position = entry_path == "rebound_fallback" or entry_edge_mode == "rebound_fallback"
diagnostics = { diagnostics = {
"strategy_mode": "torch_forecast", "strategy_mode": "torch_forecast",
"price": price, "price": price,
"entry_price": position.entry_price, "entry_price": position.entry_price,
"stop_loss": effective_stop_loss, "stop_loss": effective_stop_loss,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
"take_profit": position.take_profit,
"atr_trailing_stop": atr_trailing_stop, "atr_trailing_stop": atr_trailing_stop,
"atr_trailing_multiplier": atr_multiplier, "atr_trailing_multiplier": atr_multiplier,
"highest_price": position.highest_price, "highest_price": position.highest_price,
"entry_path": entry_path,
"entry_edge_mode": entry_edge_mode,
"rebound_fallback_position": rebound_fallback_position,
"forecast": forecast, "forecast": forecast,
"expected_return_percent": expected_return, "expected_return_percent": expected_return,
"min_edge_percent": min_edge, "min_edge_percent": min_edge,
@@ -726,27 +903,86 @@ def _torch_forecast_exit_signal(
"min_probability_up": min_probability, "min_probability_up": min_probability,
"skill": skill, "skill": skill,
"estimated_exit_net_percent": round(estimated_exit_net_percent, 4), "estimated_exit_net_percent": round(estimated_exit_net_percent, 4),
"min_exit_net_percent": min_exit_net_percent,
"atr_14": latest.atr_14 if latest else None, "atr_14": latest.atr_14 if latest else None,
} }
if price <= effective_stop_loss: hold_seconds = (utc_now() - position.opened_at).total_seconds()
diagnostics["hold_seconds"] = hold_seconds
diagnostics["min_hold_seconds"] = settings.min_hold_seconds
if effective_stop_loss is not None and price <= effective_stop_loss:
return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics) return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics)
if price >= position.take_profit:
return Signal(position.symbol, "SELL", 0.96, "torch_forecast: take-profit hit", diagnostics)
if atr_trailing_stop is not None and price <= atr_trailing_stop: if atr_trailing_stop is not None and price <= atr_trailing_stop:
if estimated_exit_net_percent < min_exit_net_percent:
diagnostics["atr_exit_blocked_by_min_profit"] = True
if estimated_exit_net_percent < 0:
diagnostics["atr_exit_blocked_by_cost"] = True
return Signal(
position.symbol,
"HOLD",
0.45,
"torch_forecast: ATR trailing touched, but exit profit is below minimum",
diagnostics,
)
return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", diagnostics) return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", diagnostics)
if not _is_torch_forecast(forecast): if not _is_torch_forecast(forecast):
if rebound_fallback_position:
hold_seconds = (utc_now() - position.opened_at).total_seconds()
diagnostics["hold_seconds"] = hold_seconds
if hold_seconds < settings.min_hold_seconds:
return Signal(
position.symbol,
"HOLD",
0.45,
"torch_forecast: rebound fallback minimum hold",
diagnostics,
)
return Signal(
position.symbol,
"HOLD",
0.42,
"torch_forecast: rebound fallback hold without PyTorch model",
diagnostics,
)
return Signal(position.symbol, "SELL", 0.78, "torch_forecast: no valid PyTorch forecast to hold", diagnostics) return Signal(position.symbol, "SELL", 0.78, "torch_forecast: no valid PyTorch forecast to hold", diagnostics)
if bool(forecast.get("block_entry", False)) or expected_return <= 0.0 or probability_up <= 0.50: if bool(forecast.get("block_entry", False)) or expected_return <= 0.0 or probability_up <= 0.50:
if hold_seconds < settings.min_hold_seconds:
diagnostics["forecast_exit_blocked_by_min_hold"] = True
return Signal(
position.symbol,
"HOLD",
0.46,
"torch_forecast: minimum hold protects against fee churn",
diagnostics,
)
forecast_exit = _forecast_exit_signal(
forecast=forecast,
position=position,
price=price,
estimated_exit_net_percent=estimated_exit_net_percent,
stop_loss_percent=stop_loss_percent,
min_edge_percent=min_edge,
min_exit_net_percent=min_exit_net_percent,
)
if forecast_exit is not None:
action, confidence, reason = forecast_exit
return Signal(position.symbol, action, confidence, reason, diagnostics)
diagnostics["forecast_exit_blocked_by_min_profit"] = True
if estimated_exit_net_percent < 0:
diagnostics["forecast_exit_blocked_by_cost"] = True
return Signal( return Signal(
position.symbol, position.symbol,
"SELL", "HOLD",
0.86, 0.44,
( (
"torch_forecast: PyTorch forecast turned negative; " "torch_forecast: forecast weakened, but exit profit is below minimum; "
f"p_up={probability_up:.3f}, expected={expected_return:.4f}%" f"p_up={probability_up:.3f}, expected={expected_return:.4f}%"
), ),
diagnostics, diagnostics,
) )
weak_hold = expected_return < min_edge or probability_up < min_probability or skill <= 0.0 weak_hold = expected_return < min_edge or probability_up < min_probability or skill <= 0.0
if weak_hold and estimated_exit_net_percent >= 0: if weak_hold and estimated_exit_net_percent >= min_exit_net_percent:
return Signal( return Signal(
position.symbol, position.symbol,
"SELL", "SELL",
@@ -757,6 +993,8 @@ def _torch_forecast_exit_signal(
), ),
diagnostics, diagnostics,
) )
if weak_hold and estimated_exit_net_percent >= 0:
diagnostics["weak_exit_blocked_by_min_profit"] = True
return Signal(position.symbol, "HOLD", 0.35, "torch_forecast: PyTorch hold confirmed", diagnostics) return Signal(position.symbol, "HOLD", 0.35, "torch_forecast: PyTorch hold confirmed", diagnostics)
@@ -765,8 +1003,27 @@ def _is_torch_forecast(forecast: dict) -> bool:
return bool(forecast.get("usable", False)) and model in {"torch_lstm", "torch_gru"} return bool(forecast.get("usable", False)) and model in {"torch_lstm", "torch_gru"}
def _missing_torch_model(forecast: dict) -> bool:
model = str(forecast.get("model", "")).strip().lower()
reason = str(forecast.get("reason", "")).lower()
return (
not bool(forecast.get("usable", False))
and model in {"", "none"}
and "no valid pytorch" in reason
)
def _torch_min_probability(settings: Settings) -> float: def _torch_min_probability(settings: Settings) -> float:
return round(_clamp(settings.min_signal_confidence - 0.08, 0.52, 0.68), 4) return round(_clamp(settings.time_series_min_probability_up, 0.45, 0.75), 4)
def _dynamic_symbol_position_limit(settings: Settings) -> int:
configured_limit = max(1, settings.max_positions_per_symbol)
exposure_based_limit = max(
1,
int(settings.max_symbol_exposure_usdt // max(settings.min_position_usdt, 0.01)),
)
return min(configured_limit, exposure_based_limit)
def _torch_forecast_confidence(settings: Settings, forecast: dict) -> float: def _torch_forecast_confidence(settings: Settings, forecast: dict) -> float:
@@ -786,30 +1043,43 @@ def _torch_forecast_position_sizing(
account: dict | None, account: dict | None,
stop_loss_percent: float, stop_loss_percent: float,
forecast: dict, forecast: dict,
symbol: str | None = None,
) -> dict[str, float | str]: ) -> dict[str, float | str]:
base = _trend_position_sizing(settings, account, stop_loss_percent) base = _trend_position_sizing(settings, account, stop_loss_percent)
base_notional = float(base["notional_usdt"]) base_notional = float(base["notional_usdt"])
if base_notional <= 0: kelly = _kelly_position(
settings=settings,
final_score=_torch_forecast_confidence(settings, forecast),
forecast=forecast,
adaptive={},
account=account,
symbol=symbol,
)
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
min_edge = max(0.01, settings.time_series_min_edge_percent)
edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15)
probability_multiplier = _clamp(0.75 + (probability_up - 0.55) * 3.0, 0.50, 1.20)
skill_multiplier = _clamp(0.85 + skill * 0.60, 0.60, 1.15)
if settings.kelly_sizing_enabled:
raw = float(kelly["kelly_remaining_notional_usdt"]) * _risk_guard_multiplier(account)
notional = 0.0 if raw < settings.min_position_usdt else min(raw, settings.max_position_usdt)
elif base_notional <= 0:
notional = 0.0 notional = 0.0
edge_multiplier = probability_multiplier = skill_multiplier = 0.0
else: else:
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
min_edge = max(0.01, settings.time_series_min_edge_percent)
edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15)
probability_multiplier = _clamp(0.75 + (probability_up - 0.55) * 3.0, 0.50, 1.20)
skill_multiplier = _clamp(0.85 + skill * 0.60, 0.60, 1.15)
raw = base_notional * edge_multiplier * probability_multiplier * skill_multiplier raw = base_notional * edge_multiplier * probability_multiplier * skill_multiplier
notional = 0.0 if raw < settings.min_position_usdt else min(raw, settings.max_position_usdt) notional = 0.0 if raw < settings.min_position_usdt else min(raw, settings.max_position_usdt)
return { return {
**base, **base,
"method": "torch_forecast_risk", "method": "torch_forecast_fractional_kelly" if settings.kelly_sizing_enabled else "torch_forecast_risk",
"enabled": bool(settings.kelly_sizing_enabled),
"notional_usdt": round(notional, 2), "notional_usdt": round(notional, 2),
"base_notional_usdt": base["notional_usdt"], "base_notional_usdt": base["notional_usdt"],
"torch_edge_multiplier": round(edge_multiplier, 4), "torch_edge_multiplier": round(edge_multiplier, 4),
"torch_probability_multiplier": round(probability_multiplier, 4), "torch_probability_multiplier": round(probability_multiplier, 4),
"torch_skill_multiplier": round(skill_multiplier, 4), "torch_skill_multiplier": round(skill_multiplier, 4),
**kelly,
} }
@@ -851,6 +1121,8 @@ def _trend_position_sizing(
if equity <= 0: if equity <= 0:
equity = settings.starting_balance_usdt equity = settings.starting_balance_usdt
risk_fraction = _clamp(settings.risk_per_trade_percent, 0.0, 0.01) risk_fraction = _clamp(settings.risk_per_trade_percent, 0.0, 0.01)
guard_multiplier = _risk_guard_multiplier(account)
risk_fraction *= guard_multiplier
risk_usdt = equity * risk_fraction risk_usdt = equity * risk_fraction
raw_notional = risk_usdt / max(stop_loss_percent, 0.0001) raw_notional = risk_usdt / max(stop_loss_percent, 0.0001)
high = max(0.0, settings.max_position_usdt) high = max(0.0, settings.max_position_usdt)
@@ -859,6 +1131,7 @@ def _trend_position_sizing(
return { return {
"method": "fixed_fractional_risk", "method": "fixed_fractional_risk",
"risk_per_trade_percent": round(risk_fraction * 100, 4), "risk_per_trade_percent": round(risk_fraction * 100, 4),
"risk_guard_multiplier": round(guard_multiplier, 4),
"risk_usdt": round(risk_usdt, 4), "risk_usdt": round(risk_usdt, 4),
"stop_loss_percent": round(stop_loss_percent * 100, 4), "stop_loss_percent": round(stop_loss_percent * 100, 4),
"raw_notional_usdt": round(raw_notional, 4), "raw_notional_usdt": round(raw_notional, 4),
@@ -907,7 +1180,7 @@ def _position_sizing(
denominator = max(0.0001, 1.0 - settings.min_signal_confidence) denominator = max(0.0001, 1.0 - settings.min_signal_confidence)
confidence_ratio = _clamp((final_score - settings.min_signal_confidence) / denominator, 0.0, 1.0) confidence_ratio = _clamp((final_score - settings.min_signal_confidence) / denominator, 0.0, 1.0)
confidence_notional = low + (high - low) * confidence_ratio confidence_notional = low + (high - low) * confidence_ratio
risk_multiplier = _position_risk_multiplier(forecast, adaptive) risk_multiplier = _position_risk_multiplier(forecast, adaptive) * _risk_guard_multiplier(account)
method = "confidence" method = "confidence"
raw = confidence_notional raw = confidence_notional
kelly = _kelly_position( kelly = _kelly_position(
@@ -951,6 +1224,17 @@ def _position_risk_multiplier(forecast: dict | None, adaptive: dict | None) -> f
return multiplier return multiplier
def _risk_guard_multiplier(account: dict | None) -> float:
guard = (account or {}).get("risk_guard")
if not isinstance(guard, dict):
return 1.0
try:
value = float(guard.get("position_size_multiplier", 1.0))
except (TypeError, ValueError):
value = 1.0
return _clamp(value, 0.0, 1.0)
def _kelly_position( def _kelly_position(
*, *,
settings: Settings, settings: Settings,
@@ -958,6 +1242,7 @@ def _kelly_position(
forecast: dict, forecast: dict,
adaptive: dict, adaptive: dict,
account: dict | None, account: dict | None,
symbol: str | None = None,
) -> dict[str, float | bool | str]: ) -> dict[str, float | bool | str]:
confidence_probability = _confidence_probability(final_score, settings.min_signal_confidence) confidence_probability = _confidence_probability(final_score, settings.min_signal_confidence)
probability_source = "confidence" probability_source = "confidence"
@@ -970,8 +1255,13 @@ def _kelly_position(
stop_loss = _adaptive_percent(adaptive, "stop_loss_percent", settings.stop_loss_percent, 0.003, 0.08) stop_loss = _adaptive_percent(adaptive, "stop_loss_percent", settings.stop_loss_percent, 0.003, 0.08)
take_profit = _adaptive_percent(adaptive, "take_profit_percent", settings.take_profit_percent, 0.003, 0.20) take_profit = _adaptive_percent(adaptive, "take_profit_percent", settings.take_profit_percent, 0.003, 0.20)
round_trip_cost = max(0.0, 2.0 * (settings.taker_fee_rate + settings.slippage_rate)) round_trip_cost = max(0.0, 2.0 * (settings.taker_fee_rate + settings.slippage_rate))
win_return = max(0.0, take_profit - round_trip_cost) base_win_return = max(0.0, take_profit - round_trip_cost)
loss_return = max(0.0001, stop_loss + round_trip_cost) loss_return = max(0.0001, stop_loss + round_trip_cost)
expected_net_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0) / 100.0)
implied_win_return = 0.0
if probability > 0:
implied_win_return = max(0.0, (expected_net_return + (1.0 - probability) * loss_return) / probability)
win_return = max(base_win_return, implied_win_return)
reward_loss_ratio = win_return / loss_return if loss_return > 0 else 0.0 reward_loss_ratio = win_return / loss_return if loss_return > 0 else 0.0
full_kelly = probability - ((1.0 - probability) / reward_loss_ratio) if reward_loss_ratio > 0 else 0.0 full_kelly = probability - ((1.0 - probability) / reward_loss_ratio) if reward_loss_ratio > 0 else 0.0
full_kelly = max(0.0, full_kelly) full_kelly = max(0.0, full_kelly)
@@ -980,19 +1270,88 @@ def _kelly_position(
bankroll = _safe_float((account or {}).get("equity"), settings.starting_balance_usdt) bankroll = _safe_float((account or {}).get("equity"), settings.starting_balance_usdt)
if bankroll <= 0: if bankroll <= 0:
bankroll = settings.starting_balance_usdt bankroll = settings.starting_balance_usdt
kelly_notional = max(0.0, bankroll * effective_fraction) target_notional = max(0.0, bankroll * effective_fraction)
open_symbol_exposure = _account_symbol_exposure(account, symbol)
raw_remaining_notional = max(0.0, target_notional - open_symbol_exposure)
exchange_min_entry = _account_exchange_min_entry(account, settings)
remaining_notional = raw_remaining_notional
effective_target_notional = target_notional
layer_mode = False
if (
symbol
and target_notional > 0
and raw_remaining_notional < exchange_min_entry
and exchange_min_entry > settings.min_position_usdt + 1e-9
and _account_open_positions_for_symbol(account) > 0
):
room = min(
max(0.0, settings.max_position_usdt),
max(0.0, settings.max_symbol_exposure_usdt - open_symbol_exposure),
max(0.0, settings.max_total_exposure_usdt - _account_total_exposure(account)),
max(0.0, _safe_float((account or {}).get("cash"), settings.starting_balance_usdt) - settings.min_cash_reserve_usdt),
)
if room >= exchange_min_entry:
remaining_notional = exchange_min_entry
effective_target_notional = open_symbol_exposure + exchange_min_entry
layer_mode = True
return { return {
"kelly_probability": round(probability, 4), "kelly_probability": round(probability, 4),
"kelly_probability_source": probability_source, "kelly_probability_source": probability_source,
"kelly_reward_loss_ratio": round(reward_loss_ratio, 4), "kelly_reward_loss_ratio": round(reward_loss_ratio, 4),
"kelly_win_return_percent": round(win_return * 100.0, 4),
"kelly_loss_return_percent": round(loss_return * 100.0, 4),
"kelly_expected_net_percent": round(expected_net_return * 100.0, 4),
"kelly_full_fraction": round(full_kelly, 4), "kelly_full_fraction": round(full_kelly, 4),
"kelly_fractional_fraction": round(fractional_kelly, 4), "kelly_fractional_fraction": round(fractional_kelly, 4),
"kelly_effective_fraction": round(effective_fraction, 4), "kelly_effective_fraction": round(effective_fraction, 4),
"kelly_bankroll_usdt": round(bankroll, 2), "kelly_bankroll_usdt": round(bankroll, 2),
"kelly_notional_usdt": round(kelly_notional, 2), "kelly_target_notional_usdt": round(target_notional, 2),
"kelly_effective_target_notional_usdt": round(effective_target_notional, 2),
"kelly_open_symbol_exposure_usdt": round(open_symbol_exposure, 2),
"kelly_raw_remaining_notional_usdt": round(raw_remaining_notional, 2),
"kelly_remaining_notional_usdt": round(remaining_notional, 2),
"kelly_notional_usdt": round(remaining_notional, 2),
"kelly_exchange_min_entry_usdt": round(exchange_min_entry, 2),
"kelly_layer_mode": layer_mode,
} }
def _account_symbol_exposure(account: dict | None, symbol: str | None = None) -> float:
if not isinstance(account, dict):
return 0.0
direct = _safe_float(account.get("symbol_exposure_usdt"), -1.0)
if direct >= 0:
return max(0.0, direct)
if not symbol:
symbol = str(account.get("symbol", "") or "")
exposures = account.get("symbol_exposures")
if isinstance(exposures, dict) and symbol:
return max(0.0, _safe_float(exposures.get(symbol), 0.0))
return 0.0
def _account_total_exposure(account: dict | None) -> float:
if not isinstance(account, dict):
return 0.0
return max(0.0, _safe_float(account.get("exposure"), 0.0))
def _account_open_positions_for_symbol(account: dict | None) -> int:
if not isinstance(account, dict):
return 0
try:
return max(0, int(account.get("open_positions_for_symbol", 0)))
except (TypeError, ValueError):
return 0
def _account_exchange_min_entry(account: dict | None, settings: Settings) -> float:
minimum = max(0.0, settings.min_position_usdt)
if not isinstance(account, dict):
return minimum
return max(minimum, _safe_float(account.get("exchange_min_entry_usdt"), minimum))
def _confidence_probability(final_score: float, min_signal_confidence: float) -> float: def _confidence_probability(final_score: float, min_signal_confidence: float) -> float:
denominator = max(0.0001, 1.0 - min_signal_confidence) denominator = max(0.0001, 1.0 - min_signal_confidence)
ratio = _clamp((final_score - min_signal_confidence) / denominator, 0.0, 1.0) ratio = _clamp((final_score - min_signal_confidence) / denominator, 0.0, 1.0)
@@ -1257,6 +1616,27 @@ def _adaptive_percent(adaptive: dict, key: str, default: float, low: float, high
return _clamp(_safe_float(adaptive.get(key), default), low, high) return _clamp(_safe_float(adaptive.get(key), default), low, high)
def _effective_stop_loss(settings: Settings, position: Position, stop_loss_percent: float) -> float | None:
if not settings.stop_loss_exit_enabled:
return None
return max(position.stop_loss, position.entry_price * (1 - stop_loss_percent))
def _atr_trailing_stop(
settings: Settings,
position: Position,
atr: float | None,
atr_multiplier: float,
effective_stop_loss: float | None,
) -> float | None:
if atr is None or atr <= 0 or position.highest_price <= position.entry_price:
return None
raw_stop = position.highest_price - atr * atr_multiplier
if settings.stop_loss_exit_enabled and effective_stop_loss is not None:
return max(effective_stop_loss, raw_stop)
return raw_stop if raw_stop > position.entry_price else None
def _estimated_exit_net_percent(position: Position, price: float, settings: Settings) -> float: def _estimated_exit_net_percent(position: Position, price: float, settings: Settings) -> float:
if position.entry_price <= 0: if position.entry_price <= 0:
return 0.0 return 0.0
@@ -1265,6 +1645,10 @@ def _estimated_exit_net_percent(position: Position, price: float, settings: Sett
return gross_percent - round_trip_cost_percent return gross_percent - round_trip_cost_percent
def _min_exit_net_percent(settings: Settings) -> float:
return round(_clamp(settings.min_exit_net_percent, 0.0, 5.0), 4)
def _adaptive_indicator_exit_allowed(adaptive: dict, mode_key: str, estimated_exit_net_percent: float) -> bool: def _adaptive_indicator_exit_allowed(adaptive: dict, mode_key: str, estimated_exit_net_percent: float) -> bool:
mode = str(adaptive.get(mode_key, "normal")).lower() mode = str(adaptive.get(mode_key, "normal")).lower()
if mode != "profit_only": if mode != "profit_only":
@@ -1281,6 +1665,7 @@ def _forecast_exit_signal(
estimated_exit_net_percent: float, estimated_exit_net_percent: float,
stop_loss_percent: float, stop_loss_percent: float,
min_edge_percent: float, min_edge_percent: float,
min_exit_net_percent: float,
) -> tuple[str, float, str] | None: ) -> tuple[str, float, str] | None:
if not forecast.get("usable"): if not forecast.get("usable"):
return None return None
@@ -1292,7 +1677,7 @@ def _forecast_exit_signal(
if not strong_negative: if not strong_negative:
return None return None
reason = forecast.get("reason") or "ожидается снижение" reason = forecast.get("reason") or "ожидается снижение"
if estimated_exit_net_percent >= 0: if estimated_exit_net_percent >= min_exit_net_percent:
return "SELL", 0.82, f"прогноз временного ряда ухудшился: {reason}; фиксируем результат" return "SELL", 0.82, f"прогноз временного ряда ухудшился: {reason}; фиксируем результат"
loss_from_entry = ((price - position.entry_price) / position.entry_price) if position.entry_price else 0.0 loss_from_entry = ((price - position.entry_price) / position.entry_price) if position.entry_price else 0.0
soft_loss_limit = -max(0.003, stop_loss_percent * 0.35) soft_loss_limit = -max(0.003, stop_loss_percent * 0.35)
+260
View File
@@ -69,6 +69,65 @@ DEFAULT_TORCH_FEATURES = (
) )
FEATURE_DESCRIPTIONS: dict[str, tuple[str, str, str]] = {
"return_1": ("Цена", "Доходность 1ч", "Изменение цены закрытия за последнюю 1h свечу."),
"return_3": ("Цена", "Доходность 3ч", "Изменение цены закрытия за последние 3 часовые свечи."),
"return_6": ("Цена", "Доходность 6ч", "Изменение цены закрытия за последние 6 часовых свечей."),
"return_12": ("Цена", "Доходность 12ч", "Изменение цены закрытия за последние 12 часовых свечей."),
"return_24": ("Цена", "Доходность 24ч", "Изменение цены закрытия за последние 24 часовые свечи."),
"range_percent": ("Свеча", "Диапазон свечи", "Размер high-low последней свечи относительно цены закрытия."),
"body_percent": ("Свеча", "Тело свечи", "Разница close-open относительно цены закрытия; знак показывает цвет свечи."),
"upper_wick_percent": ("Свеча", "Верхняя тень", "Насколько далеко цена уходила выше тела свечи."),
"lower_wick_percent": ("Свеча", "Нижняя тень", "Насколько далеко цена уходила ниже тела свечи."),
"volume_change": ("Объем", "Изменение объема", "Изменение объема последней свечи относительно предыдущей."),
"volume_ratio": ("Объем", "Объем к MA20", "Отклонение текущего объема от средней за 20 свечей."),
"volume_percentile_20": ("Объем", "Процентиль объема", "Позиция текущего объема среди последних 20 свечей."),
"atr_percent": ("Волатильность", "ATR14 %", "Средний торговый диапазон ATR14 относительно цены."),
"atr_ratio_20": ("Волатильность", "ATR к среднему", "Отклонение текущего ATR от среднего ATR за 20 свечей."),
"realized_volatility_12": ("Волатильность", "Реализованная вола 12ч", "Фактическая волатильность доходностей за 12 свечей."),
"realized_volatility_24": ("Волатильность", "Реализованная вола 24ч", "Фактическая волатильность доходностей за 24 свечи."),
"rsi_centered": ("Индикаторы", "RSI14 от 50", "RSI14, приведенный к центру 50: выше нуля сильнее покупатели."),
"rsi_slope_6": ("Индикаторы", "Наклон RSI 6ч", "Изменение RSI14 за последние 6 свечей."),
"macd_hist_percent": ("Индикаторы", "MACD histogram", "MACD histogram относительно цены; знак показывает импульс."),
"macd_hist_slope_3": ("Индикаторы", "Наклон MACD hist", "Изменение MACD histogram за последние 3 свечи."),
"ema50_gap_percent": ("EMA/тренд", "Цена к EMA50", "Расстояние цены закрытия до EMA50."),
"ema200_gap_percent": ("EMA/тренд", "Цена к EMA200", "Расстояние цены закрытия до EMA200."),
"ema20_slope_6": ("EMA/тренд", "Наклон EMA20", "Изменение EMA20 за последние 6 свечей."),
"ema50_slope_12": ("EMA/тренд", "Наклон EMA50", "Изменение EMA50 за последние 12 свечей."),
"ema200_slope_24": ("EMA/тренд", "Наклон EMA200", "Изменение EMA200 за последние 24 свечи."),
"ema50_ema200_gap_percent": ("EMA/тренд", "EMA50 к EMA200", "Расстояние EMA50 относительно EMA200."),
"range_position_50": ("Цена", "Позиция в диапазоне 50ч", "Где текущая цена внутри high-low диапазона последних 50 свечей."),
"trend_return_4h": ("Цена", "Тренд 4ч", "Изменение цены за последние 4 свечи."),
"trend_return_24h": ("Цена", "Тренд 24ч", "Изменение цены за последние 24 свечи."),
"daily_close_ema200_gap_percent": ("Дневной тренд", "D цена к EMA200", "Расстояние дневного close до дневной EMA200."),
"daily_ema50_ema200_gap_percent": ("Дневной тренд", "D EMA50 к EMA200", "Расстояние дневной EMA50 относительно дневной EMA200."),
"daily_ema50_slope": ("Дневной тренд", "D наклон EMA50", "Изменение дневной EMA50 за последние несколько дневных свечей."),
"btc_return_1": ("BTC/ETH контекст", "BTC 1ч", "Изменение BTCUSDT за последнюю 1h свечу."),
"btc_return_3": ("BTC/ETH контекст", "BTC 3ч", "Изменение BTCUSDT за последние 3 часа."),
"btc_return_6": ("BTC/ETH контекст", "BTC 6ч", "Изменение BTCUSDT за последние 6 часов."),
"btc_return_24": ("BTC/ETH контекст", "BTC 24ч", "Изменение BTCUSDT за последние 24 часа."),
"eth_return_1": ("BTC/ETH контекст", "ETH 1ч", "Изменение ETHUSDT за последнюю 1h свечу."),
"eth_return_3": ("BTC/ETH контекст", "ETH 3ч", "Изменение ETHUSDT за последние 3 часа."),
"eth_return_6": ("BTC/ETH контекст", "ETH 6ч", "Изменение ETHUSDT за последние 6 часов."),
"eth_return_24": ("BTC/ETH контекст", "ETH 24ч", "Изменение ETHUSDT за последние 24 часа."),
"relative_btc_return_3": ("BTC/ETH контекст", "Сила к BTC 3ч", "Доходность пары за 3 часа минус доходность BTC за 3 часа."),
"relative_eth_return_3": ("BTC/ETH контекст", "Сила к ETH 3ч", "Доходность пары за 3 часа минус доходность ETH за 3 часа."),
"btc_eth_return_spread_3": ("BTC/ETH контекст", "BTC-ETH 3ч", "Разница 3-часовой доходности BTC и ETH."),
"pattern_score": ("Шаблон", "Оценка шаблона", "Числовая оценка текущего рыночного шаблона от 0 до 1."),
"pattern_bullish": ("Шаблон", "Бычий шаблон", "Флаг, что текущий шаблон похож на бычий."),
"pattern_bearish": ("Шаблон", "Медвежий шаблон", "Флаг, что текущий шаблон похож на медвежий."),
"pattern_range": ("Шаблон", "Флэтовый шаблон", "Флаг, что рынок похож на диапазон/флэт."),
"pattern_pullback": ("Шаблон", "Откат", "Флаг отката внутри восходящего движения."),
"pattern_oversold_reversal": ("Шаблон", "Перепроданный разворот", "Флаг возможного разворота после перепроданности."),
"pattern_stabilized_drop": ("Шаблон", "Падение стабилизировалось", "Флаг замедления падения и попытки стабилизации."),
"pattern_breakout": ("Шаблон", "Пробой вверх", "Флаг пробоя верхней части диапазона с объемом."),
"pattern_breakdown": ("Шаблон", "Пробой вниз", "Флаг пробоя нижней части диапазона с объемом."),
"pattern_fast_drop": ("Шаблон", "Быстрое падение", "Флаг резкого снижения или сильной перепроданности."),
"pattern_volume_spike": ("Шаблон", "Всплеск объема", "Флаг объема заметно выше обычного."),
"pattern_range_position_20": ("Шаблон", "Позиция в диапазоне 20ч", "Где цена внутри high-low диапазона последних 20 свечей."),
}
@dataclass(slots=True) @dataclass(slots=True)
class TimeSeriesForecast: class TimeSeriesForecast:
enabled: bool enabled: bool
@@ -92,8 +151,11 @@ class TimeSeriesForecast:
quantile_90_percent: float quantile_90_percent: float
conservative_return_percent: float conservative_return_percent: float
target_transform: str target_transform: str
feature_snapshot: list[dict[str, Any]] = field(default_factory=list)
horizon_forecasts: dict[str, Any] = field(default_factory=dict) horizon_forecasts: dict[str, Any] = field(default_factory=dict)
candidates: list[dict[str, Any]] = field(default_factory=list) candidates: list[dict[str, Any]] = field(default_factory=list)
quality_gate_passed: bool | None = None
quality_gate: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return asdict(self) return asdict(self)
@@ -104,6 +166,8 @@ class TimeSeriesForecaster:
self.settings = settings self.settings = settings
self._lstm_artifact_mtime: float | None = None self._lstm_artifact_mtime: float | None = None
self._lstm_artifact: dict[str, Any] = {} self._lstm_artifact: dict[str, Any] = {}
self._calibration_mtime: float | None = None
self._quality_gate: dict[str, Any] = {}
def forecast( def forecast(
self, self,
@@ -124,8 +188,11 @@ class TimeSeriesForecaster:
return _empty_forecast(True, "not enough returns for PyTorch forecast") return _empty_forecast(True, "not enough returns for PyTorch forecast")
artifact = self._load_lstm_artifact() artifact = self._load_lstm_artifact()
quality_gate = self._load_quality_gate()
quality_gate_passed = _quality_gate_passed(quality_gate)
entry = _torch_recurrent_entry(symbol, artifact) entry = _torch_recurrent_entry(symbol, artifact)
model = _torch_recurrent_model_name(symbol, artifact) model = _torch_recurrent_model_name(symbol, artifact)
clip = _clamp(_float_entry(entry or {}, "clip", 8.0), 1.0, 50.0)
feature_rows = ( feature_rows = (
_feature_matrix( _feature_matrix(
candles, candles,
@@ -137,6 +204,7 @@ class TimeSeriesForecaster:
if entry if entry
else [] else []
) )
feature_snapshot = _feature_snapshot(feature_rows, entry, clip)
if not model or not _can_use_torch_recurrent(returns, symbol, artifact, feature_rows): if not model or not _can_use_torch_recurrent(returns, symbol, artifact, feature_rows):
return _empty_forecast(True, "no valid PyTorch LSTM/GRU model for symbol") return _empty_forecast(True, "no valid PyTorch LSTM/GRU model for symbol")
@@ -215,8 +283,11 @@ class TimeSeriesForecaster:
quantile_90_percent=round(q90_percent, 4), quantile_90_percent=round(q90_percent, 4),
conservative_return_percent=round(conservative_return_percent, 4), conservative_return_percent=round(conservative_return_percent, 4),
target_transform=str(entry.get("target_transform", "net_return_over_volatility")), target_transform=str(entry.get("target_transform", "net_return_over_volatility")),
feature_snapshot=feature_snapshot,
horizon_forecasts=_public_horizon_forecasts(prediction), horizon_forecasts=_public_horizon_forecasts(prediction),
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}], candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
quality_gate_passed=quality_gate_passed,
quality_gate=quality_gate,
) )
direct_horizon = _is_direct_horizon(entry) direct_horizon = _is_direct_horizon(entry)
@@ -274,8 +345,11 @@ class TimeSeriesForecaster:
quantile_90_percent=round(expected_return_percent + volatility_percent, 4), quantile_90_percent=round(expected_return_percent + volatility_percent, 4),
conservative_return_percent=round(expected_return_percent, 4), conservative_return_percent=round(expected_return_percent, 4),
target_transform=str(entry.get("target_transform", "direct_log_return")), target_transform=str(entry.get("target_transform", "direct_log_return")),
feature_snapshot=feature_snapshot,
horizon_forecasts={}, horizon_forecasts={},
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}], candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
quality_gate_passed=quality_gate_passed,
quality_gate=quality_gate,
) )
def _load_lstm_artifact(self) -> dict[str, Any]: def _load_lstm_artifact(self) -> dict[str, Any]:
@@ -298,6 +372,25 @@ class TimeSeriesForecaster:
self._lstm_artifact_mtime = stat.st_mtime self._lstm_artifact_mtime = stat.st_mtime
return self._lstm_artifact return self._lstm_artifact
def _load_quality_gate(self) -> dict[str, Any]:
path = self.settings.time_series_lstm_model_path.parent / "torch_threshold_calibration.json"
try:
stat = path.stat()
except OSError:
self._calibration_mtime = None
self._quality_gate = {}
return {}
if self._calibration_mtime == stat.st_mtime:
return self._quality_gate
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
validation = data.get("validation") if isinstance(data, dict) else {}
self._quality_gate = validation if isinstance(validation, dict) else {}
self._calibration_mtime = stat.st_mtime
return self._quality_gate
def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast: def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
return TimeSeriesForecast( return TimeSeriesForecast(
@@ -322,10 +415,27 @@ def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
quantile_90_percent=0.0, quantile_90_percent=0.0,
conservative_return_percent=0.0, conservative_return_percent=0.0,
target_transform="none", target_transform="none",
feature_snapshot=[],
horizon_forecasts={}, horizon_forecasts={},
candidates=[],
quality_gate_passed=None,
quality_gate={},
) )
def _quality_gate_passed(quality_gate: dict[str, Any]) -> bool | None:
if not quality_gate:
return None
if "passed" in quality_gate:
return bool(quality_gate.get("passed"))
status = str(quality_gate.get("status", "")).strip().lower()
if status in {"pass", "passed", "ok"}:
return True
if status in {"fail", "failed", "warn"}:
return False
return None
def _log_returns(closes: list[float]) -> list[float]: def _log_returns(closes: list[float]) -> list[float]:
return [math.log(closes[index] / closes[index - 1]) for index in range(1, len(closes))] return [math.log(closes[index] / closes[index - 1]) for index in range(1, len(closes))]
@@ -1033,6 +1143,156 @@ def _normalize_feature_rows(rows: list[list[float]], entry: dict[str, Any], clip
return normalized return normalized
def _feature_snapshot(
feature_rows: list[list[float]],
entry: dict[str, Any] | None,
clip: float,
) -> list[dict[str, Any]]:
if not entry or not feature_rows:
return []
names = _feature_names(entry)
latest = feature_rows[-1]
normalized_rows = _normalize_feature_rows([latest], entry, clip)
normalized = normalized_rows[-1] if normalized_rows else []
means = _float_vector(entry.get("feature_means"))
scales = _float_vector(entry.get("feature_scales"))
snapshot: list[dict[str, Any]] = []
for index, name in enumerate(names):
raw_value = float(latest[index]) if index < len(latest) else 0.0
model_value = float(normalized[index]) if index < len(normalized) else 0.0
group, label, meaning = FEATURE_DESCRIPTIONS.get(
name,
("Прочее", name, "Технический входной признак модели."),
)
snapshot.append(
{
"name": name,
"label": label,
"group": group,
"raw_value": round(raw_value, 10),
"raw_display": _feature_raw_display(name, raw_value),
"model_value": round(model_value, 4),
"model_display": f"{model_value:+.2f}",
"mean": round(float(means[index]), 10) if index < len(means) else 0.0,
"scale": round(float(scales[index]), 10) if index < len(scales) else 1.0,
"meaning": meaning,
"interpretation": _feature_interpretation(name, raw_value, model_value),
}
)
return snapshot
def _feature_raw_display(name: str, value: float) -> str:
if _feature_is_log_percent(name):
return f"{(math.exp(value) - 1) * 100:+.3f}%"
if _feature_is_linear_percent(name):
return f"{value * 100:+.3f}%"
if name in {"rsi_centered"}:
return f"RSI {value * 50 + 50:.1f}"
if name in {"rsi_slope_6"}:
return f"{value * 50:+.2f} RSI"
if name in {"volume_percentile_20", "range_position_50", "pattern_range_position_20"}:
return f"{value * 100:.1f}%"
if name.startswith("pattern_") and name != "pattern_score":
return "да" if value >= 0.5 else "нет"
if name == "pattern_score":
return f"{value:.2f}"
return f"{value:+.4f}"
def _feature_interpretation(name: str, value: float, model_value: float) -> str:
norm = _model_value_text(model_value)
if name.startswith("pattern_") and name != "pattern_score" and name != "pattern_range_position_20":
state = "шаблон активен" if value >= 0.5 else "шаблон не активен"
return f"{state}; {norm}."
if name in {"volume_percentile_20", "range_position_50", "pattern_range_position_20"}:
if value >= 0.8:
state = "значение находится в верхней части диапазона"
elif value <= 0.2:
state = "значение находится в нижней части диапазона"
else:
state = "значение около середины диапазона"
return f"{state}; {norm}."
if name in {"volume_ratio", "atr_ratio_20"}:
state = "выше среднего" if value > 0 else "ниже среднего" if value < 0 else "около среднего"
return f"{state}; {norm}."
if name == "rsi_centered":
rsi = value * 50 + 50
if rsi >= 65:
state = "RSI высокий"
elif rsi <= 35:
state = "RSI низкий"
else:
state = "RSI в средней зоне"
return f"{state}; {norm}."
if _feature_is_log_percent(name) or _feature_is_linear_percent(name) or name.endswith("_slope"):
if value > 0:
state = "положительное значение"
elif value < 0:
state = "отрицательное значение"
else:
state = "нейтральное значение"
return f"{state}; {norm}."
if name == "pattern_score":
if value >= 0.65:
state = "шаблон скорее поддерживает long"
elif value <= 0.35:
state = "шаблон скорее против long"
else:
state = "шаблон нейтральный"
return f"{state}; {norm}."
return norm + "."
def _model_value_text(value: float) -> str:
magnitude = abs(value)
if magnitude >= 2.0:
return "для модели это сильное отклонение от обучающей нормы"
if magnitude >= 1.0:
return "для модели это заметное отклонение от обучающей нормы"
return "для модели это близко к обычному диапазону"
def _feature_is_log_percent(name: str) -> bool:
return (
name.startswith("return_")
or name.startswith("btc_return_")
or name.startswith("eth_return_")
or name.startswith("relative_")
or name in {
"volume_change",
"trend_return_4h",
"trend_return_24h",
"ema20_slope_6",
"ema50_slope_12",
"ema200_slope_24",
"daily_ema50_slope",
"btc_eth_return_spread_3",
}
)
def _feature_is_linear_percent(name: str) -> bool:
return name in {
"range_percent",
"body_percent",
"upper_wick_percent",
"lower_wick_percent",
"volume_ratio",
"atr_percent",
"atr_ratio_20",
"realized_volatility_12",
"realized_volatility_24",
"macd_hist_percent",
"macd_hist_slope_3",
"ema50_gap_percent",
"ema200_gap_percent",
"ema50_ema200_gap_percent",
"daily_close_ema200_gap_percent",
"daily_ema50_ema200_gap_percent",
}
def _torch_recurrent_hidden( def _torch_recurrent_hidden(
sequence: list[list[float]], sequence: list[list[float]],
*, *,
+309
View File
@@ -0,0 +1,309 @@
from __future__ import annotations
import base64
import hashlib
import json
import os
import uuid
from datetime import UTC
from datetime import datetime
from datetime import timedelta
from pathlib import Path
from threading import Lock
from typing import Any
ALLOWED_TRAINING_ARTIFACTS = {
"lstm_forecaster.json",
"torch_retrain_guard.json",
"torch_threshold_calibration.json",
}
RUNNING_TIMEOUT = timedelta(hours=12)
ONLINE_WINDOW = timedelta(minutes=3)
class TrainingCoordinator:
def __init__(self, runtime_dir: Path) -> None:
self.runtime_dir = runtime_dir
self.state_path = runtime_dir / "training_coordination.json"
self.upload_root = runtime_dir / ".training_uploads"
self._lock = Lock()
def status(self) -> dict[str, Any]:
with self._lock:
state = self._load_state()
self._expire_stale_jobs(state)
self._save_state(state)
return self._public_status(state)
def request_retrain(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
self._expire_stale_jobs(state)
existing = self._active_job(state)
if existing is not None:
self._save_state(state)
return {"queued": False, "reason": "active_job_exists", "job": existing, "status": self._public_status(state)}
now = _now()
job = {
"id": str(uuid.uuid4()),
"status": "pending",
"requested_at": now,
"requested_by": str(payload.get("source") or "api"),
"parameters": _safe_parameters(payload.get("parameters")),
"message": "",
"artifacts": [],
}
state.setdefault("jobs", []).append(job)
self._trim_jobs(state)
self._save_state(state)
return {"queued": True, "job": job, "status": self._public_status(state)}
def heartbeat(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
worker = self._worker_from_payload(payload)
state["worker"] = worker
self._save_state(state)
return {"ok": True, "worker": worker, "status": self._public_status(state)}
def claim(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
self._expire_stale_jobs(state)
worker = self._worker_from_payload(payload)
state["worker"] = worker
job = self._oldest_pending_job(state)
if job is None:
self._save_state(state)
return {"claimed": False, "job": None, "status": self._public_status(state)}
now = _now()
job["status"] = "running"
job["claimed_at"] = now
job["claimed_by"] = worker["id"]
job["worker"] = worker
self._save_state(state)
return {"claimed": True, "job": job, "status": self._public_status(state)}
def save_artifact_chunk(self, job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
name = Path(str(payload.get("name") or "")).name
if name not in ALLOWED_TRAINING_ARTIFACTS:
raise ValueError(f"artifact is not allowed: {name}")
index = int(payload.get("index", -1))
total = int(payload.get("total", 0))
sha256 = str(payload.get("sha256") or "").strip().lower()
if index < 0 or total <= 0 or index >= total:
raise ValueError("invalid artifact chunk index")
if not sha256:
raise ValueError("artifact sha256 is required")
try:
chunk = base64.b64decode(str(payload.get("data_base64") or ""), validate=True)
except (ValueError, TypeError) as exc:
raise ValueError("invalid artifact chunk payload") from exc
chunk_dir = self.upload_root / job_id / name
chunk_dir.mkdir(parents=True, exist_ok=True)
(chunk_dir / f"{index:06d}.part").write_bytes(chunk)
if not all((chunk_dir / f"{part:06d}.part").is_file() for part in range(total)):
return {"complete": False, "received": index + 1, "total": total}
target_tmp = self.runtime_dir / f".{name}.{job_id}.tmp"
digest = hashlib.sha256()
with target_tmp.open("wb") as output:
for part in range(total):
data = (chunk_dir / f"{part:06d}.part").read_bytes()
digest.update(data)
output.write(data)
if digest.hexdigest().lower() != sha256:
target_tmp.unlink(missing_ok=True)
raise ValueError("artifact sha256 mismatch")
self.runtime_dir.mkdir(parents=True, exist_ok=True)
os.replace(target_tmp, self.runtime_dir / name)
_remove_tree(chunk_dir)
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is not None:
artifacts = job.setdefault("artifacts", [])
artifacts = [item for item in artifacts if item.get("name") != name]
artifacts.append({"name": name, "sha256": sha256, "uploaded_at": _now()})
job["artifacts"] = artifacts
self._save_state(state)
return {"complete": True, "name": name, "sha256": sha256}
def progress(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is None:
raise ValueError(f"training job not found: {job_id}")
if isinstance(payload.get("worker"), dict):
state["worker"] = self._worker_from_payload(payload["worker"])
job["status"] = str(payload.get("status") or job.get("status") or "running")
job["phase"] = str(payload.get("phase") or job.get("phase") or "running")
job["message"] = str(payload.get("message") or job.get("message") or "")
job["progress_percent"] = _coerce_percent(payload.get("progress_percent"), job.get("progress_percent", 0))
job["updated_at"] = _now()
if isinstance(payload.get("details"), dict):
job["details"] = payload["details"]
self._save_state(state)
return {"ok": True, "job": job, "status": self._public_status(state)}
def complete(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is None:
raise ValueError(f"training job not found: {job_id}")
success = bool(payload.get("success", payload.get("status") == "completed"))
job["status"] = "completed" if success else "failed"
job["phase"] = "completed" if success else "failed"
job["progress_percent"] = 100 if success else _coerce_percent(payload.get("progress_percent"), job.get("progress_percent", 0))
job["completed_at"] = _now()
job["message"] = str(payload.get("message") or "")
if isinstance(payload.get("summary"), dict):
job["summary"] = payload["summary"]
self._save_state(state)
return {"ok": True, "job": job, "status": self._public_status(state)}
def _load_state(self) -> dict[str, Any]:
try:
data = json.loads(self.state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
if not isinstance(data, dict):
data = {}
data.setdefault("jobs", [])
return data
def _save_state(self, state: dict[str, Any]) -> None:
self.runtime_dir.mkdir(parents=True, exist_ok=True)
tmp = self.state_path.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, self.state_path)
def _worker_from_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(payload.get("worker_id") or payload.get("id") or "windows-training-host"),
"name": str(payload.get("name") or "DESKTOP-TMFDL0H"),
"path": str(payload.get("path") or "C:\\Repos\\TradeBot"),
"version": str(payload.get("version") or "1"),
"last_seen_at": _now(),
}
def _public_status(self, state: dict[str, Any]) -> dict[str, Any]:
worker = state.get("worker") if isinstance(state.get("worker"), dict) else {}
last_seen = _parse_time(str(worker.get("last_seen_at") or ""))
active = self._active_job(state)
latest = _latest_job(state)
recently_seen = bool(last_seen and datetime.now(UTC) - last_seen <= ONLINE_WINDOW)
agent_busy = bool(
active
and active.get("status") == "running"
and worker
and active.get("claimed_by") == worker.get("id")
)
return {
"available": True,
"agent_online": recently_seen or agent_busy,
"agent_recently_seen": recently_seen,
"agent_busy": agent_busy,
"worker": worker,
"active_job": active,
"latest_job": latest,
"pending_jobs": sum(1 for job in state.get("jobs", []) if job.get("status") == "pending"),
}
def _active_job(self, state: dict[str, Any]) -> dict[str, Any] | None:
for job in reversed(state.get("jobs", [])):
if job.get("status") in {"pending", "running"}:
return job
return None
def _oldest_pending_job(self, state: dict[str, Any]) -> dict[str, Any] | None:
for job in state.get("jobs", []):
if job.get("status") == "pending":
return job
return None
def _job_by_id(self, state: dict[str, Any], job_id: str) -> dict[str, Any] | None:
for job in state.get("jobs", []):
if job.get("id") == job_id:
return job
return None
def _expire_stale_jobs(self, state: dict[str, Any]) -> None:
now = datetime.now(UTC)
for job in state.get("jobs", []):
if job.get("status") != "running":
continue
claimed_at = _parse_time(str(job.get("claimed_at") or ""))
if claimed_at and now - claimed_at > RUNNING_TIMEOUT:
job["status"] = "failed"
job["completed_at"] = _now()
job["message"] = "training worker timeout"
def _trim_jobs(self, state: dict[str, Any]) -> None:
jobs = state.get("jobs", [])
if isinstance(jobs, list) and len(jobs) > 30:
state["jobs"] = jobs[-30:]
def _safe_parameters(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
allowed = {"symbols", "limit", "lookbacks", "architectures", "hidden_sizes", "layers", "dropouts", "epochs"}
return {key: value[key] for key in allowed if key in value}
def _latest_job(state: dict[str, Any]) -> dict[str, Any] | None:
jobs = state.get("jobs", [])
if not jobs:
return None
latest = jobs[-1]
return latest if isinstance(latest, dict) else None
def _now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def _parse_time(value: str) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def _coerce_percent(value: Any, default: Any = 0) -> int:
try:
number = int(float(value))
except (TypeError, ValueError):
try:
number = int(float(default))
except (TypeError, ValueError):
number = 0
return max(0, min(number, 100))
def _remove_tree(path: Path) -> None:
if not path.exists():
return
for child in path.iterdir():
if child.is_dir():
_remove_tree(child)
else:
child.unlink(missing_ok=True)
path.rmdir()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
BTCUSDT: loaded 2000 60 candles
ETHUSDT: loaded 2000 60 candles
LTCUSDT: loaded 2000 60 candles
SOLUSDT: loaded 2000 60 candles
BTCUSDT: replay records 720
ETHUSDT: replay records 720
SOLUSDT: replay records 720
LTCUSDT: replay records 720
records_by_symbol {"BTCUSDT": 720, "ETHUSDT": 720, "LTCUSDT": 720, "SOLUSDT": 720}
artifact {"created_at": "2026-06-23T19:07:54.434411+00:00", "feature_count": 55, "symbols": {"BTCUSDT": {"directional_accuracy": 0.725, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.15903346077183758}, "ETHUSDT": {"directional_accuracy": 0.6916666666666667, "hidden_size": 64, "lookback": 64, "model": "torch_gru", "skill": 0.09273757527902074}, "LTCUSDT": {"directional_accuracy": 0.6583333333333333, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.11954702418314447}, "SOLUSDT": {"directional_accuracy": 0.6416666666666667, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.03400498728351002}}, "target_horizon": 3, "target_horizons": [1, 3, 6, 12], "target_transform": "net_return_over_volatility", "version": 4}
TOP_RESULTS
edge=0.1000 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.1000 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.1000 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.1000 prob=0.6200 conf=0.6000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.1000 prob=0.6200 conf=0.5600 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.1000 prob=0.6200 conf=0.5000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.6000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.5600 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.5000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0600 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0600 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0600 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
RECOMMENDED
edge=0.1000 prob=0.6000 conf=0.6800 trades=16 win=0.562 avg=0.4948% total=7.9171% dd=1.8130% pf=3.629 score=0.5817
FULL_REPLAY
trades=5 win=1.000 avg=1.9149% total=9.5746% dd=0.0000% pf=999.000
WALK_FORWARD
{"avg_net_percent": 0.4783, "max_drawdown_percent": 1.3024, "profit_factor": 3.471, "status": "ok", "total_net_percent": 7.1747, "trades": 15, "win_rate": 0.5333, "wins": 8}
env TIME_SERIES_MIN_EDGE_PERCENT=0.1000 TIME_SERIES_MIN_PROBABILITY_UP=0.6000 TIME_SERIES_MIN_CONFIDENCE=0.6800
File diff suppressed because it is too large Load Diff
+515
View File
@@ -0,0 +1,515 @@
{
"artifact": {
"version": 4,
"created_at": "2026-06-23T19:07:54.434411+00:00",
"feature_count": 55,
"target_horizon": 3,
"target_horizons": [
1,
3,
6,
12
],
"target_transform": "net_return_over_volatility",
"symbols": {
"BTCUSDT": {
"model": "torch_gru",
"lookback": 64,
"hidden_size": 96,
"skill": 0.15903346077183758,
"directional_accuracy": 0.725
},
"ETHUSDT": {
"model": "torch_gru",
"lookback": 64,
"hidden_size": 64,
"skill": 0.09273757527902074,
"directional_accuracy": 0.6916666666666667
},
"SOLUSDT": {
"model": "torch_gru",
"lookback": 64,
"hidden_size": 96,
"skill": 0.03400498728351002,
"directional_accuracy": 0.6416666666666667
},
"LTCUSDT": {
"model": "torch_gru",
"lookback": 64,
"hidden_size": 96,
"skill": 0.11954702418314447,
"directional_accuracy": 0.6583333333333333
}
}
},
"records_by_symbol": {
"BTCUSDT": 720,
"ETHUSDT": 720,
"SOLUSDT": 720,
"LTCUSDT": 720
},
"recommended": {
"edge": 0.1,
"probability": 0.52,
"confidence": 0.72,
"trades": 30,
"wins": 17,
"win_rate": 0.5666666666666667,
"total_net_percent": 12.82679871911413,
"average_net_percent": 0.42755995730380436,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 3.2963631842987433,
"score": 0.5882388552951857
},
"full_replay": {
"trades": 8,
"wins": 8,
"win_rate": 1.0,
"total_net_percent": 21.0484,
"avg_net_percent": 2.631,
"max_drawdown_percent": 0.0,
"profit_factor": 999.0,
"trades_detail": [
{
"symbol": "ETHUSDT",
"entry_timestamp": 1779832800000,
"exit_timestamp": 1779868800000,
"net_percent": 0.5281,
"reason": "forecast_weak_profit_lock",
"held_bars": 10,
"entry_probability": 0.5747,
"entry_expected_percent": 0.3453
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1779940800000,
"exit_timestamp": 1779984000000,
"net_percent": 1.3185,
"reason": "forecast_weak_profit_lock",
"held_bars": 12,
"entry_probability": 0.6018,
"entry_expected_percent": 0.3155
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1780300800000,
"exit_timestamp": 1780347600000,
"net_percent": 0.2591,
"reason": "forecast_weak_profit_lock",
"held_bars": 13,
"entry_probability": 0.6164,
"entry_expected_percent": 0.3215
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1780768800000,
"exit_timestamp": 1780855200000,
"net_percent": 4.4581,
"reason": "max_hold",
"held_bars": 24,
"entry_probability": 0.5632,
"entry_expected_percent": 0.5685
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1780862400000,
"exit_timestamp": 1780869600000,
"net_percent": 2.4609,
"reason": "forecast_weak_profit_lock",
"held_bars": 2,
"entry_probability": 0.5368,
"entry_expected_percent": 0.4055
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1781139600000,
"exit_timestamp": 1781204400000,
"net_percent": 2.0707,
"reason": "forecast_weak_profit_lock",
"held_bars": 18,
"entry_probability": 0.5775,
"entry_expected_percent": 0.3013
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1781445600000,
"exit_timestamp": 1781532000000,
"net_percent": 9.0305,
"reason": "max_hold",
"held_bars": 24,
"entry_probability": 0.6014,
"entry_expected_percent": 0.2946
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1781892000000,
"exit_timestamp": 1781942400000,
"net_percent": 0.9224,
"reason": "forecast_weak_profit_lock",
"held_bars": 14,
"entry_probability": 0.5966,
"entry_expected_percent": 0.2647
}
]
},
"walk_forward": {
"summary": {
"trades": 16,
"wins": 8,
"win_rate": 0.5,
"total_net_percent": 6.8682,
"avg_net_percent": 0.4293,
"max_drawdown_percent": 1.3024,
"profit_factor": 3.1396,
"status": "warn"
},
"folds": [
{
"fold": 1,
"train_records": 720,
"test_records": 720,
"thresholds": {
"edge": 0.1,
"probability": 0.6,
"confidence": 0.72,
"trades": 2,
"wins": 1,
"win_rate": 0.5,
"total_net_percent": -0.10348384852443271,
"average_net_percent": -0.051741924262216354,
"max_drawdown_percent": 0.5106090484004788,
"profit_factor": 0.7973325211360753,
"score": -0.0037694524148430344
},
"test": {
"trades": 4,
"wins": 2,
"win_rate": 0.5,
"total_net_percent": 0.0557,
"avg_net_percent": 0.0139,
"max_drawdown_percent": 1.3024,
"profit_factor": 1.0428
}
},
{
"fold": 2,
"train_records": 1440,
"test_records": 720,
"thresholds": {
"edge": 0.1,
"probability": 0.52,
"confidence": 0.72,
"trades": 18,
"wins": 11,
"win_rate": 0.6111111111111112,
"total_net_percent": 6.01434645293809,
"average_net_percent": 0.3341303584965606,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 2.6352078385564366,
"score": 0.3944002502730791
},
"test": {
"trades": 11,
"wins": 6,
"win_rate": 0.5455,
"total_net_percent": 7.119,
"avg_net_percent": 0.6472,
"max_drawdown_percent": 1.0894,
"profit_factor": 5.4462
}
},
{
"fold": 3,
"train_records": 2160,
"test_records": 720,
"thresholds": {
"edge": 0.1,
"probability": 0.52,
"confidence": 0.72,
"trades": 29,
"wins": 17,
"win_rate": 0.5862068965517241,
"total_net_percent": 13.13332018590817,
"average_net_percent": 0.4528731098589024,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 3.48775770371021,
"score": 0.6189314390475966
},
"test": {
"trades": 1,
"wins": 0,
"win_rate": 0.0,
"total_net_percent": -0.3065,
"avg_net_percent": -0.3065,
"max_drawdown_percent": 0.3065,
"profit_factor": 0.0
}
}
]
},
"probability_calibration": {
"samples": 2880,
"buckets": [
{
"bucket": "0.30-0.35",
"samples": 38,
"avg_probability": 0.336,
"actual_win_rate": 0.1053,
"avg_future_net_percent": -0.6575
},
{
"bucket": "0.35-0.40",
"samples": 418,
"avg_probability": 0.3839,
"actual_win_rate": 0.2225,
"avg_future_net_percent": -0.6359
},
{
"bucket": "0.40-0.45",
"samples": 1065,
"avg_probability": 0.427,
"actual_win_rate": 0.2873,
"avg_future_net_percent": -0.4036
},
{
"bucket": "0.45-0.50",
"samples": 911,
"avg_probability": 0.4746,
"actual_win_rate": 0.3271,
"avg_future_net_percent": -0.3061
},
{
"bucket": "0.50-0.55",
"samples": 290,
"avg_probability": 0.5188,
"actual_win_rate": 0.3966,
"avg_future_net_percent": -0.0583
},
{
"bucket": "0.55-0.60",
"samples": 104,
"avg_probability": 0.5758,
"actual_win_rate": 0.4327,
"avg_future_net_percent": 0.0173
},
{
"bucket": "0.60-0.65",
"samples": 42,
"avg_probability": 0.6138,
"actual_win_rate": 0.4762,
"avg_future_net_percent": -0.0041
},
{
"bucket": "0.65-0.70",
"samples": 6,
"avg_probability": 0.6679,
"actual_win_rate": 0.3333,
"avg_future_net_percent": 0.6587
},
{
"bucket": "0.70-0.75",
"samples": 6,
"avg_probability": 0.7103,
"actual_win_rate": 0.8333,
"avg_future_net_percent": 2.1268
}
]
},
"top_results": [
{
"edge": 0.1,
"probability": 0.52,
"confidence": 0.72,
"trades": 30,
"wins": 17,
"win_rate": 0.5666666666666667,
"total_net_percent": 12.82679871911413,
"average_net_percent": 0.42755995730380436,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 3.2963631842987433,
"score": 0.5882388552951857
},
{
"edge": 0.1,
"probability": 0.5,
"confidence": 0.72,
"trades": 30,
"wins": 17,
"win_rate": 0.5666666666666667,
"total_net_percent": 12.82679871911413,
"average_net_percent": 0.42755995730380436,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 3.2963631842987433,
"score": 0.5882388552951857
},
{
"edge": 0.1,
"probability": 0.52,
"confidence": 0.68,
"trades": 38,
"wins": 19,
"win_rate": 0.5,
"total_net_percent": 13.314209250896504,
"average_net_percent": 0.35037392765517117,
"max_drawdown_percent": 2.2311766078638495,
"profit_factor": 2.618335500149353,
"score": 0.5031517681827032
},
{
"edge": 0.1,
"probability": 0.5,
"confidence": 0.68,
"trades": 38,
"wins": 19,
"win_rate": 0.5,
"total_net_percent": 13.314209250896504,
"average_net_percent": 0.35037392765517117,
"max_drawdown_percent": 2.2311766078638495,
"profit_factor": 2.618335500149353,
"score": 0.5031517681827032
},
{
"edge": 0.08,
"probability": 0.52,
"confidence": 0.64,
"trades": 56,
"wins": 28,
"win_rate": 0.5,
"total_net_percent": 17.433321755380405,
"average_net_percent": 0.31130931706036435,
"max_drawdown_percent": 3.6509791332801522,
"profit_factor": 2.1428339375966368,
"score": 0.4832797693926659
},
{
"edge": 0.06,
"probability": 0.52,
"confidence": 0.68,
"trades": 56,
"wins": 28,
"win_rate": 0.5,
"total_net_percent": 17.433321755380405,
"average_net_percent": 0.31130931706036435,
"max_drawdown_percent": 3.6509791332801522,
"profit_factor": 2.1428339375966368,
"score": 0.4832797693926659
},
{
"edge": 0.05,
"probability": 0.52,
"confidence": 0.68,
"trades": 60,
"wins": 29,
"win_rate": 0.48333333333333334,
"total_net_percent": 16.9130381260749,
"average_net_percent": 0.281883968767915,
"max_drawdown_percent": 3.7288680658046136,
"profit_factor": 1.9655257883521706,
"score": 0.44304683201823336
},
{
"edge": 0.08,
"probability": 0.52,
"confidence": 0.72,
"trades": 38,
"wins": 16,
"win_rate": 0.42105263157894735,
"total_net_percent": 12.356704347142244,
"average_net_percent": 0.3251764301879538,
"max_drawdown_percent": 2.826650409116027,
"profit_factor": 2.4329654894966497,
"score": 0.44256958838476457
},
{
"edge": 0.08,
"probability": 0.5,
"confidence": 0.72,
"trades": 38,
"wins": 16,
"win_rate": 0.42105263157894735,
"total_net_percent": 12.356704347142244,
"average_net_percent": 0.3251764301879538,
"max_drawdown_percent": 2.826650409116027,
"profit_factor": 2.4329654894966497,
"score": 0.44256958838476457
},
{
"edge": 0.1,
"probability": 0.52,
"confidence": 0.6,
"trades": 59,
"wins": 28,
"win_rate": 0.4745762711864407,
"total_net_percent": 16.704033568694644,
"average_net_percent": 0.2831192130287228,
"max_drawdown_percent": 3.9030543543860263,
"profit_factor": 2.053895519143829,
"score": 0.4355711367750193
},
{
"edge": 0.1,
"probability": 0.54,
"confidence": 0.72,
"trades": 29,
"wins": 16,
"win_rate": 0.5517241379310345,
"total_net_percent": 9.266858120167019,
"average_net_percent": 0.3195468317298972,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 2.659032178431275,
"score": 0.41557735852998334
},
{
"edge": 0.1,
"probability": 0.55,
"confidence": 0.72,
"trades": 25,
"wins": 14,
"win_rate": 0.56,
"total_net_percent": 9.16979950649479,
"average_net_percent": 0.3667919802597916,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 3.155095090386423,
"score": 0.41121722668525085
},
{
"edge": 0.08,
"probability": 0.5,
"confidence": 0.64,
"trades": 57,
"wins": 28,
"win_rate": 0.49122807017543857,
"total_net_percent": 15.383105036720245,
"average_net_percent": 0.2698790357319341,
"max_drawdown_percent": 3.6509791332801522,
"profit_factor": 1.8889561886320847,
"score": 0.4107453600913507
},
{
"edge": 0.06,
"probability": 0.5,
"confidence": 0.68,
"trades": 57,
"wins": 28,
"win_rate": 0.49122807017543857,
"total_net_percent": 15.383105036720245,
"average_net_percent": 0.2698790357319341,
"max_drawdown_percent": 3.6509791332801522,
"profit_factor": 1.8889561886320847,
"score": 0.4107453600913507
},
{
"edge": 0.1,
"probability": 0.55,
"confidence": 0.68,
"trades": 32,
"wins": 16,
"win_rate": 0.5,
"total_net_percent": 9.83610967545454,
"average_net_percent": 0.3073784273579544,
"max_drawdown_percent": 2.2311766078638495,
"profit_factor": 2.5019571623752666,
"score": 0.40798477425385704
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
BTCUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.47001% baseline=0.55889% skill=0.1590 dir=0.725 p_brier=0.2443
ETHUSDT: model=torch_gru lookback=64 features=55 hidden=64 layers=2 horizons=1,3,6,12 mae=0.63328% baseline=0.69801% skill=0.0927 dir=0.692 p_brier=0.2239
SOLUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.85491% baseline=0.88500% skill=0.0340 dir=0.642 p_brier=0.2308
LTCUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.57185% baseline=0.64949% skill=0.1195 dir=0.658 p_brier=0.2369
saved G:\Repos\TradeBot\runtime\lstm_forecaster.candidate.json
View File
Binary file not shown.
+16 -1
View File
@@ -66,20 +66,35 @@ def make_settings():
kelly_fraction=0.25, kelly_fraction=0.25,
kelly_max_fraction=0.20, kelly_max_fraction=0.20,
risk_per_trade_percent=0.01, risk_per_trade_percent=0.01,
risk_guard_enabled=True,
risk_symbol_guard_enabled=True,
risk_recent_trade_window=20,
risk_max_consecutive_losses=4,
risk_min_recent_profit_factor=0.85,
risk_reduce_multiplier=0.50,
atr_trailing_multiplier=2.2, atr_trailing_multiplier=2.2,
trend_rsi_min=45.0, trend_rsi_min=45.0,
trend_rsi_max=65.0, trend_rsi_max=65.0,
time_series_forecast_enabled=True, time_series_forecast_enabled=True,
time_series_min_candles=120, time_series_min_candles=120,
time_series_forecast_horizon=3, time_series_forecast_horizon=3,
time_series_min_edge_percent=0.04, time_series_min_edge_percent=0.08,
time_series_min_probability_up=0.58,
time_series_min_confidence=0.4,
time_series_max_adjustment=0.08, time_series_max_adjustment=0.08,
time_series_lstm_enabled=True, time_series_lstm_enabled=True,
time_series_lstm_model_path=tmp_path / "lstm_forecaster.json", time_series_lstm_model_path=tmp_path / "lstm_forecaster.json",
time_series_probe_enabled=True,
time_series_probe_min_edge_percent=0.02,
time_series_probe_min_probability_up=0.55,
time_series_probe_size_multiplier=0.40,
time_series_rebound_fallback_enabled=True,
stop_loss_percent=0.02, stop_loss_percent=0.02,
stop_loss_exit_enabled=True,
take_profit_percent=0.035, take_profit_percent=0.035,
trailing_stop_percent=0.015, trailing_stop_percent=0.015,
min_hold_seconds=180, min_hold_seconds=180,
min_exit_net_percent=0.20,
entry_cooldown_seconds=180, entry_cooldown_seconds=180,
max_daily_drawdown_usdt=6.0, max_daily_drawdown_usdt=6.0,
min_cash_reserve_usdt=5.0, min_cash_reserve_usdt=5.0,
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
from crypto_spot_bot.analytics import risk_guard_snapshot
from crypto_spot_bot.data_quality import analyze_symbol_quality
from crypto_spot_bot.models import Candle, Ticker, Trade, utc_now
from crypto_spot_bot.storage import Storage
def test_risk_guard_reduces_size_after_consecutive_losses(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, risk_max_consecutive_losses=2)
storage = Storage(settings.database_path)
now = utc_now()
for _ in range(2):
storage.insert_trade(
Trade(
id=None,
symbol="BTCUSDT",
side="SELL",
qty=1.0,
entry_price=100.0,
exit_price=99.0,
net_pnl=-1.0,
opened_at=now,
closed_at=now,
entry_diagnostics={"forecast": {"probability_up": 0.64, "model": "torch_gru"}},
)
)
guard = risk_guard_snapshot(settings, storage.closed_trades(), storage.latest_equity())
assert guard["block_new_entries"] is False
assert "consecutive_losses" in guard["reasons"]
assert guard["position_size_multiplier"] == settings.risk_reduce_multiplier
def test_risk_guard_blocks_only_bad_symbol(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
risk_symbol_guard_enabled=True,
risk_max_consecutive_losses=3,
symbols=["BTCUSDT", "ETHUSDT"],
)
storage = Storage(settings.database_path)
now = utc_now()
for _ in range(3):
storage.insert_trade(
Trade(
id=None,
symbol="BTCUSDT",
side="SELL",
qty=1.0,
entry_price=100.0,
exit_price=99.0,
net_pnl=-1.0,
opened_at=now,
closed_at=now,
entry_diagnostics={"forecast": {"probability_up": 0.64, "model": "torch_gru"}},
)
)
storage.insert_trade(
Trade(
id=None,
symbol="ETHUSDT",
side="SELL",
qty=1.0,
entry_price=100.0,
exit_price=102.0,
net_pnl=2.0,
opened_at=now,
closed_at=now,
entry_diagnostics={"forecast": {"probability_up": 0.64, "model": "torch_gru"}},
)
)
guard = risk_guard_snapshot(settings, storage.closed_trades(), storage.latest_equity())
assert guard["block_new_entries"] is False
assert guard["blocked_symbols"] == ["BTCUSDT"]
symbol_rows = {row["symbol"]: row for row in guard["symbols"]}
assert symbol_rows["BTCUSDT"]["block_new_entries"] is True
assert symbol_rows["ETHUSDT"]["block_new_entries"] is False
def test_risk_guard_can_disable_symbol_blocks(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
risk_symbol_guard_enabled=False,
risk_max_consecutive_losses=3,
symbols=["BTCUSDT", "ETHUSDT"],
)
storage = Storage(settings.database_path)
now = utc_now()
for _ in range(3):
storage.insert_trade(
Trade(
id=None,
symbol="BTCUSDT",
side="SELL",
qty=1.0,
entry_price=100.0,
exit_price=99.0,
net_pnl=-1.0,
opened_at=now,
closed_at=now,
entry_diagnostics={"forecast": {"probability_up": 0.64, "model": "torch_gru"}},
)
)
guard = risk_guard_snapshot(settings, storage.closed_trades(), storage.latest_equity())
assert guard["symbol_guard_enabled"] is False
assert guard["blocked_symbols"] == []
symbol_rows = {row["symbol"]: row for row in guard["symbols"]}
assert symbol_rows["BTCUSDT"]["consecutive_losses"] == 3
assert symbol_rows["BTCUSDT"]["block_new_entries"] is False
assert symbol_rows["BTCUSDT"]["reasons"] == []
def test_risk_guard_ignores_trades_outside_active_universe(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, risk_max_consecutive_losses=2, symbols=["BTCUSDT", "ETHUSDT"])
storage = Storage(settings.database_path)
now = utc_now()
for _ in range(4):
storage.insert_trade(
Trade(
id=None,
symbol="HYPEUSDT",
side="SELL",
qty=1.0,
entry_price=100.0,
exit_price=99.0,
net_pnl=-1.0,
opened_at=now,
closed_at=now,
)
)
guard = risk_guard_snapshot(settings, storage.closed_trades(), storage.latest_equity())
assert guard["block_new_entries"] is False
assert guard["reasons"] == []
assert guard["blocked_symbols"] == []
def test_data_quality_flags_missing_candle_gap() -> None:
candles = [
Candle(1_000_000, 100, 101, 99, 100.5, 10),
Candle(1_000_000 + 60 * 60 * 1000 * 3, 100.5, 102, 100, 101, 12),
]
ticker = Ticker("BTCUSDT", 101, 100.99, 101.01, 1_000_000, 100, 0)
row = analyze_symbol_quality(symbol="BTCUSDT", candles=candles, ticker=ticker, interval="60")
assert row["status"] == "warn"
assert any(issue["code"] == "missing_candles" for issue in row["issues"])
+28
View File
@@ -58,6 +58,34 @@ def test_live_spot_order_explicitly_disables_leverage(make_settings, tmp_path) -
assert captured["payload"]["orderFilter"] == "Order" assert captured["payload"]["orderFilter"] == "Order"
def test_private_get_signs_the_same_query_it_sends(make_settings, tmp_path) -> None:
client = BybitClient(make_settings(tmp_path))
captured = {}
class Response:
def raise_for_status(self):
return None
def json(self):
return {"retCode": 0, "result": {"ok": True}}
class Session:
def get(self, url, params, headers, timeout):
captured["url"] = url
captured["params"] = params
captured["headers"] = headers
captured["timeout"] = timeout
return Response()
client.session = Session()
assert client.wallet_balance(coin=None) == {"ok": True}
assert captured["params"] == [("accountType", "UNIFIED")]
assert "coin" not in dict(captured["params"])
assert captured["headers"]["X-BAPI-SIGN"]
def test_websocket_subscribe_uses_configured_kline_interval() -> None: def test_websocket_subscribe_uses_configured_kline_interval() -> None:
payload = websocket_subscribe_message(["BTCUSDT"], interval="60") payload = websocket_subscribe_message(["BTCUSDT"], interval="60")
+44 -4
View File
@@ -52,6 +52,21 @@ def test_fast_trading_env_sets_effective_intervals(tmp_path, monkeypatch) -> Non
assert settings.max_entries_per_minute == 4 assert settings.max_entries_per_minute == 4
def test_symbol_risk_guard_can_be_disabled(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("RISK_SYMBOL_GUARD_ENABLED", raising=False)
monkeypatch.setenv("TRADING_MODE", "paper")
env_file = tmp_path / ".env"
env_file.write_text(
"TRADING_MODE=paper\nRISK_SYMBOL_GUARD_ENABLED=false\n",
encoding="utf-8",
)
settings = load_settings(env_file)
assert settings.risk_guard_enabled is True
assert settings.risk_symbol_guard_enabled is False
def test_llm_advisor_is_disabled_by_default(tmp_path, monkeypatch) -> None: def test_llm_advisor_is_disabled_by_default(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("LLM_ADVISOR_ENABLED", raising=False) monkeypatch.delenv("LLM_ADVISOR_ENABLED", raising=False)
monkeypatch.setenv("TRADING_MODE", "paper") monkeypatch.setenv("TRADING_MODE", "paper")
@@ -84,7 +99,7 @@ def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None:
assert settings.time_series_forecast_enabled is True assert settings.time_series_forecast_enabled is True
def test_torch_forecast_forces_fixed_symbols(tmp_path, monkeypatch) -> None: def test_torch_forecast_keeps_configured_symbol_selection(tmp_path, monkeypatch) -> None:
for key in ( for key in (
"AUTO_SELECT_SYMBOLS", "AUTO_SELECT_SYMBOLS",
"TOP_SYMBOLS_COUNT", "TOP_SYMBOLS_COUNT",
@@ -110,7 +125,32 @@ def test_torch_forecast_forces_fixed_symbols(tmp_path, monkeypatch) -> None:
settings = load_settings(env_file) settings = load_settings(env_file)
assert settings.auto_select_symbols is False assert settings.auto_select_symbols is True
assert settings.top_symbols_count == len(FIXED_SPOT_SYMBOLS) assert settings.top_symbols_count == 9
assert settings.symbols == FIXED_SPOT_SYMBOLS assert settings.symbols == ("DOGEUSDT", "XRPUSDT")
assert settings.time_series_forecast_enabled is True assert settings.time_series_forecast_enabled is True
def test_auto_select_uses_empty_symbol_list(tmp_path, monkeypatch) -> None:
for key in ("AUTO_SELECT_SYMBOLS", "TOP_SYMBOLS_COUNT", "SYMBOLS", "STRATEGY_MODE"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("TRADING_MODE", "paper")
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"TRADING_MODE=paper",
"STRATEGY_MODE=torch_forecast",
"AUTO_SELECT_SYMBOLS=true",
"TOP_SYMBOLS_COUNT=12",
"SYMBOLS=",
]
),
encoding="utf-8",
)
settings = load_settings(env_file)
assert settings.auto_select_symbols is True
assert settings.top_symbols_count == 12
assert settings.symbols == ()
+11
View File
@@ -4,6 +4,7 @@ import json
from crypto_spot_bot.dashboard import _apply_fast_trading from crypto_spot_bot.dashboard import _apply_fast_trading
from crypto_spot_bot.dashboard import _safe_config from crypto_spot_bot.dashboard import _safe_config
from crypto_spot_bot.dashboard import WEB_UI_REMOVED_MESSAGE
from crypto_spot_bot.storage import Storage from crypto_spot_bot.storage import Storage
@@ -39,6 +40,11 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
config = _safe_config(settings) config = _safe_config(settings)
assert config["time_series_probe_enabled"] is True
assert config["time_series_probe_min_edge_percent"] == 0.02
assert config["time_series_probe_min_probability_up"] == 0.55
assert config["time_series_probe_size_multiplier"] == 0.40
assert config["time_series_rebound_fallback_enabled"] is True
assert config["time_series_model_artifact"] == { assert config["time_series_model_artifact"] == {
"available": True, "available": True,
"type": "pytorch_recurrent_forecaster", "type": "pytorch_recurrent_forecaster",
@@ -50,3 +56,8 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
"target_horizon": 0, "target_horizon": 0,
"direct_horizon": False, "direct_horizon": False,
} }
def test_web_ui_is_removed_from_api_service() -> None:
assert "Web UI removed" in WEB_UI_REMOVED_MESSAGE
assert "/api/*" in WEB_UI_REMOVED_MESSAGE
+96 -4
View File
@@ -27,6 +27,9 @@ def test_paper_broker_buy_and_sell_records_trade(make_settings, tmp_path) -> Non
assert trade.side == "SELL" assert trade.side == "SELL"
assert len(broker.open_positions()) == 0 assert len(broker.open_positions()) == 0
assert storage.recent_trades(limit=10) assert storage.recent_trades(limit=10)
summary = storage.closed_trade_summary()
assert summary["trades"] == 1
assert summary["net_pnl"] == round(trade.net_pnl, 6)
def test_paper_broker_limits_fast_entries_per_minute(make_settings, tmp_path) -> None: def test_paper_broker_limits_fast_entries_per_minute(make_settings, tmp_path) -> None:
@@ -54,12 +57,13 @@ def test_paper_broker_limits_fast_entries_per_minute(make_settings, tmp_path) ->
def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_path) -> None: def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_path) -> None:
settings = make_settings( settings = make_settings(
tmp_path, tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1, min_position_usdt=1,
max_position_usdt=20, max_position_usdt=20,
max_symbol_exposure_usdt=6, max_symbol_exposure_usdt=6,
max_total_exposure_usdt=50, max_total_exposure_usdt=50,
max_open_positions=20, max_open_positions=20,
max_positions_per_symbol=1, max_positions_per_symbol=6,
max_entries_per_minute=0, max_entries_per_minute=0,
) )
storage = Storage(settings.database_path) storage = Storage(settings.database_path)
@@ -100,6 +104,63 @@ def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_
assert 5.5 <= broker.symbol_exposure("BTCUSDT") <= 6.0 assert 5.5 <= broker.symbol_exposure("BTCUSDT") <= 6.0
def test_paper_broker_raises_small_signal_to_exchange_min_notional(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
min_position_usdt=1,
max_position_usdt=20,
max_symbol_exposure_usdt=20,
max_total_exposure_usdt=80,
max_open_positions=20,
max_positions_per_symbol=20,
max_entries_per_minute=0,
)
storage = Storage(settings.database_path)
broker = PaperBroker(settings, storage)
ticker = Ticker("XRPUSDT", 1.0, 0.999, 1.001, 10_000_000, 100, 0)
instrument = Instrument("XRPUSDT", "XRP", "USDT", "Trading", 0.0001, 0.01, 0.01, 5)
position = broker.buy(
Signal("XRPUSDT", "BUY", 0.8, "small rebound", {"position_notional_usdt": 1.5}),
ticker,
instrument,
{"XRPUSDT": 1.0},
)
assert position is not None
assert position.notional_usdt >= instrument.min_notional_value
assert position.notional_usdt <= settings.max_position_usdt
def test_paper_broker_rounds_small_order_up_to_exchange_qty_step(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
min_position_usdt=1,
max_position_usdt=20,
max_symbol_exposure_usdt=20,
max_total_exposure_usdt=80,
max_open_positions=20,
max_positions_per_symbol=20,
max_entries_per_minute=0,
)
storage = Storage(settings.database_path)
broker = PaperBroker(settings, storage)
ticker = Ticker("HYPEUSDT", 39.6, 39.59, 39.61, 10_000_000, 100, 0)
instrument = Instrument("HYPEUSDT", "HYPE", "USDT", "Trading", 0.001, 0.01, 0.01, 1)
position = broker.buy(
Signal("HYPEUSDT", "BUY", 0.8, "small torch edge", {"position_notional_usdt": 1.05}),
ticker,
instrument,
{"HYPEUSDT": 39.6},
)
assert position is not None
assert position.qty == 0.03
assert position.notional_usdt >= instrument.min_notional_value
assert position.notional_usdt <= settings.max_position_usdt
def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path) -> None: def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path) -> None:
settings = make_settings( settings = make_settings(
tmp_path, tmp_path,
@@ -160,7 +221,7 @@ def test_trend_macd_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) -
assert len(broker.open_positions()) == 1 assert len(broker.open_positions()) == 1
def test_torch_forecast_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) -> None: def test_torch_forecast_broker_allows_dynamic_entries_until_total_limit(make_settings, tmp_path) -> None:
settings = make_settings( settings = make_settings(
tmp_path, tmp_path,
strategy_mode="torch_forecast", strategy_mode="torch_forecast",
@@ -179,10 +240,41 @@ def test_torch_forecast_broker_blocks_dca_for_same_symbol(make_settings, tmp_pat
first = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "first", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100}) first = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "first", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
second = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "second", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100}) second = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "second", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
third = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "third", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
fourth = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "fourth", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
assert first is not None assert first is not None
assert second is None assert second is not None
assert len(broker.open_positions()) == 1 assert third is not None
assert fourth is None
assert len(broker.open_positions()) == 3
def test_torch_forecast_broker_respects_configured_symbol_position_limit(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1,
max_position_usdt=20,
max_symbol_exposure_usdt=20,
max_total_exposure_usdt=80,
max_open_positions=20,
max_positions_per_symbol=2,
max_entries_per_minute=0,
)
storage = Storage(settings.database_path)
broker = PaperBroker(settings, storage)
ticker = Ticker("BTCUSDT", 100, 99.9, 100.1, 10_000_000, 100, 0)
instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 1)
first = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "first", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
second = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "second", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
third = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "third", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
assert first is not None
assert second is not None
assert third is None
assert len(broker.open_positions()) == 2
def test_trend_macd_closes_old_paper_positions_outside_symbol_universe(make_settings, tmp_path) -> None: def test_trend_macd_closes_old_paper_positions_outside_symbol_universe(make_settings, tmp_path) -> None:
+793 -4
View File
@@ -233,6 +233,90 @@ def test_trend_macd_exits_on_atr_trailing_stop(make_settings, tmp_path) -> None:
assert "ATR trailing" in signal.reason assert "ATR trailing" in signal.reason
def test_trend_macd_holds_below_stop_when_stop_loss_exit_disabled(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="trend_macd",
stop_loss_exit_enabled=False,
atr_trailing_multiplier=2.2,
)
strategy = SpotStrategy(settings)
candles = _trend_entry_candles(close=99.0, ema50=95.0, macd_cross_up=False)
candles[-2].macd = 0.1
candles[-2].macd_signal = 0.0
candles[-1].macd = 0.1
candles[-1].macd_signal = 0.0
candles[-1].atr_14 = 1.0
position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 100.5)
ticker = Ticker("BTCUSDT", 95.5, 95.49, 95.51, 1_000_000, 100, 0)
signal = strategy.exit_signal(position, candles, ticker)
assert signal.action == "HOLD"
assert signal.diagnostics["stop_loss"] is None
assert signal.diagnostics["stop_loss_exit_enabled"] is False
def test_torch_atr_trailing_without_stop_loss_waits_for_profit_stop(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
stop_loss_exit_enabled=False,
atr_trailing_multiplier=2.2,
)
strategy = SpotStrategy(settings)
candles = _trend_entry_candles(close=99.0, ema50=95.0, macd_cross_up=False)
candles[-1].atr_14 = 1.0
position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 101.0)
ticker = Ticker("BTCUSDT", 98.7, 98.69, 98.71, 1_000_000, 100, 0)
signal = strategy.exit_signal(
position,
candles,
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": 0.4,
"probability_up": 0.62,
"skill": 0.18,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["atr_trailing_stop"] is None
def test_torch_atr_trailing_without_stop_loss_can_lock_profit(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
stop_loss_exit_enabled=False,
atr_trailing_multiplier=2.2,
)
strategy = SpotStrategy(settings)
candles = _trend_entry_candles(close=104.0, ema50=95.0, macd_cross_up=False)
candles[-1].atr_14 = 1.0
position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 104.0)
ticker = Ticker("BTCUSDT", 101.7, 101.69, 101.71, 1_000_000, 100, 0)
signal = strategy.exit_signal(
position,
candles,
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": 0.4,
"probability_up": 0.62,
"skill": 0.18,
},
)
assert signal.action == "SELL"
assert "ATR trailing" in signal.reason
def test_torch_forecast_buys_only_from_positive_torch_edge(make_settings, tmp_path) -> None: def test_torch_forecast_buys_only_from_positive_torch_edge(make_settings, tmp_path) -> None:
settings = make_settings( settings = make_settings(
tmp_path, tmp_path,
@@ -252,8 +336,8 @@ def test_torch_forecast_buys_only_from_positive_torch_edge(make_settings, tmp_pa
forecast={ forecast={
"usable": True, "usable": True,
"model": "torch_gru", "model": "torch_gru",
"expected_return_percent": 0.24, "expected_return_percent": 0.36,
"probability_up": 0.63, "probability_up": 0.66,
"skill": 0.22, "skill": 0.22,
"block_entry": False, "block_entry": False,
}, },
@@ -263,7 +347,8 @@ def test_torch_forecast_buys_only_from_positive_torch_edge(make_settings, tmp_pa
assert signal.action == "BUY" assert signal.action == "BUY"
assert signal.diagnostics["strategy_mode"] == "torch_forecast" assert signal.diagnostics["strategy_mode"] == "torch_forecast"
assert signal.diagnostics["checks"]["torch_model_ok"] is True assert signal.diagnostics["checks"]["torch_model_ok"] is True
assert signal.diagnostics["position_notional_usdt"] == 25.0 assert signal.diagnostics["position_sizing"]["method"] == "torch_forecast_fractional_kelly"
assert settings.min_position_usdt <= signal.diagnostics["position_notional_usdt"] <= settings.max_position_usdt
def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path) -> None: def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path) -> None:
@@ -284,9 +369,498 @@ def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path
assert signal.diagnostics["checks"]["torch_model_ok"] is False assert signal.diagnostics["checks"]["torch_model_ok"] is False
def test_torch_forecast_allows_additional_entries_until_symbol_limit(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1,
max_symbol_exposure_usdt=3,
max_positions_per_symbol=3,
max_position_usdt=25,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
forecast = {
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.36,
"probability_up": 0.66,
"skill": 0.22,
"block_entry": False,
}
additional = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=1,
forecast=forecast,
account={"equity": 100.0},
)
capped = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=3,
forecast=forecast,
account={"equity": 100.0},
)
assert additional.action == "BUY"
assert capped.action == "HOLD"
assert "symbol position limit" in capped.reason
def test_torch_forecast_kelly_buys_only_remaining_symbol_allocation(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1,
max_position_usdt=8,
max_symbol_exposure_usdt=25,
max_positions_per_symbol=6,
stop_loss_percent=0.04,
take_profit_percent=0.035,
kelly_sizing_enabled=True,
kelly_fraction=0.25,
kelly_max_fraction=0.20,
time_series_min_edge_percent=0.10,
time_series_min_probability_up=0.47,
)
strategy = SpotStrategy(settings)
ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
forecast = {
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.60,
"probability_up": 0.84,
"skill": 0.22,
"block_entry": False,
}
first = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=0,
forecast=forecast,
account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 0.0},
)
second = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=1,
forecast=forecast,
account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 8.0},
)
filled = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=2,
forecast=forecast,
account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 20.0},
)
first_sizing = first.diagnostics["position_sizing"]
second_sizing = second.diagnostics["position_sizing"]
assert first.action == "BUY"
assert first_sizing["method"] == "torch_forecast_fractional_kelly"
assert first_sizing["kelly_target_notional_usdt"] > settings.max_position_usdt
assert first.diagnostics["position_notional_usdt"] == settings.max_position_usdt
assert second.action == "BUY"
assert 1 <= second.diagnostics["position_notional_usdt"] < settings.max_position_usdt
assert second_sizing["kelly_open_symbol_exposure_usdt"] == 8.0
assert filled.action == "HOLD"
assert filled.diagnostics["checks"]["risk_size_ok"] is False
def test_torch_forecast_kelly_allows_next_exchange_minimum_layer(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1,
max_position_usdt=8,
max_symbol_exposure_usdt=25,
max_total_exposure_usdt=75,
max_positions_per_symbol=6,
stop_loss_percent=0.04,
take_profit_percent=0.035,
kelly_sizing_enabled=True,
kelly_fraction=0.25,
kelly_max_fraction=0.20,
time_series_min_edge_percent=0.10,
time_series_min_probability_up=0.47,
time_series_min_confidence=0.4,
)
strategy = SpotStrategy(settings)
ticker = Ticker("HYPEUSDT", 63.14, 63.13, 63.15, 10_000_000, 1000, 1.0)
signal = strategy.entry_signal(
"HYPEUSDT",
[],
ticker,
open_positions_for_symbol=1,
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.2115,
"probability_up": 0.5163,
"skill": 0.0156,
"block_entry": False,
},
account={
"equity": 98.6,
"cash": 88.54,
"exposure": 10.07,
"symbol": "HYPEUSDT",
"symbol_exposure_usdt": 5.05,
"open_positions_for_symbol": 1,
"exchange_min_entry_usdt": 5.07,
},
)
sizing = signal.diagnostics["position_sizing"]
assert signal.action == "BUY"
assert signal.diagnostics["checks"]["risk_size_ok"] is True
assert sizing["kelly_target_notional_usdt"] < sizing["kelly_open_symbol_exposure_usdt"]
assert sizing["kelly_raw_remaining_notional_usdt"] == 0.0
assert sizing["kelly_layer_mode"] is True
assert signal.diagnostics["position_notional_usdt"] == 5.07
def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
time_series_min_edge_percent=0.10,
time_series_min_probability_up=0.57,
max_position_usdt=25,
stop_loss_percent=0.04,
)
strategy = SpotStrategy(settings)
ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
signal = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=0,
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.36,
"probability_up": 0.66,
"skill": 0.22,
"block_entry": False,
"quality_gate_passed": False,
"quality_gate": {"status": "fail"},
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["checks"]["quality_gate_ok"] is False
def test_torch_forecast_probe_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
time_series_min_edge_percent=0.10,
time_series_min_probability_up=0.52,
time_series_probe_enabled=True,
time_series_probe_min_edge_percent=0.02,
time_series_probe_min_probability_up=0.55,
time_series_probe_size_multiplier=0.40,
max_position_usdt=25,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
ticker = Ticker("SOLUSDT", 65, 64.99, 65.01, 10_000_000, 1000, 1.0)
signal = strategy.entry_signal(
"SOLUSDT",
[],
ticker,
open_positions_for_symbol=0,
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.04,
"probability_up": 0.57,
"skill": 0.05,
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["edge_mode"] == "probe"
assert signal.diagnostics["checks"]["expected_edge_ok"] is True
assert signal.diagnostics["checks"]["risk_size_ok"] is False
assert signal.diagnostics["position_notional_usdt"] == 0.0
def test_torch_forecast_probe_blocks_negative_expected_return(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
time_series_min_edge_percent=0.10,
time_series_min_probability_up=0.52,
time_series_probe_enabled=True,
time_series_probe_min_edge_percent=0.02,
time_series_probe_min_probability_up=0.55,
)
strategy = SpotStrategy(settings)
ticker = Ticker("BTCUSDT", 59_000, 58_999, 59_001, 10_000_000, 1000, 1.0)
signal = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=0,
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": -0.03,
"probability_up": 0.60,
"skill": 0.16,
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["edge_mode"] == "blocked"
assert signal.diagnostics["checks"]["expected_edge_ok"] is False
def test_torch_forecast_rebound_overlay_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
rebound_max_position_usdt=6.0,
time_series_min_edge_percent=0.10,
time_series_probe_min_probability_up=0.55,
max_position_usdt=25,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="BTCUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.01,
"probability_up": 0.56,
"skill": 0.05,
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["rebound"]["active"] is True
assert signal.diagnostics["model_rebound_entry_ok"] is True
assert signal.diagnostics["rebound_entry_sized_ok"] is False
assert signal.diagnostics["checks"]["expected_edge_ok"] is False
assert signal.diagnostics["checks"]["risk_size_ok"] is False
def test_torch_forecast_rebound_overlay_does_not_buy_negative_forecast(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
time_series_min_edge_percent=0.10,
time_series_probe_min_probability_up=0.55,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="BTCUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": -0.01,
"probability_up": 0.56,
"skill": 0.05,
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["rebound"]["active"] is True
assert signal.diagnostics["rebound_entry_ok"] is False
assert signal.diagnostics["edge_mode"] == "blocked"
def test_torch_forecast_rebound_fallback_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
rebound_max_position_usdt=6.0,
time_series_rebound_fallback_enabled=True,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="HYPEUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"HYPEUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["fallback_rebound_entry_ok"] is True
assert signal.diagnostics["rebound_entry_sized_ok"] is False
assert signal.diagnostics["missing_torch_model"] is True
assert signal.diagnostics["checks"]["risk_size_ok"] is False
def test_torch_forecast_rebound_fallback_can_be_disabled(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
time_series_rebound_fallback_enabled=False,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="HYPEUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"HYPEUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["missing_torch_model"] is True
assert signal.diagnostics["fallback_rebound_entry_ok"] is False
def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_path) -> None: def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", stop_loss_percent=0.04) settings = make_settings(tmp_path, strategy_mode="torch_forecast", stop_loss_percent=0.04)
strategy = SpotStrategy(settings) strategy = SpotStrategy(settings)
position = Position(
1,
"SOLUSDT",
1,
100,
100,
0.1,
96,
120,
103,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("SOLUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(),
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": -0.08,
"probability_up": 0.43,
"skill": 0.18,
"block_entry": True,
},
)
assert signal.action == "SELL"
assert "прогноз" in signal.reason
def test_torch_forecast_holds_negative_forecast_during_min_hold(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180)
strategy = SpotStrategy(settings)
position = Position(1, "SOLUSDT", 1, 100, 100, 0.1, 96, 120, 103) position = Position(1, "SOLUSDT", 1, 100, 100, 0.1, 96, 120, 103)
ticker = Ticker("SOLUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0) ticker = Ticker("SOLUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0)
@@ -304,8 +878,223 @@ def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_pa
}, },
) )
assert signal.action == "HOLD"
assert signal.diagnostics["forecast_exit_blocked_by_min_hold"] is True
def test_torch_forecast_holds_fee_churn_exit_after_min_hold(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60)
strategy = SpotStrategy(settings)
position = Position(
1,
"BTCUSDT",
1,
100,
100,
0.1,
96,
120,
100.1,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("BTCUSDT", 99.99, 99.98, 100.0, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(),
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": -0.01,
"probability_up": 0.499,
"skill": 0.18,
"block_entry": False,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["forecast_exit_blocked_by_cost"] is True
def test_torch_forecast_holds_atr_trailing_exit_that_does_not_cover_fees(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60)
strategy = SpotStrategy(settings)
candles = _trend_entry_candles(close=100.2)
candles[-1].atr_14 = 0.8
position = Position(
1,
"MNTUSDT",
1,
100,
100,
0.1,
96,
120,
102,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("MNTUSDT", 100.2, 100.19, 100.21, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
candles,
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": 0.4,
"probability_up": 0.58,
"skill": 0.18,
"block_entry": False,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["atr_exit_blocked_by_cost"] is True
def test_torch_forecast_holds_atr_trailing_exit_below_min_profit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60, min_exit_net_percent=0.20)
strategy = SpotStrategy(settings)
candles = _trend_entry_candles(close=100.35)
candles[-1].atr_14 = 0.6
position = Position(
1,
"MNTUSDT",
1,
100,
100,
0.1,
96,
120,
102,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("MNTUSDT", 100.35, 100.34, 100.36, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
candles,
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": 0.4,
"probability_up": 0.58,
"skill": 0.18,
"block_entry": False,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["atr_exit_blocked_by_min_profit"] is True
assert signal.diagnostics["estimated_exit_net_percent"] < settings.min_exit_net_percent
def test_torch_forecast_holds_negative_forecast_exit_below_min_profit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60, min_exit_net_percent=0.20)
strategy = SpotStrategy(settings)
position = Position(
1,
"BTCUSDT",
1,
100,
100,
0.1,
96,
120,
100.5,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("BTCUSDT", 100.35, 100.34, 100.36, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(close=100.35),
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": -0.2,
"probability_up": 0.40,
"skill": 0.18,
"block_entry": False,
"reason": "model turned down",
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["forecast_exit_blocked_by_min_profit"] is True
assert signal.diagnostics["estimated_exit_net_percent"] < settings.min_exit_net_percent
def test_torch_forecast_rebound_fallback_holds_without_model(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180)
strategy = SpotStrategy(settings)
position = Position(
1,
"XRPUSDT",
5,
1.0,
5.0,
0.005,
0.96,
1.035,
1.0,
entry_diagnostics={"entry_path": "rebound_fallback", "edge_mode": "rebound_fallback"},
)
ticker = Ticker("XRPUSDT", 1.001, 1.0009, 1.0011, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(close=1.0, ema50=0.98),
ticker,
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["rebound_fallback_position"] is True
assert "rebound fallback" in signal.reason
def test_torch_forecast_rebound_fallback_still_sells_take_profit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast")
strategy = SpotStrategy(settings)
position = Position(
1,
"XRPUSDT",
5,
1.0,
5.0,
0.005,
0.96,
1.035,
1.04,
opened_at=utc_now() - timedelta(seconds=600),
entry_diagnostics={"entry_path": "rebound_fallback", "edge_mode": "rebound_fallback"},
)
ticker = Ticker("XRPUSDT", 1.036, 1.0359, 1.0361, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(close=1.0, ema50=0.98),
ticker,
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
)
assert signal.action == "SELL" assert signal.action == "SELL"
assert "torch_forecast" in signal.reason assert "take-profit" in signal.reason
def test_strategy_emits_buy_when_score_passes_threshold(make_settings, tmp_path) -> None: def test_strategy_emits_buy_when_score_passes_threshold(make_settings, tmp_path) -> None:
+26
View File
@@ -282,6 +282,28 @@ def test_time_series_forecaster_reads_torch_gru_artifact(make_settings, tmp_path
assert forecast.probability_up > 0.5 assert forecast.probability_up > 0.5
def test_time_series_forecaster_attaches_quality_gate(make_settings, tmp_path) -> None:
artifact_path = tmp_path / "lstm_forecaster.json"
_write_torch_gru_artifact(artifact_path, head_bias=0.2)
(tmp_path / "torch_threshold_calibration.json").write_text(
json.dumps({"validation": {"status": "fail", "passed": False, "checks": []}}),
encoding="utf-8",
)
settings = make_settings(
tmp_path,
time_series_min_candles=80,
time_series_lstm_enabled=True,
time_series_lstm_model_path=artifact_path,
)
returns = [0.00015 if index % 4 else -0.00005 for index in range(140)]
forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns), symbol="BTCUSDT")
assert forecast.usable is True
assert forecast.quality_gate_passed is False
assert forecast.quality_gate["status"] == "fail"
def test_time_series_forecaster_reads_multifeature_direct_horizon_artifact(make_settings, tmp_path) -> None: def test_time_series_forecaster_reads_multifeature_direct_horizon_artifact(make_settings, tmp_path) -> None:
artifact_path = tmp_path / "lstm_forecaster.json" artifact_path = tmp_path / "lstm_forecaster.json"
_write_multifeature_torch_gru_artifact(artifact_path, head_bias=0.2) _write_multifeature_torch_gru_artifact(artifact_path, head_bias=0.2)
@@ -322,3 +344,7 @@ def test_time_series_forecaster_reads_probabilistic_multi_horizon_artifact(make_
assert forecast.probability_up > 0.85 assert forecast.probability_up > 0.85
assert forecast.quantile_10_percent <= forecast.quantile_50_percent <= forecast.quantile_90_percent assert forecast.quantile_10_percent <= forecast.quantile_50_percent <= forecast.quantile_90_percent
assert sorted(forecast.horizon_forecasts) == ["1", "3"] assert sorted(forecast.horizon_forecasts) == ["1", "3"]
assert [item["name"] for item in forecast.feature_snapshot] == ["return_1", "range_percent"]
assert forecast.feature_snapshot[0]["label"] == "Доходность 1ч"
assert forecast.feature_snapshot[0]["raw_display"].endswith("%")
assert "диапазон" in forecast.feature_snapshot[0]["interpretation"]
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from tools.accept_torch_candidate import _decision
def _report(*, validation_passed: bool = True, trades: int = 30, total: float = 10.0) -> dict:
return {
"recommended": {"score": 0.5},
"full_replay": {
"trades": trades,
"avg_net_percent": 0.4,
"total_net_percent": total,
"profit_factor": 2.0,
"max_drawdown_percent": 1.0,
},
"walk_forward": {"summary": {"trades": trades, "avg_net_percent": 0.3}},
"validation": {"passed": validation_passed, "status": "pass" if validation_passed else "fail"},
}
def test_guard_rejects_candidate_without_honest_validation() -> None:
decision = _decision(
_report(),
_report(validation_passed=False),
min_trades=8,
min_profit_factor=1.05,
min_avg_net_percent=0.0,
max_score_regression=0.05,
)
assert decision == {"accepted": False, "reason": "candidate_failed_honest_validation"}
def test_guard_accepts_candidate_that_passes_honest_validation() -> None:
decision = _decision(
_report(total=9.0),
_report(total=12.0),
min_trades=8,
min_profit_factor=1.05,
min_avg_net_percent=0.0,
max_score_regression=0.05,
)
assert decision == {"accepted": True, "reason": "candidate_passed_guard"}
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import base64
import hashlib
import json
from crypto_spot_bot.training_coordination import TrainingCoordinator
def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
requested = coordinator.request_retrain({"source": "android"})
job_id = requested["job"]["id"]
heartbeat = coordinator.heartbeat({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
claimed = coordinator.claim({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
assert requested["queued"] is True
assert heartbeat["status"]["agent_online"] is True
assert claimed["claimed"] is True
assert claimed["job"]["id"] == job_id
assert coordinator.status()["active_job"]["status"] == "running"
progress = coordinator.progress(
job_id,
{"status": "running", "phase": "training", "progress_percent": 42, "message": "epoch 1"},
)
assert progress["job"]["phase"] == "training"
assert progress["job"]["progress_percent"] == 42
assert coordinator.status()["active_job"]["message"] == "epoch 1"
completed = coordinator.complete(job_id, {"success": True, "message": "ok"})
assert completed["job"]["status"] == "completed"
assert coordinator.status()["active_job"] is None
def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
job = coordinator.request_retrain({"source": "test"})["job"]
payload = b'{"type":"pytorch_recurrent_forecaster","symbols":{}}\n'
sha256 = hashlib.sha256(payload).hexdigest()
first = payload[:20]
second = payload[20:]
part_1 = coordinator.save_artifact_chunk(
job["id"],
{
"name": "lstm_forecaster.json",
"index": 0,
"total": 2,
"sha256": sha256,
"data_base64": base64.b64encode(first).decode("ascii"),
},
)
part_2 = coordinator.save_artifact_chunk(
job["id"],
{
"name": "lstm_forecaster.json",
"index": 1,
"total": 2,
"sha256": sha256,
"data_base64": base64.b64encode(second).decode("ascii"),
},
)
assert part_1["complete"] is False
assert part_2["complete"] is True
assert (tmp_path / "lstm_forecaster.json").read_bytes() == payload
assert coordinator.status()["latest_job"]["artifacts"][0]["sha256"] == sha256
def test_running_claimed_job_keeps_agent_online_when_heartbeat_is_stale(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
coordinator.request_retrain({"source": "android"})
coordinator.claim({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
state_path = tmp_path / "training_coordination.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["worker"]["last_seen_at"] = "2026-01-01T00:00:00+00:00"
state_path.write_text(json.dumps(state), encoding="utf-8")
status = coordinator.status()
assert status["agent_recently_seen"] is False
assert status["agent_busy"] is True
assert status["agent_online"] is True
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
from typing import Any
def main() -> None:
args = _parse_args()
current = _read_json(args.current_report)
candidate = _read_json(args.candidate_report)
decision = _decision(
current,
candidate,
min_trades=args.min_trades,
min_profit_factor=args.min_profit_factor,
min_avg_net_percent=args.min_avg_net_percent,
max_score_regression=args.max_score_regression,
)
payload = {
"accepted": decision["accepted"],
"reason": decision["reason"],
"current": _summary(current),
"candidate": _summary(candidate),
}
if args.report:
Path(args.report).write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
if not decision["accepted"]:
raise SystemExit(2)
target = Path(args.target_artifact)
candidate_artifact = Path(args.candidate_artifact)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(candidate_artifact, target)
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Accept or reject a retrained Torch candidate artifact.")
parser.add_argument("--current-report", required=True)
parser.add_argument("--candidate-report", required=True)
parser.add_argument("--candidate-artifact", required=True)
parser.add_argument("--target-artifact", required=True)
parser.add_argument("--report", default="")
parser.add_argument("--min-trades", type=int, default=8)
parser.add_argument("--min-profit-factor", type=float, default=1.05)
parser.add_argument("--min-avg-net-percent", type=float, default=0.0)
parser.add_argument("--max-score-regression", type=float, default=0.05)
return parser.parse_args()
def _decision(
current: dict[str, Any],
candidate: dict[str, Any],
*,
min_trades: int,
min_profit_factor: float,
min_avg_net_percent: float,
max_score_regression: float,
) -> dict[str, Any]:
candidate_score = _score(candidate)
current_score = _score(current)
candidate_replay = candidate.get("full_replay") if isinstance(candidate.get("full_replay"), dict) else {}
candidate_walk = candidate.get("walk_forward") if isinstance(candidate.get("walk_forward"), dict) else {}
walk_summary = candidate_walk.get("summary") if isinstance(candidate_walk.get("summary"), dict) else {}
if not _validation_passed(candidate):
return {"accepted": False, "reason": "candidate_failed_honest_validation"}
if int(candidate_replay.get("trades", 0) or 0) < min_trades:
return {"accepted": False, "reason": "candidate_has_too_few_full_replay_trades"}
if float(candidate_replay.get("profit_factor", 0.0) or 0.0) < min_profit_factor:
return {"accepted": False, "reason": "candidate_profit_factor_below_min"}
if float(candidate_replay.get("avg_net_percent", 0.0) or 0.0) <= min_avg_net_percent:
return {"accepted": False, "reason": "candidate_expectancy_non_positive"}
if int(walk_summary.get("trades", 0) or 0) >= min_trades and float(walk_summary.get("avg_net_percent", 0.0) or 0.0) <= min_avg_net_percent:
return {"accepted": False, "reason": "candidate_walk_forward_expectancy_non_positive"}
if _validation_passed(current) and current_score > 0 and candidate_score < current_score * (1.0 - max_score_regression):
return {"accepted": False, "reason": "candidate_score_regressed_vs_current"}
return {"accepted": True, "reason": "candidate_passed_guard"}
def _validation_passed(report: dict[str, Any]) -> bool:
validation = report.get("validation")
if not isinstance(validation, dict):
return False
if "passed" in validation:
return bool(validation.get("passed"))
return str(validation.get("status", "")).strip().lower() in {"pass", "passed", "ok"}
def _score(report: dict[str, Any]) -> float:
replay = report.get("full_replay") if isinstance(report.get("full_replay"), dict) else {}
recommended = report.get("recommended") if isinstance(report.get("recommended"), dict) else {}
replay_score = (
float(replay.get("avg_net_percent", 0.0) or 0.0)
+ float(replay.get("total_net_percent", 0.0) or 0.0) * 0.02
- float(replay.get("max_drawdown_percent", 0.0) or 0.0) * 0.05
+ min(float(replay.get("profit_factor", 0.0) or 0.0), 10.0) * 0.03
)
return replay_score + float(recommended.get("score", 0.0) or 0.0) * 0.25
def _summary(report: dict[str, Any]) -> dict[str, Any]:
return {
"score": round(_score(report), 6),
"recommended": report.get("recommended", {}),
"full_replay": report.get("full_replay", {}),
"walk_forward_summary": (report.get("walk_forward") or {}).get("summary", {})
if isinstance(report.get("walk_forward"), dict)
else {},
"benchmark_summary": (report.get("benchmark") or {}).get("summary", {})
if isinstance(report.get("benchmark"), dict)
else {},
"validation": report.get("validation", {}),
}
def _read_json(path: str) -> dict[str, Any]:
if not path:
return {}
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+22 -2
View File
@@ -2,13 +2,18 @@
param( param(
[string]$TaskName = "TradeBot PyTorch Forecaster Retrainer", [string]$TaskName = "TradeBot PyTorch Forecaster Retrainer",
[int]$EveryHours = 6, [int]$EveryHours = 6,
[string]$Symbols = "BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT", [string]$Symbols = "",
[int]$Limit = 3000, [int]$Limit = 3000,
[int]$Horizon = 0, [int]$Horizon = 0,
[string]$Horizons = "", [string]$Horizons = "",
[string]$Features = "", [string]$Features = "",
[string]$ContextSymbols = "", [string]$ContextSymbols = "",
[int]$FirstRunMinutes = 0 [int]$FirstRunMinutes = 0,
[switch]$DeployToPi,
[string]$PiHost = "192.168.0.185",
[string]$PiUser = "sevenhill",
[string]$PiRoot = "/mnt/data/tradebot",
[string]$PiSshKeyPath = ""
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -46,6 +51,21 @@ if ($Features) {
if ($ContextSymbols) { if ($ContextSymbols) {
$actionArgs += " -ContextSymbols `"$ContextSymbols`"" $actionArgs += " -ContextSymbols `"$ContextSymbols`""
} }
if ($DeployToPi) {
$actionArgs += " -DeployToPi"
}
if ($PiHost) {
$actionArgs += " -PiHost `"$PiHost`""
}
if ($PiUser) {
$actionArgs += " -PiUser `"$PiUser`""
}
if ($PiRoot) {
$actionArgs += " -PiRoot `"$PiRoot`""
}
if ($PiSshKeyPath) {
$actionArgs += " -PiSshKeyPath `"$PiSshKeyPath`""
}
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArgs -WorkingDirectory $RepoRoot $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArgs -WorkingDirectory $RepoRoot
$trigger = New-ScheduledTaskTrigger ` $trigger = New-ScheduledTaskTrigger `
-Once ` -Once `
+119
View File
@@ -0,0 +1,119 @@
[CmdletBinding()]
param(
[string]$TaskName = "TradeBot Windows Training Agent",
[string]$ApiBaseUrl = "https://tb.kusoft.xyz",
[string]$ApiAuth = "",
[int]$PollSeconds = 10,
[int]$WatchdogMinutes = 5,
[string]$RepoRoot = "",
[switch]$StartNow,
[switch]$KeepLegacyRetrainer
)
$ErrorActionPreference = "Stop"
if (-not $RepoRoot) {
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
}
$Agent = Join-Path $RepoRoot "tools\windows_training_agent.py"
if (-not (Test-Path $Agent)) {
throw "Windows training agent not found: $Agent"
}
function Resolve-Python {
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
if (Test-Path $venvPython) {
return $venvPython
}
$userPython = Join-Path $env:LOCALAPPDATA "Programs\TradeBotPython312\python.exe"
if (Test-Path $userPython) {
return $userPython
}
foreach ($candidate in @("python.exe", "python")) {
$command = Get-Command $candidate -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
}
throw "Python was not found. Create .venv or install Python 3.12."
}
function Resolve-WindowlessPython {
$python = Resolve-Python
$pythonw = Join-Path (Split-Path -Parent $python) "pythonw.exe"
if (Test-Path $pythonw) {
return $pythonw
}
return $python
}
if ($ApiAuth) {
[Environment]::SetEnvironmentVariable("TRADEBOT_API_AUTH", $ApiAuth, "User")
$env:TRADEBOT_API_AUTH = $ApiAuth
}
[Environment]::SetEnvironmentVariable("TRADEBOT_API_BASE_URL", $ApiBaseUrl, "User")
[Environment]::SetEnvironmentVariable("TRADEBOT_TRAINING_WORKER_NAME", $env:COMPUTERNAME, "User")
$env:TRADEBOT_API_BASE_URL = $ApiBaseUrl
$env:TRADEBOT_TRAINING_WORKER_NAME = $env:COMPUTERNAME
if (-not $KeepLegacyRetrainer) {
foreach ($legacyName in @("TradeBot PyTorch Forecaster Retrainer", "TradeBot LSTM Retrainer")) {
$legacyTask = Get-ScheduledTask -TaskName $legacyName -ErrorAction SilentlyContinue
if ($legacyTask) {
Unregister-ScheduledTask -TaskName $legacyName -Confirm:$false
Write-Host "Removed legacy scheduled task '$legacyName'."
}
}
}
$python = Resolve-WindowlessPython
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$arguments = @(
"-u",
"`"$Agent`"",
"--repo-root", "`"$RepoRoot`"",
"--api-base-url", "`"$ApiBaseUrl`"",
"--poll-seconds", $PollSeconds.ToString()
) -join " "
$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $RepoRoot
$trigger = @(
New-ScheduledTaskTrigger -AtLogOn -User $currentUser
New-ScheduledTaskTrigger -AtStartup
New-ScheduledTaskTrigger `
-Once `
-At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
-RepetitionDuration (New-TimeSpan -Days 3650)
)
$principal = New-ScheduledTaskPrincipal `
-UserId $currentUser `
-LogonType Interactive `
-RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-MultipleInstances IgnoreNew `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-RestartCount 999 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit (New-TimeSpan -Days 30)
Register-ScheduledTask `
-TaskName $TaskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Description "Keeps the TradeBot Windows training agent online and polls the public bot API for retrain jobs." `
-Force | Out-Null
if ($StartNow) {
Start-ScheduledTask -TaskName $TaskName
}
Write-Host "Registered scheduled task '$TaskName' for Windows startup, logon, and watchdog restarts."
Write-Host "Agent API: $ApiBaseUrl"
Write-Host "Agent script: $Agent"
+152
View File
@@ -0,0 +1,152 @@
[CmdletBinding()]
param(
[int]$MinReplayTrades = 8,
[int]$MaxAttempts = 0,
[string]$Symbols = "",
[int]$Limit = 3000,
[switch]$DeployToPi,
[string]$PiHost = "192.168.0.185",
[string]$PiUser = "sevenhill",
[string]$PiRoot = "/mnt/data/tradebot",
[string]$PiSshKeyPath = "",
[int]$SeedStart = 0
)
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$RuntimeDir = Join-Path $RepoRoot "runtime"
$LoopLog = Join-Path $RuntimeDir "torch_retrain_until_replay8.log"
$GuardReport = Join-Path $RuntimeDir "torch_retrain_guard.json"
$ActiveCalibration = Join-Path $RuntimeDir "torch_threshold_calibration.json"
$Runner = Join-Path $RepoRoot "tools\run_torch_retrain.ps1"
New-Item -ItemType Directory -Force -Path $RuntimeDir | Out-Null
function Write-LoopLog {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ssK"
"[$timestamp] $Message" | Tee-Object -FilePath $LoopLog -Append
}
function ConvertTo-IntOrZero {
param($Value)
try {
if ($null -eq $Value) {
return 0
}
return [int]$Value
}
catch {
return 0
}
}
function Read-GuardSummary {
if (-not (Test-Path $GuardReport)) {
return [pscustomobject]@{
Accepted = $false
Reason = "guard_report_missing"
CandidateReplayTrades = 0
CurrentReplayTrades = 0
WalkForwardTrades = 0
}
}
try {
$payload = Get-Content -Raw -LiteralPath $GuardReport | ConvertFrom-Json
return [pscustomobject]@{
Accepted = [bool]$payload.accepted
Reason = [string]$payload.reason
CandidateReplayTrades = ConvertTo-IntOrZero $payload.candidate.full_replay.trades
CurrentReplayTrades = ConvertTo-IntOrZero $payload.current.full_replay.trades
WalkForwardTrades = ConvertTo-IntOrZero $payload.candidate.walk_forward_summary.trades
}
}
catch {
return [pscustomobject]@{
Accepted = $false
Reason = "guard_report_unreadable"
CandidateReplayTrades = 0
CurrentReplayTrades = 0
WalkForwardTrades = 0
}
}
}
function Read-ActiveReplayTrades {
if (-not (Test-Path $ActiveCalibration)) {
return 0
}
try {
$payload = Get-Content -Raw -LiteralPath $ActiveCalibration | ConvertFrom-Json
return ConvertTo-IntOrZero $payload.full_replay.trades
}
catch {
return 0
}
}
function Read-ActiveValidationPassed {
if (-not (Test-Path $ActiveCalibration)) {
return $false
}
try {
$payload = Get-Content -Raw -LiteralPath $ActiveCalibration | ConvertFrom-Json
return [bool]$payload.validation.passed
}
catch {
return $false
}
}
$attempt = 0
while ($true) {
$activeReplayTrades = Read-ActiveReplayTrades
if (Read-ActiveValidationPassed) {
Write-LoopLog "Stop condition reached: active calibration passed honest validation with full_replay.trades=$activeReplayTrades."
exit 0
}
$attempt += 1
if ($SeedStart -gt 0) {
$attemptSeed = $SeedStart + $attempt - 1
}
else {
$attemptSeed = Get-Random -Minimum 1 -Maximum 2147483647
}
Write-LoopLog "Attempt $attempt started; seed=$attemptSeed; target full_replay.trades >= $MinReplayTrades."
$runnerArgs = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", $Runner,
"-Limit", $Limit.ToString(),
"-Seed", $attemptSeed.ToString()
)
if ($Symbols) {
$runnerArgs += @("-Symbols", $Symbols)
}
if ($DeployToPi) {
$runnerArgs += "-DeployToPi"
if ($PiHost) { $runnerArgs += @("-PiHost", $PiHost) }
if ($PiUser) { $runnerArgs += @("-PiUser", $PiUser) }
if ($PiRoot) { $runnerArgs += @("-PiRoot", $PiRoot) }
if ($PiSshKeyPath) { $runnerArgs += @("-PiSshKeyPath", $PiSshKeyPath) }
}
& powershell.exe @runnerArgs 2>&1 | Tee-Object -FilePath $LoopLog -Append
$runnerExit = $LASTEXITCODE
$summary = Read-GuardSummary
Write-LoopLog "Attempt $attempt finished; runner_exit=$runnerExit accepted=$($summary.Accepted) reason=$($summary.Reason) candidate_full_replay.trades=$($summary.CandidateReplayTrades) current_full_replay.trades=$($summary.CurrentReplayTrades) walk_forward.trades=$($summary.WalkForwardTrades)."
if ($summary.Accepted -and (Read-ActiveValidationPassed)) {
Write-LoopLog "Stop condition reached: accepted candidate passed honest validation with full_replay.trades=$($summary.CandidateReplayTrades)."
exit 0
}
if ($MaxAttempts -gt 0 -and $attempt -ge $MaxAttempts) {
Write-LoopLog "MaxAttempts=$MaxAttempts reached before replay target."
exit 2
}
Start-Sleep -Seconds 10
}
+126 -5
View File
@@ -11,10 +11,18 @@ param(
[string]$Horizons = "", [string]$Horizons = "",
[string]$Features = "", [string]$Features = "",
[string]$ContextSymbols = "", [string]$ContextSymbols = "",
[int]$Seed = 0,
[int]$Epochs = 0, [int]$Epochs = 0,
[int]$Patience = 0, [int]$Patience = 0,
[string]$Interval = "", [string]$Interval = "",
[string]$EnvFile = "" [string]$EnvFile = "",
[switch]$DeployToPi,
[string]$PiHost = "",
[string]$PiUser = "",
[string]$PiRoot = "",
[string]$PiSshKeyPath = "",
[switch]$NoPiRestart,
[switch]$SkipGuard
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -51,6 +59,51 @@ function Resolve-Python {
throw "Python was not found. Create .venv or install Python 3.12." throw "Python was not found. Create .venv or install Python 3.12."
} }
function Test-TorchArtifactFile {
param([string]$Path)
if (-not (Test-Path $Path)) {
return $false
}
try {
$payload = Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json
return $payload.type -eq "pytorch_recurrent_forecaster" -and $null -ne $payload.symbols
}
catch {
return $false
}
}
function Sync-AcceptedArtifactsToPi {
if (-not ($DeployToPi -or $env:TORCH_RETRAIN_DEPLOY_TO_PI)) {
Write-RetrainLog "Pi artifact sync disabled."
return
}
$syncScript = Join-Path $RepoRoot "tools\sync_torch_artifacts_to_pi.ps1"
if (-not (Test-Path $syncScript)) {
throw "Pi sync script not found: $syncScript"
}
$syncArgs = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", $syncScript,
"-RepoRoot", $RepoRoot
)
if ($PiHost) { $syncArgs += @("-RemoteHost", $PiHost) }
if ($PiUser) { $syncArgs += @("-RemoteUser", $PiUser) }
if ($PiRoot) { $syncArgs += @("-RemoteRoot", $PiRoot) }
if ($PiSshKeyPath) { $syncArgs += @("-SshKeyPath", $PiSshKeyPath) }
if ($NoPiRestart) { $syncArgs += "-NoRestart" }
Write-RetrainLog "Syncing accepted Torch artifacts to Raspberry Pi."
& powershell.exe @syncArgs 2>&1 | Tee-Object -FilePath $LogFile -Append
if ($LASTEXITCODE -ne 0) {
throw "Pi artifact sync failed with exit code $LASTEXITCODE."
}
Write-RetrainLog "Pi artifact sync completed."
}
if (-not $Symbols -and $env:TORCH_RETRAIN_SYMBOLS) { $Symbols = $env:TORCH_RETRAIN_SYMBOLS } if (-not $Symbols -and $env:TORCH_RETRAIN_SYMBOLS) { $Symbols = $env:TORCH_RETRAIN_SYMBOLS }
if ($Limit -le 0) { if ($Limit -le 0) {
$Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 3000 } $Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 3000 }
@@ -64,12 +117,20 @@ if ($Horizon -le 0 -and $env:TORCH_RETRAIN_HORIZON) { $Horizon = [int]$env:TORCH
if (-not $Horizons -and $env:TORCH_RETRAIN_HORIZONS) { $Horizons = $env:TORCH_RETRAIN_HORIZONS } if (-not $Horizons -and $env:TORCH_RETRAIN_HORIZONS) { $Horizons = $env:TORCH_RETRAIN_HORIZONS }
if (-not $Features -and $env:TORCH_RETRAIN_FEATURES) { $Features = $env:TORCH_RETRAIN_FEATURES } if (-not $Features -and $env:TORCH_RETRAIN_FEATURES) { $Features = $env:TORCH_RETRAIN_FEATURES }
if (-not $ContextSymbols -and $env:TORCH_RETRAIN_CONTEXT_SYMBOLS) { $ContextSymbols = $env:TORCH_RETRAIN_CONTEXT_SYMBOLS } if (-not $ContextSymbols -and $env:TORCH_RETRAIN_CONTEXT_SYMBOLS) { $ContextSymbols = $env:TORCH_RETRAIN_CONTEXT_SYMBOLS }
if ($Seed -le 0 -and $env:TORCH_RETRAIN_SEED) { $Seed = [int]$env:TORCH_RETRAIN_SEED }
if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 70 } } if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 70 } }
if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 8 } } if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 8 } }
if (-not $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL } if (-not $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL }
if (-not $EnvFile -and $env:TORCH_RETRAIN_ENV) { $EnvFile = $env:TORCH_RETRAIN_ENV } if (-not $EnvFile -and $env:TORCH_RETRAIN_ENV) { $EnvFile = $env:TORCH_RETRAIN_ENV }
if (-not $EnvFile -and (Test-Path (Join-Path $RepoRoot ".env"))) { $EnvFile = Join-Path $RepoRoot ".env" } if (-not $EnvFile -and (Test-Path (Join-Path $RepoRoot ".env"))) { $EnvFile = Join-Path $RepoRoot ".env" }
$ModelFile = if ($env:TIME_SERIES_LSTM_MODEL_PATH) { $env:TIME_SERIES_LSTM_MODEL_PATH } else { Join-Path $RuntimeDir "lstm_forecaster.json" }
if (-not [System.IO.Path]::IsPathRooted($ModelFile)) { $ModelFile = Join-Path $RepoRoot $ModelFile }
$CandidateFile = Join-Path $RuntimeDir "lstm_forecaster.candidate.json"
$CurrentCalibration = Join-Path $RuntimeDir "torch_guard_current.json"
$CandidateCalibration = Join-Path $RuntimeDir "torch_guard_candidate.json"
$GuardReport = Join-Path $RuntimeDir "torch_retrain_guard.json"
$mutex = New-Object System.Threading.Mutex($false, "TradeBotTorchRecurrentRetrainer") $mutex = New-Object System.Threading.Mutex($false, "TradeBotTorchRecurrentRetrainer")
$hasLock = $false $hasLock = $false
$pushedLocation = $false $pushedLocation = $false
@@ -92,7 +153,8 @@ try {
"--layers", $Layers, "--layers", $Layers,
"--dropouts", $Dropouts, "--dropouts", $Dropouts,
"--epochs", $Epochs.ToString(), "--epochs", $Epochs.ToString(),
"--patience", $Patience.ToString() "--patience", $Patience.ToString(),
"--output", $CandidateFile
) )
if ($Symbols) { $trainerArgs += @("--symbols", $Symbols) } if ($Symbols) { $trainerArgs += @("--symbols", $Symbols) }
if ($Interval) { $trainerArgs += @("--interval", $Interval) } if ($Interval) { $trainerArgs += @("--interval", $Interval) }
@@ -101,15 +163,74 @@ try {
if ($Horizons) { $trainerArgs += @("--horizons", $Horizons) } if ($Horizons) { $trainerArgs += @("--horizons", $Horizons) }
if ($Features) { $trainerArgs += @("--features", $Features) } if ($Features) { $trainerArgs += @("--features", $Features) }
if ($ContextSymbols) { $trainerArgs += @("--context-symbols", $ContextSymbols) } if ($ContextSymbols) { $trainerArgs += @("--context-symbols", $ContextSymbols) }
if ($Seed -gt 0) { $trainerArgs += @("--seed", $Seed.ToString()) }
Push-Location $RepoRoot Push-Location $RepoRoot
$pushedLocation = $true $pushedLocation = $true
Write-RetrainLog "Starting PyTorch recurrent retrain: $python $($trainerArgs -join ' ')" Write-RetrainLog "Starting PyTorch recurrent retrain: $python $($trainerArgs -join ' ')"
& $python @trainerArgs 2>&1 | Tee-Object -FilePath $LogFile -Append & $python @trainerArgs 2>&1 | Tee-Object -FilePath $LogFile -Append
if ($LASTEXITCODE -ne 0) { $trainerExitCode = $LASTEXITCODE
throw "Trainer failed with exit code $LASTEXITCODE." if ($trainerExitCode -ne 0) {
if (Test-TorchArtifactFile $CandidateFile) {
Write-RetrainLog "WARNING: Trainer exited with code $trainerExitCode after writing a valid candidate artifact; continuing to guard."
}
else {
throw "Trainer failed with exit code $trainerExitCode."
}
} }
Write-RetrainLog "Finished PyTorch recurrent retrain." Write-RetrainLog "Finished PyTorch recurrent retrain candidate: $CandidateFile"
if ($SkipGuard -or -not (Test-Path $ModelFile)) {
Move-Item -Force -LiteralPath $CandidateFile -Destination $ModelFile
Write-RetrainLog "Accepted candidate without guard. Active artifact: $ModelFile"
Sync-AcceptedArtifactsToPi
exit 0
}
$calibrationBaseArgs = @(
"-u",
"tools\calibrate_torch_thresholds.py",
"--limit", "3000",
"--calibration-window", "1200",
"--min-trades", "60",
"--walk-forward-folds", "8",
"--confidence-grid", "0.40"
)
if ($Symbols) { $calibrationBaseArgs += @("--symbols", $Symbols) }
if ($EnvFile) { $calibrationBaseArgs += @("--env", $EnvFile) }
Write-RetrainLog "Calibrating current artifact for guard."
& $python @($calibrationBaseArgs + @("--artifact", $ModelFile, "--output", $CurrentCalibration)) 2>&1 | Tee-Object -FilePath $LogFile -Append
if ($LASTEXITCODE -ne 0) {
throw "Current artifact calibration failed with exit code $LASTEXITCODE."
}
Write-RetrainLog "Calibrating candidate artifact for guard."
& $python @($calibrationBaseArgs + @("--artifact", $CandidateFile, "--output", $CandidateCalibration)) 2>&1 | Tee-Object -FilePath $LogFile -Append
if ($LASTEXITCODE -ne 0) {
throw "Candidate artifact calibration failed with exit code $LASTEXITCODE."
}
Write-RetrainLog "Running retrain guard."
& $python -u "tools\accept_torch_candidate.py" `
--current-report $CurrentCalibration `
--candidate-report $CandidateCalibration `
--candidate-artifact $CandidateFile `
--target-artifact $ModelFile `
--report $GuardReport 2>&1 | Tee-Object -FilePath $LogFile -Append
if ($LASTEXITCODE -eq 2) {
Write-RetrainLog "Candidate rejected by guard; keeping active artifact: $ModelFile"
exit 0
}
if ($LASTEXITCODE -ne 0) {
throw "Retrain guard failed with exit code $LASTEXITCODE."
}
if (Test-Path $CandidateCalibration) {
Copy-Item -Force -LiteralPath $CandidateCalibration -Destination (Join-Path $RuntimeDir "torch_threshold_calibration.json")
Write-RetrainLog "Updated active threshold calibration: $(Join-Path $RuntimeDir "torch_threshold_calibration.json")"
}
Write-RetrainLog "Candidate accepted by guard. Active artifact: $ModelFile"
Sync-AcceptedArtifactsToPi
} }
catch { catch {
Write-RetrainLog "ERROR: $($_.Exception.Message)" Write-RetrainLog "ERROR: $($_.Exception.Message)"
+95
View File
@@ -0,0 +1,95 @@
[CmdletBinding()]
param(
[string]$RepoRoot = "",
[string]$RemoteHost = "",
[string]$RemoteUser = "",
[string]$RemoteRoot = "",
[string]$SshKeyPath = "",
[string]$ServiceName = "tradebot",
[switch]$NoRestart,
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
if (-not $RepoRoot) { $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path }
if (-not $RemoteHost -and $env:TORCH_DEPLOY_PI_HOST) { $RemoteHost = $env:TORCH_DEPLOY_PI_HOST }
if (-not $RemoteUser -and $env:TORCH_DEPLOY_PI_USER) { $RemoteUser = $env:TORCH_DEPLOY_PI_USER }
if (-not $RemoteRoot -and $env:TORCH_DEPLOY_PI_ROOT) { $RemoteRoot = $env:TORCH_DEPLOY_PI_ROOT }
if (-not $SshKeyPath -and $env:TORCH_DEPLOY_PI_SSH_KEY) { $SshKeyPath = $env:TORCH_DEPLOY_PI_SSH_KEY }
if (-not $RemoteHost) { $RemoteHost = "192.168.0.185" }
if (-not $RemoteUser) { $RemoteUser = "sevenhill" }
if (-not $RemoteRoot) { $RemoteRoot = "/mnt/data/tradebot" }
$RuntimeDir = Join-Path $RepoRoot "runtime"
$artifactNames = @(
"lstm_forecaster.json",
"torch_retrain_guard.json",
"torch_threshold_calibration.json"
)
$localFiles = @()
foreach ($name in $artifactNames) {
$path = Join-Path $RuntimeDir $name
if (Test-Path $path) {
$localFiles += (Resolve-Path $path).Path
}
}
if ($localFiles.Count -eq 0) {
throw "No Torch artifacts found in $RuntimeDir."
}
function ConvertTo-RemoteSingleQuoted {
param([string]$Value)
return "'" + ($Value -replace "'", "'\''") + "'"
}
function Invoke-LoggedCommand {
param(
[string]$Exe,
[string[]]$Arguments
)
$rendered = @($Exe) + $Arguments
Write-Host ($rendered -join " ")
if ($DryRun) {
return
}
& $Exe @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Exe failed with exit code $LASTEXITCODE."
}
}
$ssh = (Get-Command "ssh.exe" -ErrorAction SilentlyContinue)
if (-not $ssh) { $ssh = Get-Command "ssh" -ErrorAction Stop }
$scp = (Get-Command "scp.exe" -ErrorAction SilentlyContinue)
if (-not $scp) { $scp = Get-Command "scp" -ErrorAction Stop }
$commonSshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=15")
if ($SshKeyPath) {
$expandedKey = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($SshKeyPath)
$commonSshArgs += @("-i", $expandedKey)
}
$remote = "${RemoteUser}@${RemoteHost}"
$remoteRuntime = "$RemoteRoot/runtime"
$remoteIncoming = "$remoteRuntime/.incoming-torch"
$mkdirCommand = "mkdir -p $(ConvertTo-RemoteSingleQuoted $remoteIncoming) $(ConvertTo-RemoteSingleQuoted $remoteRuntime)"
Invoke-LoggedCommand $ssh.Source (@($commonSshArgs + @($remote, $mkdirCommand)))
$destination = "${remote}:$remoteIncoming/"
Invoke-LoggedCommand $scp.Source (@($commonSshArgs + $localFiles + @($destination)))
$moveParts = @()
foreach ($path in $localFiles) {
$name = Split-Path $path -Leaf
$moveParts += "mv -f $(ConvertTo-RemoteSingleQuoted "$remoteIncoming/$name") $(ConvertTo-RemoteSingleQuoted "$remoteRuntime/$name")"
}
$moveCommand = $moveParts -join " && "
Invoke-LoggedCommand $ssh.Source (@($commonSshArgs + @($remote, $moveCommand)))
if (-not $NoRestart) {
$restartCommand = "cd $(ConvertTo-RemoteSingleQuoted $RemoteRoot) && docker compose restart $(ConvertTo-RemoteSingleQuoted $ServiceName)"
Invoke-LoggedCommand $ssh.Source (@($commonSshArgs + @($remote, $restartCommand)))
}
Write-Host "Synced Torch artifacts to ${remote}:$remoteRuntime"
+21 -5
View File
@@ -118,6 +118,10 @@ def main() -> None:
target_horizons = _horizons(args.horizons, decision_horizon) target_horizons = _horizons(args.horizons, decision_horizon)
feature_names = _feature_names_arg(args.features) feature_names = _feature_names_arg(args.features)
round_trip_cost = max(0.0, 2.0 * (float(settings.taker_fee_rate) + float(settings.slippage_rate))) round_trip_cost = max(0.0, 2.0 * (float(settings.taker_fee_rate) + float(settings.slippage_rate)))
_progress(
f"training started: symbols={len(symbols)} interval={interval} "
f"limit={args.limit} epochs={args.epochs}"
)
artifact: dict[str, Any] = { artifact: dict[str, Any] = {
"version": 4, "version": 4,
@@ -141,7 +145,9 @@ def main() -> None:
"symbols": {}, "symbols": {},
} }
for symbol in symbols: total_symbols = len(symbols)
for index, symbol in enumerate(symbols, start=1):
_progress(f"{symbol}: training started ({index}/{total_symbols})")
result = _train_symbol( result = _train_symbol(
client=client, client=client,
symbol=symbol, symbol=symbol,
@@ -170,10 +176,10 @@ def main() -> None:
seed=args.seed, seed=args.seed,
) )
if result is None: if result is None:
print(f"{symbol}: skipped, not enough candles or train/validation samples") _progress(f"{symbol}: skipped, not enough candles or train/validation samples")
continue continue
artifact["symbols"][symbol] = result artifact["symbols"][symbol] = result
print( _progress(
f"{symbol}: model={result['model']} lookback={result['lookback']} " f"{symbol}: model={result['model']} lookback={result['lookback']} "
f"features={result['input_size']} hidden={result['hidden_size']} " f"features={result['input_size']} hidden={result['hidden_size']} "
f"layers={result['num_layers']} horizons={','.join(map(str, result['target_horizons']))} " f"layers={result['num_layers']} horizons={','.join(map(str, result['target_horizons']))} "
@@ -187,7 +193,11 @@ def main() -> None:
tmp_output = output.with_name(f"{output.name}.tmp") tmp_output = output.with_name(f"{output.name}.tmp")
tmp_output.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") tmp_output.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_output.replace(output) tmp_output.replace(output)
print(f"saved {output}") _progress(f"saved {output}")
def _progress(message: str) -> None:
print(message, flush=True)
def _parse_args() -> argparse.Namespace: def _parse_args() -> argparse.Namespace:
@@ -274,12 +284,13 @@ def _train_symbol(
add_indicators(rows) add_indicators(rows)
market_candles[context_symbol] = rows market_candles[context_symbol] = rows
except Exception as exc: except Exception as exc:
print(f"{symbol}: context {context_symbol} skipped: {exc}") _progress(f"{symbol}: context {context_symbol} skipped: {exc}")
trend_candles = _historical_klines(client, symbol, "D", min(max(260, limit // 24 + 260), 1000)) trend_candles = _historical_klines(client, symbol, "D", min(max(260, limit // 24 + 260), 1000))
add_indicators(trend_candles) add_indicators(trend_candles)
best: dict[str, Any] | None = None best: dict[str, Any] | None = None
for lookback in lookbacks: for lookback in lookbacks:
_progress(f"{symbol}: preparing lookback={lookback}")
prepared = _prepare_data( prepared = _prepare_data(
candles=candles, candles=candles,
feature_names=feature_names, feature_names=feature_names,
@@ -307,6 +318,11 @@ def _train_symbol(
for dropout in dropouts: for dropout in dropouts:
if num_layers <= 1 and dropout != 0.0: if num_layers <= 1 and dropout != 0.0:
continue continue
_progress(
f"{symbol}: fitting {architecture} "
f"lookback={lookback} hidden={hidden_size} "
f"layers={num_layers} dropout={dropout}"
)
candidate = _fit_candidate( candidate = _fit_candidate(
prepared=prepared, prepared=prepared,
architecture=architecture, architecture=architecture,
+421
View File
@@ -0,0 +1,421 @@
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import platform
import queue
import re
import subprocess
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.error import HTTPError
from urllib.error import URLError
from urllib.request import Request
from urllib.request import urlopen
ARTIFACT_NAMES = (
"lstm_forecaster.json",
"torch_retrain_guard.json",
"torch_threshold_calibration.json",
)
def main() -> None:
args = parse_args()
repo_root = Path(args.repo_root).resolve()
runtime_dir = repo_root / "runtime"
runtime_dir.mkdir(parents=True, exist_ok=True)
log_path = Path(args.log_file).resolve() if args.log_file else runtime_dir / "windows_training_agent.log"
log(log_path, f"TradeBot Windows training agent started for {args.api_base_url}")
while True:
try:
poll_once(args, repo_root, runtime_dir, log_path)
except Exception as exc: # noqa: BLE001 - agent must keep running.
log(log_path, f"ERROR: {exc}")
if args.once:
break
time.sleep(max(5, args.poll_seconds))
def poll_once(args: argparse.Namespace, repo_root: Path, runtime_dir: Path, log_path: Path) -> None:
worker = worker_payload(args, repo_root)
api_json(args, "/api/training/heartbeat", worker)
claim = api_json(args, "/api/training/claim", worker)
if not claim.get("claimed"):
return
job = claim.get("job") if isinstance(claim.get("job"), dict) else {}
job_id = str(job.get("id") or "")
if not job_id:
return
log(log_path, f"Claimed retrain job {job_id}")
report_progress(args, job_id, "running", "claimed", 2, "Задание получено Windows-agent")
success = False
message = ""
summary: dict[str, Any] = {}
try:
run_retrain(args, job_id, job, repo_root, log_path)
summary = read_json(runtime_dir / "torch_retrain_guard.json")
report_progress(args, job_id, "running", "uploading", 72, "Обучение завершено, загружаю артефакты")
for name in ARTIFACT_NAMES:
path = runtime_dir / name
if path.is_file():
upload_artifact(args, job_id, path, log_path)
success = True
message = "training completed"
log(log_path, f"Completed retrain job {job_id}")
except Exception as exc: # noqa: BLE001 - report failure to the bot.
message = str(exc)
log(log_path, f"Job {job_id} failed: {message}")
finally:
payload = {"success": success, "message": message, "summary": summary}
api_json(args, f"/api/training/jobs/{job_id}/complete", payload)
def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo_root: Path, log_path: Path) -> None:
script = repo_root / "tools" / "run_torch_retrain.ps1"
if not script.is_file():
raise RuntimeError(f"retrain script not found: {script}")
cmd = [
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script),
]
parameters = job.get("parameters") if isinstance(job.get("parameters"), dict) else {}
arg_map = {
"symbols": "-Symbols",
"limit": "-Limit",
"lookbacks": "-Lookbacks",
"architectures": "-Architectures",
"hidden_sizes": "-HiddenSizes",
"layers": "-Layers",
"dropouts": "-Dropouts",
"epochs": "-Epochs",
}
for key, ps_arg in arg_map.items():
value = parameters.get(key)
if value not in (None, ""):
cmd.extend([ps_arg, str(value)])
log(log_path, "Running retrain: " + " ".join(quote_for_log(part) for part in cmd))
report_progress(args, job_id, "running", "training", 8, "PyTorch retrain запущен")
line_count = 0
output_queue: queue.Queue[str] = queue.Queue()
def read_output() -> None:
assert process.stdout is not None
for raw_line in process.stdout:
output_queue.put(raw_line.rstrip())
with subprocess.Popen(
cmd,
cwd=str(repo_root),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
**hidden_subprocess_kwargs(),
) as process:
reader = threading.Thread(target=read_output, name="training-output-reader", daemon=True)
reader.start()
last_report_at = 0.0
started_at = time.monotonic()
last_output_at = started_at
last_message = "PyTorch retrain выполняется"
while True:
got_line = False
try:
message = output_queue.get(timeout=5)
got_line = True
last_output_at = time.monotonic()
log(log_path, message)
line_count += 1
if message:
last_message = friendly_training_message(message)
except queue.Empty:
pass
progress = min(70, 8 + line_count // 3)
now = time.monotonic()
if got_line or now - last_report_at >= 30:
report_message = last_message
if not got_line:
report_message = training_heartbeat_message(now, started_at, last_output_at, last_message)
safe_report_progress(args, job_id, "running", "training", progress, report_message, log_path)
last_report_at = now
if process.poll() is not None and output_queue.empty():
break
reader.join(timeout=2)
code = process.wait()
if code != 0:
raise RuntimeError(f"retrain failed with exit code {code}")
report_progress(args, job_id, "running", "guard", 70, "Guard завершён, подготавливаю артефакты")
def friendly_training_message(message: str) -> str:
cleaned = message.strip()
if not cleaned:
return "PyTorch обучает модель"
if "Starting PyTorch recurrent retrain:" in cleaned:
return "PyTorch LSTM/GRU запущен: готовлю данные и варианты модели"
started = re.search(
r"training started: symbols=(?P<symbols>\d+) interval=(?P<interval>\d+) "
r"limit=(?P<limit>\d+) epochs=(?P<epochs>\d+)",
cleaned,
)
if started:
interval = started.group("interval")
timeframe = "1h" if interval == "60" else f"{interval}m"
return (
f"Старт обучения: {started.group('symbols')} пар, таймфрейм {timeframe}, "
f"история {started.group('limit')} свечей, до {started.group('epochs')} эпох"
)
pair_started = re.search(r"^(?P<symbol>[A-Z0-9]+): training started \((?P<index>\d+)/(?P<total>\d+)\)", cleaned)
if pair_started:
return (
f"{pair_started.group('symbol')}: обучение пары "
f"{pair_started.group('index')}/{pair_started.group('total')}"
)
preparing = re.search(r"^(?P<symbol>[A-Z0-9]+): preparing lookback=(?P<lookback>\d+)", cleaned)
if preparing:
return f"{preparing.group('symbol')}: готовлю окно {preparing.group('lookback')} свечей"
fitting = re.search(
r"^(?P<symbol>[A-Z0-9]+): fitting (?P<arch>lstm|gru) "
r"lookback=(?P<lookback>\d+) hidden=(?P<hidden>\d+) "
r"layers=(?P<layers>\d+) dropout=(?P<dropout>[0-9.]+)",
cleaned,
)
if fitting:
return (
f"{fitting.group('symbol')}: обучаю {fitting.group('arch').upper()}, "
f"окно {fitting.group('lookback')}, нейронов {fitting.group('hidden')}, "
f"слоёв {fitting.group('layers')}, dropout {fitting.group('dropout')}"
)
model = re.search(
r"^(?P<symbol>[A-Z0-9]+): model=torch_(?P<arch>lstm|gru).*?"
r"mae=(?P<mae>[0-9.]+)%.*?skill=(?P<skill>-?[0-9.]+).*?dir=(?P<direction>[0-9.]+)",
cleaned,
)
if model:
direction = float(model.group("direction")) * 100
skill = float(model.group("skill")) * 100
return (
f"{model.group('symbol')}: выбран {model.group('arch').upper()}, "
f"ошибка {model.group('mae')}%, skill {skill:.1f}%, направление {direction:.1f}%"
)
if "Calibrating current artifact" in cleaned:
return "Проверяю текущую модель на replay"
if "Calibrating candidate artifact" in cleaned:
return "Проверяю новую модель на replay"
if "Running retrain guard" in cleaned:
return "Gate сравнивает новую модель с текущей"
if "Candidate rejected by guard" in cleaned:
return "Новая модель обучилась, но gate не дал ей ходу"
if "Candidate accepted by guard" in cleaned:
return "Новая модель прошла gate и стала активной"
return cleaned[-220:]
def training_heartbeat_message(now: float, started_at: float, last_output_at: float, last_message: str) -> str:
elapsed = format_duration(now - started_at)
idle_seconds = max(0.0, now - last_output_at)
if idle_seconds >= 45:
return (
f"PyTorch обучает модель: процесс активен {elapsed}; "
f"последний лог {format_duration(idle_seconds)} назад: {last_message[:140]}"
)
return last_message or f"PyTorch обучает модель: процесс активен {elapsed}"
def format_duration(seconds: float) -> str:
total_seconds = max(0, int(seconds))
minutes, seconds_part = divmod(total_seconds, 60)
hours, minutes_part = divmod(minutes, 60)
if hours:
return f"{hours}ч {minutes_part}м"
if minutes:
return f"{minutes}м {seconds_part}с"
return f"{seconds_part}с"
def upload_artifact(args: argparse.Namespace, job_id: str, path: Path, log_path: Path) -> None:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
size = path.stat().st_size
chunk_size = max(64 * 1024, args.chunk_size)
total = max(1, (size + chunk_size - 1) // chunk_size)
log(log_path, f"Uploading {path.name}: {size} bytes, {total} chunks")
with path.open("rb") as source:
for index in range(total):
data = source.read(chunk_size)
payload = {
"name": path.name,
"index": index,
"total": total,
"sha256": digest,
"data_base64": base64.b64encode(data).decode("ascii"),
}
api_json(args, f"/api/training/jobs/{job_id}/artifacts/chunk", payload, timeout=120)
if index == 0 or index == total - 1 or index % 10 == 0:
progress = 72 + int(((index + 1) / total) * 23)
report_progress(args, job_id, "running", "uploading", progress, f"Загружаю {path.name}: {index + 1}/{total}")
def report_progress(
args: argparse.Namespace,
job_id: str,
status: str,
phase: str,
progress_percent: int,
message: str,
) -> None:
api_json(
args,
f"/api/training/jobs/{job_id}/progress",
{
"status": status,
"phase": phase,
"progress_percent": progress_percent,
"message": message,
"worker": worker_payload(args, Path(args.repo_root).resolve()),
},
)
def safe_report_progress(
args: argparse.Namespace,
job_id: str,
status: str,
phase: str,
progress_percent: int,
message: str,
log_path: Path,
) -> None:
last_error: Exception | None = None
for attempt in range(1, 4):
try:
report_progress(args, job_id, status, phase, progress_percent, message)
return
except Exception as exc: # noqa: BLE001 - keep the local training process alive.
last_error = exc
if attempt < 3:
time.sleep(attempt * 2)
log(log_path, f"Temporary progress upload error; training continues: {last_error}")
def api_json(args: argparse.Namespace, path: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
url = args.api_base_url.rstrip("/") + path
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
token = args.api_auth or os.environ.get("TRADEBOT_API_AUTH", "")
headers.update(auth_headers(token))
request = Request(url, data=body, headers=headers, method="POST")
try:
with urlopen(request, timeout=timeout) as response:
text = response.read().decode("utf-8")
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code} {path}: {detail[:300]}") from exc
except URLError as exc:
raise RuntimeError(f"network error {path}: {exc.reason}") from exc
return json.loads(text) if text.strip() else {}
def auth_headers(token: str) -> dict[str, str]:
value = token.strip()
if not value:
return {}
headers = {"X-TradeBot-Token": value}
if value.lower().startswith(("basic ", "bearer ")):
headers["Authorization"] = value
elif ":" in value:
encoded = base64.b64encode(value.encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {encoded}"
else:
headers["Authorization"] = f"Bearer {value}"
return headers
def worker_payload(args: argparse.Namespace, repo_root: Path) -> dict[str, Any]:
name = args.worker_name or platform.node() or "Windows training host"
return {
"worker_id": args.worker_id or f"{name}:{repo_root}",
"name": name,
"path": str(repo_root),
"version": "1",
}
def log(path: Path, message: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().astimezone().isoformat(timespec="seconds")
line = f"[{stamp}] {message}"
if sys.stdout is not None:
try:
print(line, flush=True)
except OSError:
pass
with path.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
def read_json(path: Path) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def hidden_subprocess_kwargs() -> dict[str, Any]:
if os.name != "nt":
return {}
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0
return {
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
"startupinfo": startupinfo,
}
def quote_for_log(value: str) -> str:
return f'"{value}"' if " " in value else value
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Poll TradeBot for retrain jobs and execute them on Windows.")
parser.add_argument("--api-base-url", default=os.environ.get("TRADEBOT_API_BASE_URL", "https://tb.kusoft.xyz"))
parser.add_argument("--api-auth", default=os.environ.get("TRADEBOT_API_AUTH", ""))
parser.add_argument("--repo-root", default=str(Path(__file__).resolve().parents[1]))
parser.add_argument("--worker-id", default=os.environ.get("TRADEBOT_TRAINING_WORKER_ID", ""))
parser.add_argument("--worker-name", default=os.environ.get("TRADEBOT_TRAINING_WORKER_NAME", ""))
parser.add_argument("--poll-seconds", type=int, default=int(os.environ.get("TRADEBOT_TRAINING_POLL_SECONDS", "60")))
parser.add_argument("--chunk-size", type=int, default=int(os.environ.get("TRADEBOT_TRAINING_CHUNK_SIZE", str(512 * 1024))))
parser.add_argument("--log-file", default=os.environ.get("TRADEBOT_TRAINING_LOG", ""))
parser.add_argument("--once", action="store_true")
return parser.parse_args()
if __name__ == "__main__":
main()