Compare commits
39 Commits
a548c0e890
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f1ac694e4 | |||
| 33c3831bf2 | |||
| f0c70e2e1f | |||
| 4a4039c03d | |||
| 2e2978d7d9 | |||
| 6c92c6e955 | |||
| 56701d02f1 | |||
| 0cfd89980e | |||
| 9f47c04235 | |||
| fe276d95ff | |||
| 710312ba8a | |||
| 4bc8a4c4a0 | |||
| d58e20aa4d | |||
| 57d9514eec | |||
| 7d84bf38cc | |||
| 07d132632d | |||
| baf307041a | |||
| 83bffadc0a | |||
| b6d616ec2a | |||
| f8611a1c3f | |||
| 98d6277d65 | |||
| f1f1cbb1e0 | |||
| ab2557ea26 | |||
| 4e811daaf2 | |||
| 9c983ac0bb | |||
| 2706242fc7 | |||
| 93c51070c5 | |||
| 61a063cda7 | |||
| 87cb7e8fe3 | |||
| 27205af73e | |||
| 4a24419a30 | |||
| bb3b4070f6 | |||
| cb8efb7cd7 | |||
| 15a50fb575 | |||
| a6c94b1555 | |||
| 4112a17bc5 | |||
| 4d02ff3806 | |||
| 13de641fe3 | |||
| 12f470e0a3 |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ venv/
|
|||||||
.env
|
.env
|
||||||
runtime/
|
runtime/
|
||||||
*.log
|
*.log
|
||||||
|
android/**/.gradle/
|
||||||
|
android/**/build/
|
||||||
|
android/**/*.apk
|
||||||
|
android/**/*.aab
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+230
@@ -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()
|
||||||
|
}
|
||||||
+1357
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
+63
@@ -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>
|
||||||
@@ -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")
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|||||||
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
|
||||||
"""
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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": {},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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]],
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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
+1162397
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
|
||||||
Binary file not shown.
+16
-1
@@ -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,
|
||||||
|
|||||||
@@ -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"])
|
||||||
@@ -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
@@ -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 == ()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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 `
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
@@ -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)"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user