Compare commits
32 Commits
cb8efb7cd7
..
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 |
@@ -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,36 +25,37 @@ 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=100
|
||||||
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_GUARD_ENABLED=true
|
||||||
|
RISK_SYMBOL_GUARD_ENABLED=false
|
||||||
RISK_RECENT_TRADE_WINDOW=20
|
RISK_RECENT_TRADE_WINDOW=20
|
||||||
RISK_MAX_CONSECUTIVE_LOSSES=4
|
RISK_MAX_CONSECUTIVE_LOSSES=4
|
||||||
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
|
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
|
||||||
RISK_REDUCE_MULTIPLIER=0.50
|
RISK_REDUCE_MULTIPLIER=1.0
|
||||||
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
|
||||||
@@ -62,7 +63,7 @@ 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.10
|
TIME_SERIES_MIN_EDGE_PERCENT=0.10
|
||||||
TIME_SERIES_MIN_PROBABILITY_UP=0.52
|
TIME_SERIES_MIN_PROBABILITY_UP=0.47
|
||||||
TIME_SERIES_MIN_CONFIDENCE=0.4
|
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
|
||||||
@@ -71,7 +72,9 @@ TIME_SERIES_PROBE_ENABLED=true
|
|||||||
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
|
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
|
||||||
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
|
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
|
||||||
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
|
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
|
||||||
|
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
|
||||||
STOP_LOSS_PERCENT=0.04
|
STOP_LOSS_PERCENT=0.04
|
||||||
|
STOP_LOSS_EXIT_ENABLED=false
|
||||||
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
|
||||||
|
|||||||
+14
-13
@@ -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,28 +25,28 @@ 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
|
||||||
@@ -61,8 +61,8 @@ 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.08
|
TIME_SERIES_MIN_EDGE_PERCENT=0.10
|
||||||
TIME_SERIES_MIN_PROBABILITY_UP=0.58
|
TIME_SERIES_MIN_PROBABILITY_UP=0.47
|
||||||
TIME_SERIES_MIN_CONFIDENCE=0.4
|
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
|
||||||
@@ -71,6 +71,7 @@ TIME_SERIES_PROBE_ENABLED=true
|
|||||||
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
|
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
|
||||||
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
|
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
|
||||||
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
|
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,15 @@ 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_SEED`, `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:
|
Если retrain запускается с `-DeployToPi`, после успешного guard он синхронизирует `runtime/lstm_forecaster.json`, `runtime/torch_retrain_guard.json` и `runtime/torch_threshold_calibration.json` на Raspberry Pi через SSH-ключ и перезапускает сервис `tradebot`. Отдельный запуск sync:
|
||||||
|
|
||||||
@@ -116,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
|
||||||
@@ -129,32 +138,33 @@ 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_GUARD_ENABLED=true
|
||||||
|
RISK_SYMBOL_GUARD_ENABLED=false
|
||||||
RISK_RECENT_TRADE_WINDOW=20
|
RISK_RECENT_TRADE_WINDOW=20
|
||||||
RISK_MAX_CONSECUTIVE_LOSSES=4
|
RISK_MAX_CONSECUTIVE_LOSSES=4
|
||||||
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
|
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
|
||||||
@@ -165,8 +175,8 @@ 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.08
|
TIME_SERIES_MIN_EDGE_PERCENT=0.10
|
||||||
TIME_SERIES_MIN_PROBABILITY_UP=0.58
|
TIME_SERIES_MIN_PROBABILITY_UP=0.47
|
||||||
TIME_SERIES_MIN_CONFIDENCE=0.4
|
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
|
||||||
@@ -175,6 +185,7 @@ TIME_SERIES_PROBE_ENABLED=true
|
|||||||
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
|
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
|
||||||
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
|
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
|
||||||
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
|
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")
|
||||||
@@ -112,6 +112,9 @@ def risk_guard_snapshot(
|
|||||||
"enabled": False,
|
"enabled": False,
|
||||||
"block_new_entries": False,
|
"block_new_entries": False,
|
||||||
"position_size_multiplier": 1.0,
|
"position_size_multiplier": 1.0,
|
||||||
|
"symbol_guard_enabled": settings.risk_symbol_guard_enabled,
|
||||||
|
"blocked_symbols": [],
|
||||||
|
"symbols": [],
|
||||||
"reasons": [],
|
"reasons": [],
|
||||||
}
|
}
|
||||||
active_trades = _active_universe_trades(settings, closed_trades)
|
active_trades = _active_universe_trades(settings, closed_trades)
|
||||||
@@ -145,6 +148,7 @@ def risk_guard_snapshot(
|
|||||||
"reasons": all_reasons,
|
"reasons": all_reasons,
|
||||||
"global_reasons": reasons,
|
"global_reasons": reasons,
|
||||||
"degraded_reasons": degraded_reasons,
|
"degraded_reasons": degraded_reasons,
|
||||||
|
"symbol_guard_enabled": settings.risk_symbol_guard_enabled,
|
||||||
"blocked_symbols": blocked_symbols,
|
"blocked_symbols": blocked_symbols,
|
||||||
"symbols": symbol_stats,
|
"symbols": symbol_stats,
|
||||||
"consecutive_losses": consecutive_losses,
|
"consecutive_losses": consecutive_losses,
|
||||||
@@ -202,6 +206,7 @@ def _active_universe_trades(settings: Settings, trades: list[dict[str, Any]]) ->
|
|||||||
def _symbol_guard_stats(settings: Settings, trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
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))
|
expectancy_min_samples = max(6, min(settings.risk_recent_trade_window, 10))
|
||||||
loss_streak_min_samples = max(3, settings.risk_max_consecutive_losses)
|
loss_streak_min_samples = max(3, settings.risk_max_consecutive_losses)
|
||||||
|
symbol_guard_enabled = settings.risk_symbol_guard_enabled
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for symbol in settings.symbols:
|
for symbol in settings.symbols:
|
||||||
symbol_trades = [trade for trade in trades if str(trade.get("symbol", "")).upper() == symbol.upper()]
|
symbol_trades = [trade for trade in trades if str(trade.get("symbol", "")).upper() == symbol.upper()]
|
||||||
@@ -209,17 +214,19 @@ def _symbol_guard_stats(settings: Settings, trades: list[dict[str, Any]]) -> lis
|
|||||||
stats = _trade_stats(recent)
|
stats = _trade_stats(recent)
|
||||||
losses = _consecutive_losses(recent)
|
losses = _consecutive_losses(recent)
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
if stats["trades"] >= expectancy_min_samples:
|
if symbol_guard_enabled:
|
||||||
if stats["profit_factor"] < settings.risk_min_recent_profit_factor and stats["avg_net_percent"] <= 0:
|
if stats["trades"] >= expectancy_min_samples:
|
||||||
reasons.append("symbol_expectancy_negative")
|
if stats["profit_factor"] < settings.risk_min_recent_profit_factor and stats["avg_net_percent"] <= 0:
|
||||||
if stats["trades"] >= loss_streak_min_samples:
|
reasons.append("symbol_expectancy_negative")
|
||||||
if losses >= settings.risk_max_consecutive_losses:
|
if stats["trades"] >= loss_streak_min_samples:
|
||||||
reasons.append("symbol_consecutive_losses")
|
if losses >= settings.risk_max_consecutive_losses:
|
||||||
|
reasons.append("symbol_consecutive_losses")
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"symbol": symbol.upper(),
|
"symbol": symbol.upper(),
|
||||||
"block_new_entries": bool(reasons),
|
"block_new_entries": bool(reasons) if symbol_guard_enabled else False,
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
|
"symbol_guard_enabled": symbol_guard_enabled,
|
||||||
"consecutive_losses": losses,
|
"consecutive_losses": losses,
|
||||||
**stats,
|
**stats,
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-8
@@ -140,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},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -150,12 +150,17 @@ 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["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"):
|
if risk_guard.get("block_new_entries"):
|
||||||
self.storage.insert_signal(
|
self.storage.insert_signal(
|
||||||
Signal(
|
Signal(
|
||||||
@@ -221,12 +226,14 @@ 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
|
@staticmethod
|
||||||
def _risk_guard_for_symbol(risk_guard: dict, symbol: str) -> dict:
|
def _risk_guard_for_symbol(risk_guard: dict, symbol: str) -> dict:
|
||||||
@@ -292,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] = {}
|
||||||
@@ -346,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()
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +115,7 @@ class Settings:
|
|||||||
kelly_max_fraction: float
|
kelly_max_fraction: float
|
||||||
risk_per_trade_percent: float
|
risk_per_trade_percent: float
|
||||||
risk_guard_enabled: bool
|
risk_guard_enabled: bool
|
||||||
|
risk_symbol_guard_enabled: bool
|
||||||
risk_recent_trade_window: int
|
risk_recent_trade_window: int
|
||||||
risk_max_consecutive_losses: int
|
risk_max_consecutive_losses: int
|
||||||
risk_min_recent_profit_factor: float
|
risk_min_recent_profit_factor: float
|
||||||
@@ -122,10 +136,13 @@ class Settings:
|
|||||||
time_series_probe_min_edge_percent: float
|
time_series_probe_min_edge_percent: float
|
||||||
time_series_probe_min_probability_up: float
|
time_series_probe_min_probability_up: float
|
||||||
time_series_probe_size_multiplier: 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
|
||||||
@@ -190,11 +207,8 @@ 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)
|
min_signal_confidence = _float_env("MIN_SIGNAL_CONFIDENCE", 0.64)
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
@@ -254,6 +268,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
|||||||
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_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_recent_trade_window=_int_env("RISK_RECENT_TRADE_WINDOW", 20),
|
||||||
risk_max_consecutive_losses=_int_env("RISK_MAX_CONSECUTIVE_LOSSES", 4),
|
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_min_recent_profit_factor=_float_env("RISK_MIN_RECENT_PROFIT_FACTOR", 0.85),
|
||||||
@@ -274,10 +289,13 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
|||||||
time_series_probe_min_edge_percent=_float_env("TIME_SERIES_PROBE_MIN_EDGE_PERCENT", 0.02),
|
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_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_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),
|
||||||
|
|||||||
+60
-686
@@ -4,8 +4,8 @@ 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.analytics import analytics_snapshot
|
||||||
from crypto_spot_bot.bot import CryptoSpotBot
|
from crypto_spot_bot.bot import CryptoSpotBot
|
||||||
@@ -19,6 +19,10 @@ 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:
|
||||||
@@ -39,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):
|
||||||
@@ -53,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]:
|
||||||
@@ -78,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]:
|
||||||
@@ -111,7 +122,46 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/api/retrain")
|
@app.get("/api/retrain")
|
||||||
async def retrain() -> dict[str, Any]:
|
async def retrain() -> dict[str, Any]:
|
||||||
return _runtime_json(settings, "torch_retrain_guard.json")
|
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]:
|
||||||
@@ -245,6 +295,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
|||||||
"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_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_recent_trade_window": settings.risk_recent_trade_window,
|
||||||
"risk_max_consecutive_losses": settings.risk_max_consecutive_losses,
|
"risk_max_consecutive_losses": settings.risk_max_consecutive_losses,
|
||||||
"risk_min_recent_profit_factor": settings.risk_min_recent_profit_factor,
|
"risk_min_recent_profit_factor": settings.risk_min_recent_profit_factor,
|
||||||
@@ -265,11 +316,14 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
|||||||
"time_series_probe_min_edge_percent": settings.time_series_probe_min_edge_percent,
|
"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_min_probability_up": settings.time_series_probe_min_probability_up,
|
||||||
"time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier,
|
"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,
|
||||||
@@ -389,683 +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>TradeBot Control</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #f4f6f8;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface-2: #eef2f5;
|
|
||||||
--line: #d7dee6;
|
|
||||||
--text: #17202a;
|
|
||||||
--muted: #607080;
|
|
||||||
--strong: #0f766e;
|
|
||||||
--blue: #2563eb;
|
|
||||||
--amber: #b7791f;
|
|
||||||
--red: #c24141;
|
|
||||||
--green: #138a55;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
header {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 20;
|
|
||||||
background: rgba(244, 246, 248, 0.96);
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
.topbar {
|
|
||||||
max-width: 1440px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 14px 18px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(180px, 1fr) auto;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
h1 { margin: 0; font-size: 20px; line-height: 1.2; }
|
|
||||||
.sub { color: var(--muted); font-size: 13px; margin-top: 3px; }
|
|
||||||
.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
|
||||||
button, .toggle {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 650;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
button.primary { background: var(--strong); color: white; border-color: var(--strong); }
|
|
||||||
button.danger { color: var(--red); }
|
|
||||||
.toggle { display: inline-flex; align-items: center; gap: 8px; }
|
|
||||||
.toggle input { margin: 0; }
|
|
||||||
nav {
|
|
||||||
max-width: 1440px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 18px 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.tab {
|
|
||||||
min-width: max-content;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.tab.active { background: var(--surface); color: var(--text); border-color: var(--line); }
|
|
||||||
main {
|
|
||||||
max-width: 1440px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
.grid { display: grid; gap: 14px; }
|
|
||||||
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
||||||
.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
||||||
section.panel {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.panel-head {
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.panel-head h2 { margin: 0; font-size: 15px; }
|
|
||||||
.panel-body { padding: 14px; }
|
|
||||||
.metric {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
min-height: 82px;
|
|
||||||
}
|
|
||||||
.metric .label { color: var(--muted); font-size: 12px; }
|
|
||||||
.metric .value { font-size: 22px; font-weight: 750; margin-top: 6px; overflow-wrap: anywhere; }
|
|
||||||
.metric .note { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
||||||
th, td { padding: 9px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
|
||||||
th { color: var(--muted); font-weight: 700; background: var(--surface-2); }
|
|
||||||
tr:last-child td { border-bottom: 0; }
|
|
||||||
.mono { font-variant-numeric: tabular-nums; }
|
|
||||||
.positive { color: var(--green); font-weight: 700; }
|
|
||||||
.negative { color: var(--red); font-weight: 700; }
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
.pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 24px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.pill.ok { color: var(--green); border-color: rgba(19, 138, 85, .35); background: #edf8f2; }
|
|
||||||
.pill.warn { color: var(--amber); border-color: rgba(183, 121, 31, .35); background: #fff7e6; }
|
|
||||||
.pill.error { color: var(--red); border-color: rgba(194, 65, 65, .35); background: #fff0f0; }
|
|
||||||
.stack { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
||||||
.kv { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 8px; font-size: 13px; padding: 6px 0; border-bottom: 1px solid var(--line); }
|
|
||||||
.kv:last-child { border-bottom: 0; }
|
|
||||||
.kv span:first-child { color: var(--muted); }
|
|
||||||
.feature-list { display: grid; gap: 6px; }
|
|
||||||
.feature {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(130px, 1fr) 90px 90px;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 7px 8px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fbfcfd;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.hidden { display: none; }
|
|
||||||
.empty { color: var(--muted); padding: 18px; text-align: center; }
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.topbar { grid-template-columns: 1fr; }
|
|
||||||
.actions { justify-content: flex-start; }
|
|
||||||
.cols-2, .cols-3, .cols-4 { grid-template-columns: 1fr; }
|
|
||||||
.kv { grid-template-columns: 1fr; }
|
|
||||||
.feature { grid-template-columns: 1fr 80px 80px; }
|
|
||||||
th, td { padding: 8px 6px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<div class="topbar">
|
|
||||||
<div>
|
|
||||||
<h1>TradeBot</h1>
|
|
||||||
<div class="sub" id="headline">Загрузка</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<span id="healthPill" class="pill">health</span>
|
|
||||||
<label class="toggle"><input id="fastToggle" type="checkbox" /> Fast</label>
|
|
||||||
<button id="startBtn" class="primary">Start</button>
|
|
||||||
<button id="stopBtn" class="danger">Stop</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav id="tabs"></nav>
|
|
||||||
</header>
|
|
||||||
<main id="app"></main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const TABS = [
|
|
||||||
['overview', 'Overview'],
|
|
||||||
['markets', 'Markets / Torch'],
|
|
||||||
['risk', 'Risk / Quality'],
|
|
||||||
['pnl', 'PnL'],
|
|
||||||
['backtest', 'Backtest / Retrain'],
|
|
||||||
['logs', 'Logs']
|
|
||||||
];
|
|
||||||
const state = { tab: 'overview', data: {} };
|
|
||||||
|
|
||||||
function initTabs() {
|
|
||||||
const nav = document.getElementById('tabs');
|
|
||||||
nav.innerHTML = TABS.map(([id, label]) => `<button class="tab ${id === state.tab ? 'active' : ''}" data-tab="${id}">${label}</button>`).join('');
|
|
||||||
nav.querySelectorAll('.tab').forEach(button => {
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
state.tab = button.dataset.tab;
|
|
||||||
initTabs();
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson(url, options) {
|
|
||||||
const response = await fetch(url, options);
|
|
||||||
if (!response.ok) throw new Error(`${url}: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
const [health, status, markets, trades, signals, events, config, analytics, reconciliation, backtest, retrain] = await Promise.all([
|
|
||||||
fetchJson('/api/health'),
|
|
||||||
fetchJson('/api/status'),
|
|
||||||
fetchJson('/api/markets'),
|
|
||||||
fetchJson('/api/trades?limit=120'),
|
|
||||||
fetchJson('/api/signals?limit=160'),
|
|
||||||
fetchJson('/api/events?limit=120'),
|
|
||||||
fetchJson('/api/config'),
|
|
||||||
fetchJson('/api/analytics'),
|
|
||||||
fetchJson('/api/reconciliation'),
|
|
||||||
fetchJson('/api/backtest'),
|
|
||||||
fetchJson('/api/retrain')
|
|
||||||
]);
|
|
||||||
state.data = { health, status, markets, trades, signals, events, config, analytics, reconciliation, backtest, retrain };
|
|
||||||
renderChrome();
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChrome() {
|
|
||||||
const { health, status, config } = state.data;
|
|
||||||
const botStatus = status?.status || {};
|
|
||||||
const account = status?.account || {};
|
|
||||||
document.getElementById('headline').textContent =
|
|
||||||
`${botStatus.mode || health?.mode || 'paper'} · ${botStatus.running ? 'running' : 'stopped'} · equity ${money(account.equity)} · ${config?.symbols?.join(', ') || ''}`;
|
|
||||||
const pill = document.getElementById('healthPill');
|
|
||||||
pill.textContent = health?.ok ? 'OK' : 'ERROR';
|
|
||||||
pill.className = `pill ${health?.ok ? 'ok' : 'error'}`;
|
|
||||||
document.getElementById('fastToggle').checked = Boolean(config?.fast_trading_enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
const root = document.getElementById('app');
|
|
||||||
const tab = state.tab;
|
|
||||||
if (tab === 'overview') root.innerHTML = overviewHtml();
|
|
||||||
if (tab === 'markets') root.innerHTML = marketsHtml();
|
|
||||||
if (tab === 'risk') root.innerHTML = riskHtml();
|
|
||||||
if (tab === 'pnl') root.innerHTML = pnlHtml();
|
|
||||||
if (tab === 'backtest') root.innerHTML = backtestHtml();
|
|
||||||
if (tab === 'logs') root.innerHTML = logsHtml();
|
|
||||||
drawCanvases();
|
|
||||||
}
|
|
||||||
|
|
||||||
function overviewHtml() {
|
|
||||||
const { status, config, analytics } = state.data;
|
|
||||||
const account = status?.account || {};
|
|
||||||
const bot = status?.status || {};
|
|
||||||
const risk = analytics?.risk_guard || {};
|
|
||||||
const artifact = config?.time_series_model_artifact || {};
|
|
||||||
return `
|
|
||||||
<div class="grid cols-4">
|
|
||||||
${metric('Equity', money(account.equity), signed(account.net_pnl_percent, 3) + '% net')}
|
|
||||||
${metric('Cash', money(account.cash), `exposure ${money(account.exposure)}`)}
|
|
||||||
${metric('Open positions', String(status?.positions?.length || 0), `drawdown ${money(account.drawdown)}`)}
|
|
||||||
${metric('Risk guard', risk.enabled ? (risk.block_new_entries ? 'BLOCK' : `${num((risk.position_size_multiplier ?? 1) * 100, 0)}% size`) : 'off', (risk.reasons || []).join(', ') || 'normal')}
|
|
||||||
</div>
|
|
||||||
<div class="grid cols-2" style="margin-top:14px">
|
|
||||||
${panel('Runtime', kvTable([
|
|
||||||
['Mode', bot.mode || ''],
|
|
||||||
['Running', bot.running ? 'yes' : 'no'],
|
|
||||||
['Message', bot.message || ''],
|
|
||||||
['Last loop', dt(bot.last_loop_at)],
|
|
||||||
['Fast loop', config?.fast_trading_enabled ? `${num(config.effective_loop_interval_seconds, 2)}s` : 'off'],
|
|
||||||
['Strategy', config?.strategy_mode || '']
|
|
||||||
]))}
|
|
||||||
${panel('Torch model', kvTable([
|
|
||||||
['Artifact', artifact.label || artifact.type || ''],
|
|
||||||
['Created', dt(artifact.created_at)],
|
|
||||||
['Models', (artifact.models || []).join(', ')],
|
|
||||||
['Symbols', String(artifact.symbol_count ?? 0)],
|
|
||||||
['Features', String(artifact.feature_count ?? '')],
|
|
||||||
['Horizon', String(artifact.target_horizon ?? '')],
|
|
||||||
['Min edge', `${num(config?.time_series_min_edge_percent, 3)}%`],
|
|
||||||
['Min P(up)', `${num((config?.time_series_min_probability_up || 0) * 100, 1)}%`],
|
|
||||||
['Min confidence', num(config?.time_series_min_confidence, 3)],
|
|
||||||
['Probe entry', config?.time_series_probe_enabled ? `${num(config?.time_series_probe_min_edge_percent, 3)}% / P ${num((config?.time_series_probe_min_probability_up || 0) * 100, 1)}% / size ${num((config?.time_series_probe_size_multiplier || 0) * 100, 0)}%` : 'off']
|
|
||||||
]))}
|
|
||||||
</div>
|
|
||||||
${positionsPanel()}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function marketsHtml() {
|
|
||||||
const markets = state.data.markets?.markets || [];
|
|
||||||
const latestSignals = latestSignalBySymbol();
|
|
||||||
if (!markets.length) return empty('Нет market data');
|
|
||||||
return `<div class="grid">${markets.map(market => marketPanel(market, latestSignals)).join('')}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function marketPanel(market, latestSignals) {
|
|
||||||
const ticker = market.ticker || {};
|
|
||||||
const symbol = ticker.symbol || market.instrument?.symbol || '';
|
|
||||||
const forecast = market.forecast || {};
|
|
||||||
const quality = market.quality || {};
|
|
||||||
const signal = latestSignals[symbol] || {};
|
|
||||||
const minEdge = state.data.config?.time_series_min_edge_percent ?? 0;
|
|
||||||
const probeEnabled = Boolean(state.data.config?.time_series_probe_enabled);
|
|
||||||
const probeEdge = state.data.config?.time_series_probe_min_edge_percent ?? 0;
|
|
||||||
const probeProbability = state.data.config?.time_series_probe_min_probability_up ?? 0;
|
|
||||||
const minConfidence = state.data.config?.time_series_min_confidence ?? 0;
|
|
||||||
const diagnostics = parseDiagnostics(signal);
|
|
||||||
const failed = Object.entries(diagnostics.checks || {}).filter(([, ok]) => !ok).map(([key]) => key);
|
|
||||||
return panel(
|
|
||||||
`<span>${escapeHtml(symbol)}</span><span class="pill ${qualityClass(quality.status)}">${quality.status || 'n/a'} ${num((quality.score || 0) * 100, 0)}%</span>`,
|
|
||||||
`<div class="grid cols-3">
|
|
||||||
${kvTable([
|
|
||||||
['Price', money(ticker.last_price, 6)],
|
|
||||||
['24h', signed(ticker.change_24h, 2) + '%'],
|
|
||||||
['Spread', num(ticker.spread_percent, 4) + '%'],
|
|
||||||
['Signal', `${signal.action || ''} ${num(signal.confidence, 4)}`],
|
|
||||||
['Reason', signal.reason || '']
|
|
||||||
])}
|
|
||||||
${kvTable([
|
|
||||||
['Model', modelName(forecast.model)],
|
|
||||||
['Edge', `${signed(forecast.expected_return_percent, 4)}% / min ${num(minEdge, 3)}%`],
|
|
||||||
['P(up)', num((forecast.probability_up || 0) * 100, 2) + '%'],
|
|
||||||
['Probe', probeEnabled ? `${num(probeEdge, 3)}% / P ${num(probeProbability * 100, 1)}% / ${diagnostics.edge_mode || 'n/a'}` : 'off'],
|
|
||||||
['Confidence', `${num(signal.confidence, 4)} / min ${num(minConfidence, 2)}`],
|
|
||||||
['Q10/Q50/Q90', `${signed(forecast.quantile_10_percent, 2)} / ${signed(forecast.quantile_50_percent, 2)} / ${signed(forecast.quantile_90_percent, 2)}`],
|
|
||||||
['Blocked', forecast.block_entry ? 'yes' : 'no']
|
|
||||||
])}
|
|
||||||
<div>
|
|
||||||
<canvas id="chart-${escapeAttr(symbol)}" height="120"></canvas>
|
|
||||||
<div class="row" style="margin-top:8px">${failed.map(item => `<span class="pill warn">${escapeHtml(item)}</span>`).join('') || '<span class="pill ok">checks ok</span>'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:12px">${featuresHtml(forecast)}</div>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function riskHtml() {
|
|
||||||
const { analytics, markets, reconciliation } = state.data;
|
|
||||||
const risk = analytics?.risk_guard || {};
|
|
||||||
const drift = analytics?.drift || {};
|
|
||||||
const quality = markets?.quality || {};
|
|
||||||
return `
|
|
||||||
<div class="grid cols-3">
|
|
||||||
${metric('Risk guard', risk.enabled ? (risk.block_new_entries ? 'BLOCK' : 'ACTIVE') : 'OFF', (risk.reasons || []).join(', ') || 'normal')}
|
|
||||||
${metric('Drift', drift.status || 'n/a', (drift.warnings || []).join(', ') || 'normal')}
|
|
||||||
${metric('Data quality', `${num((quality.score || 0) * 100, 0)}%`, quality.status || '')}
|
|
||||||
</div>
|
|
||||||
<div class="grid cols-2" style="margin-top:14px">
|
|
||||||
${panel('Risk guard', kvTable([
|
|
||||||
['Block entries', risk.block_new_entries ? 'yes' : 'no'],
|
|
||||||
['Size multiplier', num(risk.position_size_multiplier, 4)],
|
|
||||||
['Blocked symbols', (risk.blocked_symbols || []).join(', ') || 'none'],
|
|
||||||
['Consecutive losses', String(risk.consecutive_losses ?? 0)],
|
|
||||||
['Today PnL', money(risk.today_pnl)],
|
|
||||||
['Recent PF', num(risk.recent?.profit_factor, 3)],
|
|
||||||
['Recent avg', signed(risk.recent?.avg_net_percent, 4) + '%']
|
|
||||||
]))}
|
|
||||||
${panel('Reconciliation', kvTable([
|
|
||||||
['Status', reconciliation?.status || ''],
|
|
||||||
['Live ready', reconciliation?.live_ready ? 'yes' : 'no'],
|
|
||||||
['Discrepancies', String(reconciliation?.discrepancies?.length || 0)],
|
|
||||||
['Remote equity', money(reconciliation?.account?.total_equity)]
|
|
||||||
]) + discrepanciesHtml(reconciliation?.discrepancies || []))}
|
|
||||||
</div>
|
|
||||||
${panel('Risk by symbol', symbolRiskTable(risk.symbols || []))}
|
|
||||||
${panel('Data quality by symbol', qualityTable(quality.symbols || []))}
|
|
||||||
${panel('Probability calibration', calibrationTable(analytics?.probability_calibration?.buckets || []))}
|
|
||||||
${panel('Failed checks', simpleTable(Object.entries(drift.failed_checks || {}).map(([key, value]) => ({ check: key, count: value })), ['check', 'count']))}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pnlHtml() {
|
|
||||||
const pnl = state.data.analytics?.pnl || {};
|
|
||||||
return `
|
|
||||||
<div class="grid cols-4">
|
|
||||||
${metric('Closed trades', String(pnl.total?.trades || 0), `${num((pnl.total?.win_rate || 0) * 100, 1)}% win`)}
|
|
||||||
${metric('Net PnL', money(pnl.total?.net_pnl), `${signed(pnl.total?.avg_net_percent, 4)}% avg`)}
|
|
||||||
${metric('Fees', money(pnl.total?.fees), `PF ${num(pnl.total?.profit_factor, 3)}`)}
|
|
||||||
${metric('Worst / Best', `${money(pnl.total?.worst)} / ${money(pnl.total?.best)}`, '')}
|
|
||||||
</div>
|
|
||||||
<div class="grid cols-3" style="margin-top:14px">
|
|
||||||
${panel('By symbol', statsTable(pnl.by_symbol || []))}
|
|
||||||
${panel('By exit', statsTable(pnl.by_exit || []))}
|
|
||||||
${panel('By model', statsTable(pnl.by_model || []))}
|
|
||||||
</div>
|
|
||||||
${panel('Recent closed trades', simpleTable((pnl.recent || []).map(row => ({
|
|
||||||
id: row.id,
|
|
||||||
symbol: row.symbol,
|
|
||||||
net: money(row.net_pnl),
|
|
||||||
net_percent: signed(row.net_percent, 3) + '%',
|
|
||||||
p_up: row.entry_probability == null ? '' : num(row.entry_probability * 100, 2) + '%',
|
|
||||||
expected: row.entry_expected_percent == null ? '' : signed(row.entry_expected_percent, 3) + '%',
|
|
||||||
exit: row.exit_category,
|
|
||||||
closed: dt(row.closed_at)
|
|
||||||
})), ['id', 'symbol', 'net', 'net_percent', 'p_up', 'expected', 'exit', 'closed']))}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function backtestHtml() {
|
|
||||||
const backtest = state.data.backtest || {};
|
|
||||||
const retrain = state.data.retrain || {};
|
|
||||||
const rec = backtest.recommended || {};
|
|
||||||
const replay = backtest.full_replay || {};
|
|
||||||
const walk = backtest.walk_forward?.summary || {};
|
|
||||||
return `
|
|
||||||
<div class="grid cols-4">
|
|
||||||
${metric('Recommended edge', `${num(rec.edge, 4)}%`, `P(up) ${num((rec.probability || 0) * 100, 1)}%`)}
|
|
||||||
${metric('Entry replay', `${replay.trades || 0} trades`, `${signed(replay.avg_net_percent, 4)}% avg · PF ${num(replay.profit_factor, 3)}`)}
|
|
||||||
${metric('Walk-forward', `${walk.trades || 0} trades`, `${signed(walk.avg_net_percent, 4)}% avg · ${walk.status || ''}`)}
|
|
||||||
${metric('Retrain guard', retrain.available ? (retrain.accepted ? 'accepted' : 'rejected') : 'no report', retrain.reason || '')}
|
|
||||||
</div>
|
|
||||||
<div class="grid cols-2" style="margin-top:14px">
|
|
||||||
${panel('Threshold calibration', kvTable([
|
|
||||||
['Edge', num(rec.edge, 4) + '%'],
|
|
||||||
['P(up)', num((rec.probability || 0) * 100, 2) + '%'],
|
|
||||||
['Confidence', num(rec.confidence, 4)],
|
|
||||||
['Trades', String(rec.trades || 0)],
|
|
||||||
['Win rate', num((rec.win_rate || 0) * 100, 2) + '%'],
|
|
||||||
['Avg net', signed(rec.average_net_percent, 4) + '%'],
|
|
||||||
['Profit factor', num(rec.profit_factor, 4)]
|
|
||||||
]))}
|
|
||||||
${panel('Full replay', kvTable([
|
|
||||||
['Trades', String(replay.trades || 0)],
|
|
||||||
['Win rate', num((replay.win_rate || 0) * 100, 2) + '%'],
|
|
||||||
['Avg net', signed(replay.avg_net_percent, 4) + '%'],
|
|
||||||
['Total net', signed(replay.total_net_percent, 4) + '%'],
|
|
||||||
['Drawdown', num(replay.max_drawdown_percent, 4) + '%'],
|
|
||||||
['Profit factor', num(replay.profit_factor, 4)]
|
|
||||||
]))}
|
|
||||||
</div>
|
|
||||||
${panel('Walk-forward folds', simpleTable((backtest.walk_forward?.folds || []).map(row => ({
|
|
||||||
fold: row.fold,
|
|
||||||
train: row.train_records,
|
|
||||||
test: row.test_records,
|
|
||||||
edge: num(row.thresholds?.edge, 3),
|
|
||||||
p_up: num((row.thresholds?.probability || 0) * 100, 1) + '%',
|
|
||||||
trades: row.test?.trades || 0,
|
|
||||||
avg: signed(row.test?.avg_net_percent, 4) + '%',
|
|
||||||
pf: num(row.test?.profit_factor, 3)
|
|
||||||
})), ['fold', 'train', 'test', 'edge', 'p_up', 'trades', 'avg', 'pf']))}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function logsHtml() {
|
|
||||||
const signals = state.data.signals?.items || [];
|
|
||||||
const events = state.data.events?.items || [];
|
|
||||||
return `
|
|
||||||
${panel('Signals', simpleTable(signals.slice(0, 80).map(signal => ({
|
|
||||||
id: signal.id,
|
|
||||||
symbol: signal.symbol,
|
|
||||||
action: signal.action,
|
|
||||||
confidence: num(signal.confidence, 4),
|
|
||||||
reason: signal.reason,
|
|
||||||
created: dt(signal.created_at)
|
|
||||||
})), ['id', 'symbol', 'action', 'confidence', 'reason', 'created']))}
|
|
||||||
${panel('Events', simpleTable(events.slice(0, 80).map(event => ({
|
|
||||||
id: event.id,
|
|
||||||
level: event.level,
|
|
||||||
message: event.message,
|
|
||||||
created: dt(event.created_at)
|
|
||||||
})), ['id', 'level', 'message', 'created']))}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function positionsPanel() {
|
|
||||||
const positions = state.data.status?.positions || [];
|
|
||||||
return panel('Open positions', simpleTable(positions.map(position => ({
|
|
||||||
id: position.id,
|
|
||||||
symbol: position.symbol,
|
|
||||||
qty: num(position.qty, 8),
|
|
||||||
entry: money(position.entry_price, 6),
|
|
||||||
mark: money(position.mark_price, 6),
|
|
||||||
pnl: money(position.unrealized_pnl),
|
|
||||||
pnl_percent: signed(position.unrealized_pnl_percent, 3) + '%',
|
|
||||||
opened: dt(position.opened_at)
|
|
||||||
})), ['id', 'symbol', 'qty', 'entry', 'mark', 'pnl', 'pnl_percent', 'opened']));
|
|
||||||
}
|
|
||||||
|
|
||||||
function qualityTable(rows) {
|
|
||||||
return simpleTable(rows.map(row => ({
|
|
||||||
symbol: row.symbol,
|
|
||||||
status: row.status,
|
|
||||||
score: num((row.score || 0) * 100, 1) + '%',
|
|
||||||
candles: row.candle_count,
|
|
||||||
issues: (row.issues || []).map(issue => issue.code).join(', ') || 'none'
|
|
||||||
})), ['symbol', 'status', 'score', 'candles', 'issues']);
|
|
||||||
}
|
|
||||||
|
|
||||||
function symbolRiskTable(rows) {
|
|
||||||
return simpleTable(rows.map(row => ({
|
|
||||||
symbol: row.symbol,
|
|
||||||
block: row.block_new_entries ? 'yes' : 'no',
|
|
||||||
trades: row.trades,
|
|
||||||
win: num((row.win_rate || 0) * 100, 1) + '%',
|
|
||||||
avg: signed(row.avg_net_percent, 3) + '%',
|
|
||||||
pf: num(row.profit_factor, 3),
|
|
||||||
losses: row.consecutive_losses,
|
|
||||||
reasons: (row.reasons || []).join(', ') || 'none'
|
|
||||||
})), ['symbol', 'block', 'trades', 'win', 'avg', 'pf', 'losses', 'reasons']);
|
|
||||||
}
|
|
||||||
|
|
||||||
function calibrationTable(rows) {
|
|
||||||
return simpleTable(rows.map(row => ({
|
|
||||||
bucket: row.bucket,
|
|
||||||
trades: row.trades ?? row.samples,
|
|
||||||
predicted: num((row.avg_probability || 0) * 100, 2) + '%',
|
|
||||||
actual: num((row.actual_win_rate || 0) * 100, 2) + '%',
|
|
||||||
error: signed((row.calibration_error || 0) * 100, 2) + '%',
|
|
||||||
avg: row.avg_net_percent == null ? signed(row.avg_future_net_percent, 4) + '%' : signed(row.avg_net_percent, 4) + '%'
|
|
||||||
})), ['bucket', 'trades', 'predicted', 'actual', 'error', 'avg']);
|
|
||||||
}
|
|
||||||
|
|
||||||
function statsTable(rows) {
|
|
||||||
return simpleTable(rows.map(row => ({
|
|
||||||
key: row.key,
|
|
||||||
trades: row.trades,
|
|
||||||
win: num((row.win_rate || 0) * 100, 1) + '%',
|
|
||||||
net: money(row.net_pnl),
|
|
||||||
avg: signed(row.avg_net_percent, 3) + '%',
|
|
||||||
pf: num(row.profit_factor, 3)
|
|
||||||
})), ['key', 'trades', 'win', 'net', 'avg', 'pf']);
|
|
||||||
}
|
|
||||||
|
|
||||||
function discrepanciesHtml(rows) {
|
|
||||||
if (!rows.length) return '<div class="row"><span class="pill ok">no discrepancies</span></div>';
|
|
||||||
return `<div class="row">${rows.map(row => `<span class="pill ${qualityClass(row.severity)}">${escapeHtml(row.code)} ${escapeHtml(row.coin || '')}</span>`).join('')}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function featuresHtml(forecast) {
|
|
||||||
const items = (forecast.feature_snapshot || [])
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => Math.abs(Number(b.model_value || 0)) - Math.abs(Number(a.model_value || 0)))
|
|
||||||
.slice(0, 8);
|
|
||||||
if (!items.length) return '<div class="empty">Нет feature snapshot</div>';
|
|
||||||
return `<div class="feature-list">${items.map(item => `
|
|
||||||
<div class="feature">
|
|
||||||
<div><b>${escapeHtml(item.label || item.name)}</b><div class="muted">${escapeHtml(item.group || '')}</div></div>
|
|
||||||
<div class="mono">${escapeHtml(item.raw_display ?? item.raw_value ?? '')}</div>
|
|
||||||
<div class="mono ${Number(item.model_value || 0) >= 0 ? 'positive' : 'negative'}">${escapeHtml(item.model_display ?? item.model_value ?? '')}</div>
|
|
||||||
</div>`).join('')}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function panel(title, body) {
|
|
||||||
return `<section class="panel"><div class="panel-head"><h2>${title}</h2></div><div class="panel-body">${body}</div></section>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function metric(label, value, note) {
|
|
||||||
return `<div class="metric"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(value ?? '')}</div><div class="note">${escapeHtml(note ?? '')}</div></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function kvTable(rows) {
|
|
||||||
return `<div>${rows.map(([key, value]) => `<div class="kv"><span>${escapeHtml(key)}</span><span>${escapeHtml(value ?? '')}</span></div>`).join('')}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function simpleTable(rows, columns) {
|
|
||||||
if (!rows.length) return '<div class="empty">Нет данных</div>';
|
|
||||||
return `<div style="overflow:auto"><table><thead><tr>${columns.map(column => `<th>${escapeHtml(column)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${columns.map(column => `<td>${escapeHtml(row[column] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestSignalBySymbol() {
|
|
||||||
const output = {};
|
|
||||||
for (const signal of state.data.signals?.items || []) {
|
|
||||||
if (signal.symbol && !output[signal.symbol]) output[signal.symbol] = signal;
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDiagnostics(signal) {
|
|
||||||
if (!signal?.diagnostics_json) return {};
|
|
||||||
try { return JSON.parse(signal.diagnostics_json); } catch { return {}; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawCanvases() {
|
|
||||||
for (const market of state.data.markets?.markets || []) {
|
|
||||||
const symbol = market.ticker?.symbol || market.instrument?.symbol || '';
|
|
||||||
const canvas = document.getElementById(`chart-${symbol}`);
|
|
||||||
if (!canvas) continue;
|
|
||||||
drawChart(canvas, market.candles || []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawChart(canvas, candles) {
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const width = canvas.clientWidth || 320;
|
|
||||||
const height = canvas.height;
|
|
||||||
canvas.width = width * devicePixelRatio;
|
|
||||||
canvas.height = height * devicePixelRatio;
|
|
||||||
ctx.scale(devicePixelRatio, devicePixelRatio);
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
const rows = candles.slice(-80);
|
|
||||||
if (rows.length < 2) return;
|
|
||||||
const highs = rows.map(row => Number(row.high));
|
|
||||||
const lows = rows.map(row => Number(row.low));
|
|
||||||
const min = Math.min(...lows);
|
|
||||||
const max = Math.max(...highs);
|
|
||||||
const range = Math.max(1e-9, max - min);
|
|
||||||
ctx.lineWidth = 1.4;
|
|
||||||
rows.forEach((row, index) => {
|
|
||||||
const x = 6 + index * ((width - 12) / Math.max(1, rows.length - 1));
|
|
||||||
const yOpen = height - 6 - ((row.open - min) / range) * (height - 12);
|
|
||||||
const yClose = height - 6 - ((row.close - min) / range) * (height - 12);
|
|
||||||
const yHigh = height - 6 - ((row.high - min) / range) * (height - 12);
|
|
||||||
const yLow = height - 6 - ((row.low - min) / range) * (height - 12);
|
|
||||||
ctx.strokeStyle = Number(row.close) >= Number(row.open) ? '#138a55' : '#c24141';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, yHigh);
|
|
||||||
ctx.lineTo(x, yLow);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x - 2, yOpen);
|
|
||||||
ctx.lineTo(x + 2, yClose);
|
|
||||||
ctx.stroke();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelName(value) {
|
|
||||||
if (value === 'torch_lstm') return 'PyTorch LSTM';
|
|
||||||
if (value === 'torch_gru') return 'PyTorch GRU';
|
|
||||||
return value || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function qualityClass(value) {
|
|
||||||
if (value === 'ok') return 'ok';
|
|
||||||
if (value === 'error') return 'error';
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
function money(value, digits = 2) {
|
|
||||||
const n = Number(value);
|
|
||||||
if (!Number.isFinite(n)) return '';
|
|
||||||
return `${n.toFixed(digits)} USDT`;
|
|
||||||
}
|
|
||||||
function num(value, digits = 2) {
|
|
||||||
const n = Number(value);
|
|
||||||
if (!Number.isFinite(n)) return '';
|
|
||||||
return n.toFixed(digits);
|
|
||||||
}
|
|
||||||
function signed(value, digits = 2) {
|
|
||||||
const n = Number(value);
|
|
||||||
if (!Number.isFinite(n)) return '';
|
|
||||||
return `${n >= 0 ? '+' : ''}${n.toFixed(digits)}`;
|
|
||||||
}
|
|
||||||
function dt(value) {
|
|
||||||
if (!value) return '';
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return String(value);
|
|
||||||
return date.toLocaleString('ru-RU', { hour12: false });
|
|
||||||
}
|
|
||||||
function empty(text) { return `<div class="empty">${escapeHtml(text)}</div>`; }
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
|
||||||
}
|
|
||||||
function escapeAttr(value) { return escapeHtml(value).replace(/[^A-Za-z0-9_-]/g, ''); }
|
|
||||||
|
|
||||||
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 => {
|
|
||||||
await fetchJson('/api/config/fast-trading', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: event.target.checked }) });
|
|
||||||
await refresh();
|
|
||||||
});
|
|
||||||
initTabs();
|
|
||||||
refresh().catch(error => { document.getElementById('headline').textContent = error.message; });
|
|
||||||
setInterval(() => refresh().catch(error => { document.getElementById('headline').textContent = error.message; }), 5000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -269,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))
|
||||||
@@ -320,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
|
||||||
@@ -357,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": {},
|
||||||
|
|||||||
@@ -16,7 +16,20 @@ 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:
|
||||||
|
|||||||
@@ -242,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(
|
||||||
|
|||||||
+381
-44
@@ -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,18 +622,24 @@ 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)
|
||||||
@@ -665,8 +678,61 @@ def _torch_forecast_entry_signal(
|
|||||||
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": full_edge_ok or probe_edge_ok,
|
"expected_edge_ok": full_edge_ok or probe_edge_ok,
|
||||||
@@ -675,7 +741,7 @@ def _torch_forecast_entry_signal(
|
|||||||
"confidence_ok": confidence >= settings.time_series_min_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",
|
||||||
@@ -693,33 +759,97 @@ def _torch_forecast_entry_signal(
|
|||||||
"edge_mode": edge_mode,
|
"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,
|
"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())
|
||||||
return Signal(
|
if base_entry_ok or rebound_entry_sized_ok:
|
||||||
symbol,
|
buy_confidence = max(confidence, float(rebound.get("probability", 0.0) or 0.0)) if rebound_entry_sized_ok else confidence
|
||||||
"BUY",
|
entry_path = edge_mode if rebound_entry_ok and not base_entry_ok else edge_mode
|
||||||
confidence,
|
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; "
|
"torch_forecast: PyTorch edge confirmed; "
|
||||||
f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
|
f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
|
||||||
f"expected={expected_return:.4f}%, edge_mode={edge_mode}, "
|
f"expected={expected_return:.4f}%, edge_mode={edge_mode}, "
|
||||||
f"size={position_notional:.2f} USDT"
|
f"size={position_notional:.2f} USDT"
|
||||||
),
|
)
|
||||||
|
return Signal(
|
||||||
|
symbol,
|
||||||
|
"BUY",
|
||||||
|
round(_clamp(buy_confidence, 0.0, 0.96), 4),
|
||||||
|
reason,
|
||||||
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,
|
||||||
@@ -733,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)
|
||||||
@@ -745,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,
|
||||||
@@ -760,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",
|
||||||
@@ -791,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -799,10 +1003,29 @@ 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.time_series_min_probability_up, 0.45, 0.75), 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:
|
||||||
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
|
expected_return = max(0.0, _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)
|
||||||
@@ -820,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1006,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"
|
||||||
@@ -1018,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)
|
||||||
@@ -1028,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)
|
||||||
@@ -1305,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
|
||||||
@@ -1313,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":
|
||||||
@@ -1329,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
|
||||||
@@ -1340,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)
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ class TimeSeriesForecast:
|
|||||||
feature_snapshot: list[dict[str, Any]] = field(default_factory=list)
|
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)
|
||||||
@@ -164,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,
|
||||||
@@ -184,6 +188,8 @@ 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)
|
clip = _clamp(_float_entry(entry or {}, "clip", 8.0), 1.0, 50.0)
|
||||||
@@ -280,6 +286,8 @@ class TimeSeriesForecaster:
|
|||||||
feature_snapshot=feature_snapshot,
|
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)
|
||||||
@@ -340,6 +348,8 @@ class TimeSeriesForecaster:
|
|||||||
feature_snapshot=feature_snapshot,
|
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]:
|
||||||
@@ -362,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(
|
||||||
@@ -388,9 +417,25 @@ def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
|
|||||||
target_transform="none",
|
target_transform="none",
|
||||||
feature_snapshot=[],
|
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))]
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
+1160029
-306845
File diff suppressed because it is too large
Load Diff
+1159900
-363484
File diff suppressed because it is too large
Load Diff
+2230
-293
File diff suppressed because it is too large
Load Diff
+1595
-85
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,7 @@ def make_settings():
|
|||||||
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_guard_enabled=True,
|
||||||
|
risk_symbol_guard_enabled=True,
|
||||||
risk_recent_trade_window=20,
|
risk_recent_trade_window=20,
|
||||||
risk_max_consecutive_losses=4,
|
risk_max_consecutive_losses=4,
|
||||||
risk_min_recent_profit_factor=0.85,
|
risk_min_recent_profit_factor=0.85,
|
||||||
@@ -87,10 +88,13 @@ def make_settings():
|
|||||||
time_series_probe_min_edge_percent=0.02,
|
time_series_probe_min_edge_percent=0.02,
|
||||||
time_series_probe_min_probability_up=0.55,
|
time_series_probe_min_probability_up=0.55,
|
||||||
time_series_probe_size_multiplier=0.40,
|
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,
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ def test_risk_guard_reduces_size_after_consecutive_losses(make_settings, tmp_pat
|
|||||||
|
|
||||||
|
|
||||||
def test_risk_guard_blocks_only_bad_symbol(make_settings, tmp_path) -> None:
|
def test_risk_guard_blocks_only_bad_symbol(make_settings, tmp_path) -> None:
|
||||||
settings = make_settings(tmp_path, risk_max_consecutive_losses=3, symbols=["BTCUSDT", "ETHUSDT"])
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
risk_symbol_guard_enabled=True,
|
||||||
|
risk_max_consecutive_losses=3,
|
||||||
|
symbols=["BTCUSDT", "ETHUSDT"],
|
||||||
|
)
|
||||||
storage = Storage(settings.database_path)
|
storage = Storage(settings.database_path)
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
@@ -76,6 +81,41 @@ def test_risk_guard_blocks_only_bad_symbol(make_settings, tmp_path) -> None:
|
|||||||
assert symbol_rows["ETHUSDT"]["block_new_entries"] is False
|
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:
|
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"])
|
settings = make_settings(tmp_path, risk_max_consecutive_losses=2, symbols=["BTCUSDT", "ETHUSDT"])
|
||||||
storage = Storage(settings.database_path)
|
storage = Storage(settings.database_path)
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
|
|||||||
assert config["time_series_probe_min_edge_percent"] == 0.02
|
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_min_probability_up"] == 0.55
|
||||||
assert config["time_series_probe_size_multiplier"] == 0.40
|
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",
|
||||||
@@ -54,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:
|
||||||
|
|||||||
+721
-6
@@ -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,
|
||||||
@@ -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,7 +369,204 @@ 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_probe_buys_on_positive_high_probability(make_settings, tmp_path) -> None:
|
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(
|
settings = make_settings(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
strategy_mode="torch_forecast",
|
strategy_mode="torch_forecast",
|
||||||
@@ -317,11 +599,11 @@ def test_torch_forecast_probe_buys_on_positive_high_probability(make_settings, t
|
|||||||
account={"equity": 100.0},
|
account={"equity": 100.0},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert signal.action == "BUY"
|
assert signal.action == "HOLD"
|
||||||
assert signal.diagnostics["edge_mode"] == "probe"
|
assert signal.diagnostics["edge_mode"] == "probe"
|
||||||
assert signal.diagnostics["checks"]["expected_edge_ok"] is True
|
assert signal.diagnostics["checks"]["expected_edge_ok"] is True
|
||||||
assert signal.diagnostics["position_sizing"]["edge_mode"] == "probe"
|
assert signal.diagnostics["checks"]["risk_size_ok"] is False
|
||||||
assert settings.min_position_usdt <= signal.diagnostics["position_notional_usdt"] < settings.max_position_usdt
|
assert signal.diagnostics["position_notional_usdt"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_torch_forecast_probe_blocks_negative_expected_return(make_settings, tmp_path) -> None:
|
def test_torch_forecast_probe_blocks_negative_expected_return(make_settings, tmp_path) -> None:
|
||||||
@@ -358,9 +640,227 @@ def test_torch_forecast_probe_blocks_negative_expected_return(make_settings, tmp
|
|||||||
assert signal.diagnostics["checks"]["expected_edge_ok"] is False
|
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)
|
||||||
|
|
||||||
@@ -378,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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -64,6 +64,8 @@ def _decision(
|
|||||||
candidate_replay = candidate.get("full_replay") if isinstance(candidate.get("full_replay"), dict) else {}
|
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 {}
|
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 {}
|
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:
|
if int(candidate_replay.get("trades", 0) or 0) < min_trades:
|
||||||
return {"accepted": False, "reason": "candidate_has_too_few_full_replay_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:
|
if float(candidate_replay.get("profit_factor", 0.0) or 0.0) < min_profit_factor:
|
||||||
@@ -72,11 +74,20 @@ def _decision(
|
|||||||
return {"accepted": False, "reason": "candidate_expectancy_non_positive"}
|
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:
|
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"}
|
return {"accepted": False, "reason": "candidate_walk_forward_expectancy_non_positive"}
|
||||||
if current_score > 0 and candidate_score < current_score * (1.0 - max_score_regression):
|
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": False, "reason": "candidate_score_regressed_vs_current"}
|
||||||
return {"accepted": True, "reason": "candidate_passed_guard"}
|
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:
|
def _score(report: dict[str, Any]) -> float:
|
||||||
replay = report.get("full_replay") if isinstance(report.get("full_replay"), dict) else {}
|
replay = report.get("full_replay") if isinstance(report.get("full_replay"), dict) else {}
|
||||||
recommended = report.get("recommended") if isinstance(report.get("recommended"), dict) else {}
|
recommended = report.get("recommended") if isinstance(report.get("recommended"), dict) else {}
|
||||||
@@ -97,6 +108,10 @@ def _summary(report: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"walk_forward_summary": (report.get("walk_forward") or {}).get("summary", {})
|
"walk_forward_summary": (report.get("walk_forward") or {}).get("summary", {})
|
||||||
if isinstance(report.get("walk_forward"), dict)
|
if isinstance(report.get("walk_forward"), dict)
|
||||||
else {},
|
else {},
|
||||||
|
"benchmark_summary": (report.get("benchmark") or {}).get("summary", {})
|
||||||
|
if isinstance(report.get("benchmark"), dict)
|
||||||
|
else {},
|
||||||
|
"validation": report.get("validation", {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class ForecastRecord:
|
|||||||
q50_percent: float
|
q50_percent: float
|
||||||
block_entry: bool
|
block_entry: bool
|
||||||
future_net_percent: float
|
future_net_percent: float
|
||||||
|
benchmark_entry: bool
|
||||||
|
benchmark_exit: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -138,10 +140,17 @@ def main() -> None:
|
|||||||
for result in results[: min(args.top, len(results))]:
|
for result in results[: min(args.top, len(results))]:
|
||||||
print(_result_line(result))
|
print(_result_line(result))
|
||||||
|
|
||||||
recommended = _choose_recommendation(results, min_trades=args.min_trades)
|
recommended, full_backtest = _choose_replay_recommendation(
|
||||||
|
results,
|
||||||
|
records,
|
||||||
|
min_trades=args.min_trades,
|
||||||
|
min_full_replay_trades=args.min_full_replay_trades,
|
||||||
|
horizon=horizon,
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
print("\nRECOMMENDED")
|
print("\nRECOMMENDED")
|
||||||
print(_result_line(recommended))
|
print(_result_line(recommended))
|
||||||
full_backtest = _full_backtest(records, recommended, horizon=horizon, round_trip_cost=round_trip_cost, settings=settings)
|
|
||||||
print("\nFULL_REPLAY")
|
print("\nFULL_REPLAY")
|
||||||
print(_stats_line(full_backtest))
|
print(_stats_line(full_backtest))
|
||||||
walk_forward = _walk_forward(
|
walk_forward = _walk_forward(
|
||||||
@@ -152,9 +161,32 @@ def main() -> None:
|
|||||||
min_trades=args.min_trades,
|
min_trades=args.min_trades,
|
||||||
horizon=horizon,
|
horizon=horizon,
|
||||||
folds=args.walk_forward_folds,
|
folds=args.walk_forward_folds,
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
benchmark = _benchmark_walk_forward(
|
||||||
|
records,
|
||||||
|
horizon=horizon,
|
||||||
|
folds=args.walk_forward_folds,
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
validation = _quality_gate(
|
||||||
|
walk_forward=walk_forward,
|
||||||
|
benchmark=benchmark,
|
||||||
|
min_oos_trades=args.min_oos_trades,
|
||||||
|
min_oos_symbols=args.min_oos_symbols,
|
||||||
|
max_symbol_share=args.max_oos_symbol_share,
|
||||||
|
min_oos_folds=args.min_oos_folds_with_trades,
|
||||||
|
min_profit_factor=args.min_oos_profit_factor,
|
||||||
|
min_benchmark_edge=args.min_benchmark_edge_percent,
|
||||||
)
|
)
|
||||||
print("\nWALK_FORWARD")
|
print("\nWALK_FORWARD")
|
||||||
print(json.dumps(walk_forward["summary"], ensure_ascii=False, sort_keys=True))
|
print(json.dumps(walk_forward["summary"], ensure_ascii=False, sort_keys=True))
|
||||||
|
print("\nBENCHMARK")
|
||||||
|
print(json.dumps(benchmark["summary"], ensure_ascii=False, sort_keys=True))
|
||||||
|
print("\nQUALITY_GATE")
|
||||||
|
print(json.dumps(validation, ensure_ascii=False, sort_keys=True))
|
||||||
print(
|
print(
|
||||||
"env "
|
"env "
|
||||||
f"TIME_SERIES_MIN_EDGE_PERCENT={recommended.edge:.4f} "
|
f"TIME_SERIES_MIN_EDGE_PERCENT={recommended.edge:.4f} "
|
||||||
@@ -169,6 +201,8 @@ def main() -> None:
|
|||||||
"recommended": _result_dict(recommended),
|
"recommended": _result_dict(recommended),
|
||||||
"full_replay": full_backtest,
|
"full_replay": full_backtest,
|
||||||
"walk_forward": walk_forward,
|
"walk_forward": walk_forward,
|
||||||
|
"benchmark": benchmark,
|
||||||
|
"validation": validation,
|
||||||
"probability_calibration": _probability_calibration(records),
|
"probability_calibration": _probability_calibration(records),
|
||||||
"top_results": [_result_dict(result) for result in results[: args.top]],
|
"top_results": [_result_dict(result) for result in results[: args.top]],
|
||||||
}
|
}
|
||||||
@@ -186,14 +220,21 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--calibration-window", type=int, default=720, help="Tail records used for calibration.")
|
parser.add_argument("--calibration-window", type=int, default=720, help="Tail records used for calibration.")
|
||||||
parser.add_argument("--horizon", type=int, default=0, help="Forecast horizon to calibrate.")
|
parser.add_argument("--horizon", type=int, default=0, help="Forecast horizon to calibrate.")
|
||||||
parser.add_argument("--min-trades", type=int, default=12, help="Minimum non-overlapping trades for recommendation.")
|
parser.add_argument("--min-trades", type=int, default=12, help="Minimum non-overlapping trades for recommendation.")
|
||||||
|
parser.add_argument("--min-full-replay-trades", type=int, default=8, help="Prefer recommendations with at least this many full replay trades.")
|
||||||
parser.add_argument("--edge-grid", default="0.00,0.02,0.04,0.05,0.06,0.08,0.10", help="Percent edge thresholds.")
|
parser.add_argument("--edge-grid", default="0.00,0.02,0.04,0.05,0.06,0.08,0.10", help="Percent edge thresholds.")
|
||||||
parser.add_argument("--probability-grid", default="0.55,0.56,0.57,0.58,0.59,0.60,0.62,0.64,0.66,0.68,0.70", help="P(up) thresholds.")
|
parser.add_argument("--probability-grid", default="0.45,0.47,0.50,0.52,0.54,0.55,0.56,0.58,0.60,0.62,0.64,0.66,0.68,0.70", help="P(up) thresholds.")
|
||||||
parser.add_argument("--confidence-grid", default="0.50,0.56,0.60,0.64,0.68,0.72", help="Confidence thresholds.")
|
parser.add_argument("--confidence-grid", default="0.40", help="Confidence thresholds.")
|
||||||
parser.add_argument("--top", type=int, default=15, help="How many top results to print and save.")
|
parser.add_argument("--top", type=int, default=15, help="How many top results to print and save.")
|
||||||
parser.add_argument("--output", default="", help="Optional JSON output path.")
|
parser.add_argument("--output", default="", help="Optional JSON output path.")
|
||||||
parser.add_argument("--batch-size", type=int, default=256, help="Torch inference batch size.")
|
parser.add_argument("--batch-size", type=int, default=256, help="Torch inference batch size.")
|
||||||
parser.add_argument("--threads", type=int, default=0, help="Torch CPU threads; 0 keeps torch default.")
|
parser.add_argument("--threads", type=int, default=0, help="Torch CPU threads; 0 keeps torch default.")
|
||||||
parser.add_argument("--walk-forward-folds", type=int, default=4, help="Threshold walk-forward folds.")
|
parser.add_argument("--walk-forward-folds", type=int, default=8, help="Threshold walk-forward folds.")
|
||||||
|
parser.add_argument("--min-oos-trades", type=int, default=30, help="Minimum out-of-sample walk-forward trades for a valid model.")
|
||||||
|
parser.add_argument("--min-oos-symbols", type=int, default=2, help="Minimum symbols with out-of-sample trades.")
|
||||||
|
parser.add_argument("--max-oos-symbol-share", type=float, default=0.75, help="Reject if one symbol contributes more than this share of out-of-sample trades.")
|
||||||
|
parser.add_argument("--min-oos-folds-with-trades", type=int, default=2, help="Minimum walk-forward folds that must produce trades.")
|
||||||
|
parser.add_argument("--min-oos-profit-factor", type=float, default=1.10, help="Minimum out-of-sample profit factor.")
|
||||||
|
parser.add_argument("--min-benchmark-edge-percent", type=float, default=0.0, help="Required total-net percent advantage over the benchmark.")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -237,6 +278,7 @@ def _forecast_records(
|
|||||||
batched_records = _batch_forecast_records(
|
batched_records = _batch_forecast_records(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
candles=candles,
|
candles=candles,
|
||||||
|
trend_candles=trend_candles,
|
||||||
feature_rows=feature_rows,
|
feature_rows=feature_rows,
|
||||||
closes=closes,
|
closes=closes,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
@@ -287,6 +329,8 @@ def _forecast_records(
|
|||||||
q50_percent=q50_percent,
|
q50_percent=q50_percent,
|
||||||
block_entry=False,
|
block_entry=False,
|
||||||
future_net_percent=future_net_percent,
|
future_net_percent=future_net_percent,
|
||||||
|
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
|
||||||
|
benchmark_exit=_benchmark_exit_signal(candles, index),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return records
|
return records
|
||||||
@@ -296,6 +340,7 @@ def _batch_forecast_records(
|
|||||||
*,
|
*,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
candles: list[Candle],
|
candles: list[Candle],
|
||||||
|
trend_candles: list[Candle],
|
||||||
feature_rows: list[list[float]],
|
feature_rows: list[list[float]],
|
||||||
closes: list[float],
|
closes: list[float],
|
||||||
entry: dict[str, Any],
|
entry: dict[str, Any],
|
||||||
@@ -380,6 +425,8 @@ def _batch_forecast_records(
|
|||||||
q50_percent=q50_percent,
|
q50_percent=q50_percent,
|
||||||
block_entry=False,
|
block_entry=False,
|
||||||
future_net_percent=future_net_percent,
|
future_net_percent=future_net_percent,
|
||||||
|
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
|
||||||
|
benchmark_exit=_benchmark_exit_signal(candles, index),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return records
|
return records
|
||||||
@@ -515,12 +562,14 @@ def _full_backtest(
|
|||||||
horizon: int,
|
horizon: int,
|
||||||
round_trip_cost: float,
|
round_trip_cost: float,
|
||||||
settings: Any,
|
settings: Any,
|
||||||
|
detail_limit: int = 50,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
positions: dict[str, dict[str, Any]] = {}
|
positions: dict[str, dict[str, Any]] = {}
|
||||||
trades: list[float] = []
|
trades: list[float] = []
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
max_hold = max(12, horizon * 8)
|
max_hold = max(12, horizon * 8)
|
||||||
stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0
|
stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0
|
||||||
|
stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True))
|
||||||
atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier)))
|
atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier)))
|
||||||
for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)):
|
for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)):
|
||||||
position = positions.get(record.symbol)
|
position = positions.get(record.symbol)
|
||||||
@@ -528,10 +577,15 @@ def _full_backtest(
|
|||||||
position["highest"] = max(position["highest"], record.close)
|
position["highest"] = max(position["highest"], record.close)
|
||||||
net_percent = _net_percent(position["entry_price"], record.close, round_trip_cost)
|
net_percent = _net_percent(position["entry_price"], record.close, round_trip_cost)
|
||||||
held = record.index - int(position["entry_index"])
|
held = record.index - int(position["entry_index"])
|
||||||
atr_stop = (
|
atr_stop_level = (
|
||||||
record.close <= position["highest"] - record.atr * atr_multiplier
|
position["highest"] - record.atr * atr_multiplier
|
||||||
if record.atr > 0 and position["highest"] > position["entry_price"]
|
if record.atr > 0 and position["highest"] > position["entry_price"]
|
||||||
else False
|
else None
|
||||||
|
)
|
||||||
|
atr_stop = bool(
|
||||||
|
atr_stop_level is not None
|
||||||
|
and record.close <= atr_stop_level
|
||||||
|
and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"])
|
||||||
)
|
)
|
||||||
weak_forecast = (
|
weak_forecast = (
|
||||||
record.expected_percent < thresholds.edge
|
record.expected_percent < thresholds.edge
|
||||||
@@ -539,7 +593,7 @@ def _full_backtest(
|
|||||||
or record.skill <= 0.0
|
or record.skill <= 0.0
|
||||||
)
|
)
|
||||||
exit_reason = ""
|
exit_reason = ""
|
||||||
if net_percent <= -stop_loss_percent:
|
if stop_loss_exit_enabled and net_percent <= -stop_loss_percent:
|
||||||
exit_reason = "stop_loss"
|
exit_reason = "stop_loss"
|
||||||
elif atr_stop:
|
elif atr_stop:
|
||||||
exit_reason = "atr_trailing_stop"
|
exit_reason = "atr_trailing_stop"
|
||||||
@@ -595,7 +649,98 @@ def _full_backtest(
|
|||||||
"entry_expected_percent": round(float(position["expected_percent"]), 4),
|
"entry_expected_percent": round(float(position["expected_percent"]), 4),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {**_stats(trades), "trades_detail": rows[-50:]}
|
return {
|
||||||
|
**_stats(trades),
|
||||||
|
"trades_detail": _limited_rows(rows, detail_limit),
|
||||||
|
"symbol_breakdown": _symbol_breakdown(rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _benchmark_backtest(
|
||||||
|
records: list[ForecastRecord],
|
||||||
|
*,
|
||||||
|
horizon: int,
|
||||||
|
round_trip_cost: float,
|
||||||
|
settings: Any,
|
||||||
|
detail_limit: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
positions: dict[str, dict[str, Any]] = {}
|
||||||
|
trades: list[float] = []
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
max_hold = max(12, horizon * 8)
|
||||||
|
stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0
|
||||||
|
stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True))
|
||||||
|
atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier)))
|
||||||
|
for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)):
|
||||||
|
position = positions.get(record.symbol)
|
||||||
|
if position is not None:
|
||||||
|
position["highest"] = max(position["highest"], record.close)
|
||||||
|
net_percent = _net_percent(position["entry_price"], record.close, round_trip_cost)
|
||||||
|
held = record.index - int(position["entry_index"])
|
||||||
|
atr_stop_level = (
|
||||||
|
position["highest"] - record.atr * atr_multiplier
|
||||||
|
if record.atr > 0 and position["highest"] > position["entry_price"]
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
atr_stop = bool(
|
||||||
|
atr_stop_level is not None
|
||||||
|
and record.close <= atr_stop_level
|
||||||
|
and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"])
|
||||||
|
)
|
||||||
|
exit_reason = ""
|
||||||
|
if stop_loss_exit_enabled and net_percent <= -stop_loss_percent:
|
||||||
|
exit_reason = "stop_loss"
|
||||||
|
elif atr_stop:
|
||||||
|
exit_reason = "atr_trailing_stop"
|
||||||
|
elif record.benchmark_exit:
|
||||||
|
exit_reason = "benchmark_exit"
|
||||||
|
elif held >= max_hold:
|
||||||
|
exit_reason = "max_hold"
|
||||||
|
if exit_reason:
|
||||||
|
trades.append(net_percent)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"symbol": record.symbol,
|
||||||
|
"entry_timestamp": position["timestamp"],
|
||||||
|
"exit_timestamp": record.timestamp,
|
||||||
|
"net_percent": round(net_percent, 4),
|
||||||
|
"reason": exit_reason,
|
||||||
|
"held_bars": held,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
positions.pop(record.symbol, None)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if record.symbol in positions:
|
||||||
|
continue
|
||||||
|
if record.benchmark_entry:
|
||||||
|
positions[record.symbol] = {
|
||||||
|
"entry_price": record.close,
|
||||||
|
"entry_index": record.index,
|
||||||
|
"timestamp": record.timestamp,
|
||||||
|
"highest": record.close,
|
||||||
|
}
|
||||||
|
for symbol, position in list(positions.items()):
|
||||||
|
tail = next((record for record in reversed(records) if record.symbol == symbol), None)
|
||||||
|
if tail is None:
|
||||||
|
continue
|
||||||
|
net_percent = _net_percent(position["entry_price"], tail.close, round_trip_cost)
|
||||||
|
trades.append(net_percent)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"symbol": symbol,
|
||||||
|
"entry_timestamp": position["timestamp"],
|
||||||
|
"exit_timestamp": tail.timestamp,
|
||||||
|
"net_percent": round(net_percent, 4),
|
||||||
|
"reason": "end_of_replay",
|
||||||
|
"held_bars": tail.index - int(position["entry_index"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**_stats(trades),
|
||||||
|
"trades_detail": _limited_rows(rows, detail_limit),
|
||||||
|
"symbol_breakdown": _symbol_breakdown(rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _walk_forward(
|
def _walk_forward(
|
||||||
@@ -607,6 +752,8 @@ def _walk_forward(
|
|||||||
min_trades: int,
|
min_trades: int,
|
||||||
horizon: int,
|
horizon: int,
|
||||||
folds: int,
|
folds: int,
|
||||||
|
round_trip_cost: float,
|
||||||
|
settings: Any,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
ordered = sorted(records, key=lambda item: item.timestamp)
|
ordered = sorted(records, key=lambda item: item.timestamp)
|
||||||
if folds < 2 or len(ordered) < folds * 20:
|
if folds < 2 or len(ordered) < folds * 20:
|
||||||
@@ -615,6 +762,7 @@ def _walk_forward(
|
|||||||
fold_size = max(1, len(timestamps) // folds)
|
fold_size = max(1, len(timestamps) // folds)
|
||||||
rows = []
|
rows = []
|
||||||
all_test_trades: list[float] = []
|
all_test_trades: list[float] = []
|
||||||
|
all_test_rows: list[dict[str, Any]] = []
|
||||||
for fold in range(1, folds):
|
for fold in range(1, folds):
|
||||||
test_start = timestamps[fold * fold_size]
|
test_start = timestamps[fold * fold_size]
|
||||||
test_end = timestamps[(fold + 1) * fold_size - 1] if fold < folds - 1 else timestamps[-1]
|
test_end = timestamps[(fold + 1) * fold_size - 1] if fold < folds - 1 else timestamps[-1]
|
||||||
@@ -631,20 +779,124 @@ def _walk_forward(
|
|||||||
if not train_results:
|
if not train_results:
|
||||||
continue
|
continue
|
||||||
selected = _choose_recommendation(train_results, min_trades=max(4, min_trades // 2))
|
selected = _choose_recommendation(train_results, min_trades=max(4, min_trades // 2))
|
||||||
test_trades = _selected_trades(test, selected.edge, selected.probability, selected.confidence, horizon)
|
test_backtest = _full_backtest(
|
||||||
|
test,
|
||||||
|
selected,
|
||||||
|
horizon=horizon,
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
settings=settings,
|
||||||
|
detail_limit=0,
|
||||||
|
)
|
||||||
|
test_rows = test_backtest.get("trades_detail", [])
|
||||||
|
test_trades = [float(row.get("net_percent", 0.0) or 0.0) for row in test_rows if isinstance(row, dict)]
|
||||||
all_test_trades.extend(test_trades)
|
all_test_trades.extend(test_trades)
|
||||||
|
all_test_rows.extend(test_rows)
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"fold": fold,
|
"fold": fold,
|
||||||
"train_records": len(train),
|
"train_records": len(train),
|
||||||
"test_records": len(test),
|
"test_records": len(test),
|
||||||
"thresholds": _result_dict(selected),
|
"thresholds": _result_dict(selected),
|
||||||
"test": _stats(test_trades),
|
"test": {key: value for key, value in test_backtest.items() if key != "trades_detail"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
summary = _stats(all_test_trades)
|
summary = _stats(all_test_trades)
|
||||||
summary["status"] = "ok" if summary["trades"] >= min_trades and summary["avg_net_percent"] > 0 else "warn"
|
summary["status"] = "ok" if summary["trades"] >= min_trades and summary["avg_net_percent"] > 0 else "warn"
|
||||||
return {"summary": summary, "folds": rows}
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"symbol_breakdown": _symbol_breakdown(all_test_rows),
|
||||||
|
"folds_with_trades": sum(1 for row in rows if int((row.get("test") or {}).get("trades", 0) or 0) > 0),
|
||||||
|
"folds": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _benchmark_walk_forward(
|
||||||
|
records: list[ForecastRecord],
|
||||||
|
*,
|
||||||
|
horizon: int,
|
||||||
|
folds: int,
|
||||||
|
round_trip_cost: float,
|
||||||
|
settings: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
ordered = sorted(records, key=lambda item: item.timestamp)
|
||||||
|
if folds < 2 or len(ordered) < folds * 20:
|
||||||
|
return {"summary": {"status": "insufficient"}, "symbol_breakdown": [], "folds_with_trades": 0, "folds": []}
|
||||||
|
timestamps = sorted({record.timestamp for record in ordered})
|
||||||
|
fold_size = max(1, len(timestamps) // folds)
|
||||||
|
rows = []
|
||||||
|
all_test_rows: list[dict[str, Any]] = []
|
||||||
|
for fold in range(1, folds):
|
||||||
|
test_start = timestamps[fold * fold_size]
|
||||||
|
test_end = timestamps[(fold + 1) * fold_size - 1] if fold < folds - 1 else timestamps[-1]
|
||||||
|
test = [record for record in ordered if test_start <= record.timestamp <= test_end]
|
||||||
|
test_backtest = _benchmark_backtest(
|
||||||
|
test,
|
||||||
|
horizon=horizon,
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
settings=settings,
|
||||||
|
detail_limit=0,
|
||||||
|
)
|
||||||
|
test_rows = test_backtest.get("trades_detail", [])
|
||||||
|
all_test_rows.extend(test_rows)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"fold": fold,
|
||||||
|
"test_records": len(test),
|
||||||
|
"test": {key: value for key, value in test_backtest.items() if key != "trades_detail"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
trades = [float(row.get("net_percent", 0.0) or 0.0) for row in all_test_rows if isinstance(row, dict)]
|
||||||
|
summary = _stats(trades)
|
||||||
|
summary["status"] = "ok" if summary["trades"] > 0 else "no_trades"
|
||||||
|
return {
|
||||||
|
"name": "trend_macd_baseline",
|
||||||
|
"summary": summary,
|
||||||
|
"symbol_breakdown": _symbol_breakdown(all_test_rows),
|
||||||
|
"folds_with_trades": sum(1 for row in rows if int((row.get("test") or {}).get("trades", 0) or 0) > 0),
|
||||||
|
"folds": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _quality_gate(
|
||||||
|
*,
|
||||||
|
walk_forward: dict[str, Any],
|
||||||
|
benchmark: dict[str, Any],
|
||||||
|
min_oos_trades: int,
|
||||||
|
min_oos_symbols: int,
|
||||||
|
max_symbol_share: float,
|
||||||
|
min_oos_folds: int,
|
||||||
|
min_profit_factor: float,
|
||||||
|
min_benchmark_edge: float,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
summary = walk_forward.get("summary") if isinstance(walk_forward.get("summary"), dict) else {}
|
||||||
|
benchmark_summary = benchmark.get("summary") if isinstance(benchmark.get("summary"), dict) else {}
|
||||||
|
breakdown = walk_forward.get("symbol_breakdown") if isinstance(walk_forward.get("symbol_breakdown"), list) else []
|
||||||
|
symbols_with_trades = sum(1 for row in breakdown if int(row.get("trades", 0) or 0) > 0)
|
||||||
|
max_share = max((float(row.get("trade_share", 0.0) or 0.0) for row in breakdown), default=0.0)
|
||||||
|
oos_total = float(summary.get("total_net_percent", 0.0) or 0.0)
|
||||||
|
benchmark_total = float(benchmark_summary.get("total_net_percent", 0.0) or 0.0)
|
||||||
|
checks = [
|
||||||
|
_gate_check("oos_trades", int(summary.get("trades", 0) or 0), min_oos_trades, int(summary.get("trades", 0) or 0) >= min_oos_trades),
|
||||||
|
_gate_check("oos_symbols", symbols_with_trades, min_oos_symbols, symbols_with_trades >= min_oos_symbols),
|
||||||
|
_gate_check("max_symbol_share", round(max_share, 4), max_symbol_share, max_share <= max_symbol_share if breakdown else False),
|
||||||
|
_gate_check("folds_with_trades", int(walk_forward.get("folds_with_trades", 0) or 0), min_oos_folds, int(walk_forward.get("folds_with_trades", 0) or 0) >= min_oos_folds),
|
||||||
|
_gate_check("oos_avg_net_positive", float(summary.get("avg_net_percent", 0.0) or 0.0), "> 0", float(summary.get("avg_net_percent", 0.0) or 0.0) > 0),
|
||||||
|
_gate_check("oos_profit_factor", float(summary.get("profit_factor", 0.0) or 0.0), min_profit_factor, float(summary.get("profit_factor", 0.0) or 0.0) >= min_profit_factor),
|
||||||
|
_gate_check("beats_benchmark_total", round(oos_total - benchmark_total, 4), min_benchmark_edge, (oos_total - benchmark_total) > min_benchmark_edge),
|
||||||
|
]
|
||||||
|
passed = all(bool(row["passed"]) for row in checks)
|
||||||
|
return {
|
||||||
|
"status": "pass" if passed else "fail",
|
||||||
|
"passed": passed,
|
||||||
|
"checks": checks,
|
||||||
|
"oos_summary": summary,
|
||||||
|
"benchmark_summary": benchmark_summary,
|
||||||
|
"symbol_breakdown": breakdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gate_check(name: str, value: Any, required: Any, passed: bool) -> dict[str, Any]:
|
||||||
|
return {"name": name, "value": value, "required": required, "passed": bool(passed)}
|
||||||
|
|
||||||
|
|
||||||
def _probability_calibration(records: list[ForecastRecord]) -> dict[str, Any]:
|
def _probability_calibration(records: list[ForecastRecord]) -> dict[str, Any]:
|
||||||
@@ -691,12 +943,89 @@ def _candidate_allows(record: ForecastRecord, edge: float, probability: float, c
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _benchmark_entry_signal(candles: list[Candle], trend_candles: list[Candle], index: int) -> bool:
|
||||||
|
if index <= 0 or index >= len(candles):
|
||||||
|
return False
|
||||||
|
previous = candles[index - 1]
|
||||||
|
current = candles[index]
|
||||||
|
rsi = current.rsi_14
|
||||||
|
return bool(
|
||||||
|
_daily_trend_ok(trend_candles, current.timestamp)
|
||||||
|
and _macd_crossed_up(previous, current)
|
||||||
|
and current.ema_50 is not None
|
||||||
|
and current.close > current.ema_50
|
||||||
|
and rsi is not None
|
||||||
|
and 45.0 <= rsi <= 65.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _benchmark_exit_signal(candles: list[Candle], index: int) -> bool:
|
||||||
|
if index <= 0 or index >= len(candles):
|
||||||
|
return False
|
||||||
|
previous = candles[index - 1]
|
||||||
|
current = candles[index]
|
||||||
|
return bool(_macd_crossed_down(previous, current) or (current.ema_50 is not None and current.close < current.ema_50))
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_trend_ok(trend_candles: list[Candle], timestamp: int) -> bool:
|
||||||
|
for candle in reversed(trend_candles):
|
||||||
|
if candle.timestamp > timestamp:
|
||||||
|
continue
|
||||||
|
return bool(
|
||||||
|
candle.ema_50 is not None
|
||||||
|
and candle.ema_200 is not None
|
||||||
|
and candle.close > candle.ema_200
|
||||||
|
and candle.ema_50 > candle.ema_200
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _macd_crossed_up(previous: Candle, current: Candle) -> bool:
|
||||||
|
if None in (previous.macd, previous.macd_signal, current.macd, current.macd_signal):
|
||||||
|
return False
|
||||||
|
return bool(previous.macd <= previous.macd_signal and current.macd > current.macd_signal)
|
||||||
|
|
||||||
|
|
||||||
|
def _macd_crossed_down(previous: Candle, current: Candle) -> bool:
|
||||||
|
if None in (previous.macd, previous.macd_signal, current.macd, current.macd_signal):
|
||||||
|
return False
|
||||||
|
return bool(previous.macd >= previous.macd_signal and current.macd < current.macd_signal)
|
||||||
|
|
||||||
|
|
||||||
def _net_percent(entry_price: float, exit_price: float, round_trip_cost: float) -> float:
|
def _net_percent(entry_price: float, exit_price: float, round_trip_cost: float) -> float:
|
||||||
if entry_price <= 0 or exit_price <= 0:
|
if entry_price <= 0 or exit_price <= 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
return (math.exp(math.log(exit_price / entry_price) - round_trip_cost) - 1.0) * 100.0
|
return (math.exp(math.log(exit_price / entry_price) - round_trip_cost) - 1.0) * 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def _limited_rows(rows: list[dict[str, Any]], detail_limit: int) -> list[dict[str, Any]]:
|
||||||
|
if detail_limit <= 0:
|
||||||
|
return rows
|
||||||
|
return rows[-detail_limit:]
|
||||||
|
|
||||||
|
|
||||||
|
def _symbol_breakdown(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
by_symbol: dict[str, list[float]] = {}
|
||||||
|
for row in rows:
|
||||||
|
symbol = str(row.get("symbol", ""))
|
||||||
|
if not symbol:
|
||||||
|
continue
|
||||||
|
by_symbol.setdefault(symbol, []).append(float(row.get("net_percent", 0.0) or 0.0))
|
||||||
|
total_trades = sum(len(values) for values in by_symbol.values())
|
||||||
|
result = []
|
||||||
|
for symbol in sorted(by_symbol):
|
||||||
|
values = by_symbol[symbol]
|
||||||
|
stats = _stats(values)
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"symbol": symbol,
|
||||||
|
**stats,
|
||||||
|
"trade_share": round(len(values) / total_trades, 4) if total_trades else 0.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _stats(values: list[float]) -> dict[str, Any]:
|
def _stats(values: list[float]) -> dict[str, Any]:
|
||||||
wins = sum(1 for value in values if value > 0)
|
wins = sum(1 for value in values if value > 0)
|
||||||
total = sum(values)
|
total = sum(values)
|
||||||
@@ -799,6 +1128,51 @@ def _choose_recommendation(results: list[CalibrationResult], *, min_trades: int)
|
|||||||
return viable[0] if viable else results[0]
|
return viable[0] if viable else results[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _choose_replay_recommendation(
|
||||||
|
results: list[CalibrationResult],
|
||||||
|
records: list[ForecastRecord],
|
||||||
|
*,
|
||||||
|
min_trades: int,
|
||||||
|
min_full_replay_trades: int,
|
||||||
|
horizon: int,
|
||||||
|
round_trip_cost: float,
|
||||||
|
settings: Any,
|
||||||
|
) -> tuple[CalibrationResult, dict[str, Any]]:
|
||||||
|
fallback = _choose_recommendation(results, min_trades=min_trades)
|
||||||
|
fallback_replay = _full_backtest(records, fallback, horizon=horizon, round_trip_cost=round_trip_cost, settings=settings)
|
||||||
|
if min_full_replay_trades <= 0:
|
||||||
|
return fallback, fallback_replay
|
||||||
|
|
||||||
|
viable: list[tuple[CalibrationResult, dict[str, Any]]] = []
|
||||||
|
for result in results:
|
||||||
|
if result.trades < min(4, min_trades):
|
||||||
|
continue
|
||||||
|
if result.average_net_percent <= 0 or result.total_net_percent <= 0 or result.profit_factor < 1.05:
|
||||||
|
continue
|
||||||
|
replay = _full_backtest(records, result, horizon=horizon, round_trip_cost=round_trip_cost, settings=settings)
|
||||||
|
if int(replay.get("trades", 0) or 0) < min_full_replay_trades:
|
||||||
|
continue
|
||||||
|
if float(replay.get("avg_net_percent", 0.0) or 0.0) <= 0:
|
||||||
|
continue
|
||||||
|
if float(replay.get("profit_factor", 0.0) or 0.0) < 1.05:
|
||||||
|
continue
|
||||||
|
viable.append((result, replay))
|
||||||
|
|
||||||
|
if not viable:
|
||||||
|
return fallback, fallback_replay
|
||||||
|
viable.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
item[0].score,
|
||||||
|
float(item[1].get("avg_net_percent", 0.0) or 0.0),
|
||||||
|
float(item[1].get("total_net_percent", 0.0) or 0.0),
|
||||||
|
int(item[1].get("trades", 0) or 0),
|
||||||
|
item[0].confidence,
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return viable[0]
|
||||||
|
|
||||||
|
|
||||||
def _forecast_confidence(expected_return: float, probability_up: float, skill: float, min_edge: float) -> float:
|
def _forecast_confidence(expected_return: float, probability_up: float, skill: float, min_edge: float) -> float:
|
||||||
expected_return = max(0.0, expected_return)
|
expected_return = max(0.0, expected_return)
|
||||||
skill = max(0.0, skill)
|
skill = max(0.0, skill)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
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 = "",
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
param(
|
param(
|
||||||
[int]$MinReplayTrades = 8,
|
[int]$MinReplayTrades = 8,
|
||||||
[int]$MaxAttempts = 0,
|
[int]$MaxAttempts = 0,
|
||||||
[string]$Symbols = "BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT",
|
[string]$Symbols = "",
|
||||||
[int]$Limit = 3000,
|
[int]$Limit = 3000,
|
||||||
[switch]$DeployToPi,
|
[switch]$DeployToPi,
|
||||||
[string]$PiHost = "192.168.0.185",
|
[string]$PiHost = "192.168.0.185",
|
||||||
@@ -18,6 +18,7 @@ $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
|||||||
$RuntimeDir = Join-Path $RepoRoot "runtime"
|
$RuntimeDir = Join-Path $RepoRoot "runtime"
|
||||||
$LoopLog = Join-Path $RuntimeDir "torch_retrain_until_replay8.log"
|
$LoopLog = Join-Path $RuntimeDir "torch_retrain_until_replay8.log"
|
||||||
$GuardReport = Join-Path $RuntimeDir "torch_retrain_guard.json"
|
$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"
|
$Runner = Join-Path $RepoRoot "tools\run_torch_retrain.ps1"
|
||||||
New-Item -ItemType Directory -Force -Path $RuntimeDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $RuntimeDir | Out-Null
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ function Read-GuardSummary {
|
|||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
Accepted = $false
|
Accepted = $false
|
||||||
Reason = "guard_report_missing"
|
Reason = "guard_report_missing"
|
||||||
ReplayTrades = 0
|
CandidateReplayTrades = 0
|
||||||
|
CurrentReplayTrades = 0
|
||||||
WalkForwardTrades = 0
|
WalkForwardTrades = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +56,8 @@ function Read-GuardSummary {
|
|||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
Accepted = [bool]$payload.accepted
|
Accepted = [bool]$payload.accepted
|
||||||
Reason = [string]$payload.reason
|
Reason = [string]$payload.reason
|
||||||
ReplayTrades = ConvertTo-IntOrZero $payload.candidate.full_replay.trades
|
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
|
WalkForwardTrades = ConvertTo-IntOrZero $payload.candidate.walk_forward_summary.trades
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,14 +65,47 @@ function Read-GuardSummary {
|
|||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
Accepted = $false
|
Accepted = $false
|
||||||
Reason = "guard_report_unreadable"
|
Reason = "guard_report_unreadable"
|
||||||
ReplayTrades = 0
|
CandidateReplayTrades = 0
|
||||||
|
CurrentReplayTrades = 0
|
||||||
WalkForwardTrades = 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
|
$attempt = 0
|
||||||
while ($true) {
|
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
|
$attempt += 1
|
||||||
if ($SeedStart -gt 0) {
|
if ($SeedStart -gt 0) {
|
||||||
$attemptSeed = $SeedStart + $attempt - 1
|
$attemptSeed = $SeedStart + $attempt - 1
|
||||||
@@ -83,10 +119,12 @@ while ($true) {
|
|||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-ExecutionPolicy", "Bypass",
|
"-ExecutionPolicy", "Bypass",
|
||||||
"-File", $Runner,
|
"-File", $Runner,
|
||||||
"-Symbols", $Symbols,
|
|
||||||
"-Limit", $Limit.ToString(),
|
"-Limit", $Limit.ToString(),
|
||||||
"-Seed", $attemptSeed.ToString()
|
"-Seed", $attemptSeed.ToString()
|
||||||
)
|
)
|
||||||
|
if ($Symbols) {
|
||||||
|
$runnerArgs += @("-Symbols", $Symbols)
|
||||||
|
}
|
||||||
if ($DeployToPi) {
|
if ($DeployToPi) {
|
||||||
$runnerArgs += "-DeployToPi"
|
$runnerArgs += "-DeployToPi"
|
||||||
if ($PiHost) { $runnerArgs += @("-PiHost", $PiHost) }
|
if ($PiHost) { $runnerArgs += @("-PiHost", $PiHost) }
|
||||||
@@ -98,10 +136,10 @@ while ($true) {
|
|||||||
& powershell.exe @runnerArgs 2>&1 | Tee-Object -FilePath $LoopLog -Append
|
& powershell.exe @runnerArgs 2>&1 | Tee-Object -FilePath $LoopLog -Append
|
||||||
$runnerExit = $LASTEXITCODE
|
$runnerExit = $LASTEXITCODE
|
||||||
$summary = Read-GuardSummary
|
$summary = Read-GuardSummary
|
||||||
Write-LoopLog "Attempt $attempt finished; runner_exit=$runnerExit accepted=$($summary.Accepted) reason=$($summary.Reason) full_replay.trades=$($summary.ReplayTrades) walk_forward.trades=$($summary.WalkForwardTrades)."
|
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.ReplayTrades -ge $MinReplayTrades) {
|
if ($summary.Accepted -and (Read-ActiveValidationPassed)) {
|
||||||
Write-LoopLog "Stop condition reached: full_replay.trades=$($summary.ReplayTrades) >= $MinReplayTrades."
|
Write-LoopLog "Stop condition reached: accepted candidate passed honest validation with full_replay.trades=$($summary.CandidateReplayTrades)."
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,9 +190,11 @@ try {
|
|||||||
$calibrationBaseArgs = @(
|
$calibrationBaseArgs = @(
|
||||||
"-u",
|
"-u",
|
||||||
"tools\calibrate_torch_thresholds.py",
|
"tools\calibrate_torch_thresholds.py",
|
||||||
"--limit", "2000",
|
"--limit", "3000",
|
||||||
"--calibration-window", "720",
|
"--calibration-window", "1200",
|
||||||
"--min-trades", "12"
|
"--min-trades", "60",
|
||||||
|
"--walk-forward-folds", "8",
|
||||||
|
"--confidence-grid", "0.40"
|
||||||
)
|
)
|
||||||
if ($Symbols) { $calibrationBaseArgs += @("--symbols", $Symbols) }
|
if ($Symbols) { $calibrationBaseArgs += @("--symbols", $Symbols) }
|
||||||
if ($EnvFile) { $calibrationBaseArgs += @("--env", $EnvFile) }
|
if ($EnvFile) { $calibrationBaseArgs += @("--env", $EnvFile) }
|
||||||
|
|||||||
@@ -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